431 lines
18 KiB
Go
431 lines
18 KiB
Go
package services
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"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"
|
|
apiBase := strings.TrimSuffix(s.config.APIBaseURL, "/api/v1")
|
|
verifyURL := fmt.Sprintf("%s/api/v1/auth/verify?token=%s", apiBase, url.QueryEscape(token))
|
|
|
|
title := "Email Verification"
|
|
header := fmt.Sprintf("Hey %s!", toName)
|
|
if toName == "" {
|
|
header = "Hey there!"
|
|
}
|
|
|
|
content := `
|
|
<p>Welcome to Sojorn — 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 := `
|
|
<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!\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, url.QueryEscape(token))
|
|
|
|
title := "Password Reset"
|
|
header := "Reset your password"
|
|
content := fmt.Sprintf(`
|
|
<p>Hey %s,</p>
|
|
<p>You requested a password reset for your Sojorn account. Click the button below to set a new password:</p>
|
|
`, toName)
|
|
|
|
footer := `
|
|
<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 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 {
|
|
subject := "Your Sojorn account has been suspended"
|
|
|
|
title := "Account Suspended"
|
|
header := "Your account has been suspended"
|
|
content := fmt.Sprintf(`
|
|
<p>Hi %s,</p>
|
|
<p>After a review of your recent activity, your Sojorn account has been <strong>permanently suspended</strong> for violating our <a href="https://sojorn.net/terms" style="color: #4338CA;">Community Guidelines</a>.</p>
|
|
<p style="background: #FEF2F2; border-left: 4px solid #EF4444; padding: 12px 16px; border-radius: 4px; margin: 16px 0;">
|
|
<strong>Reason:</strong> %s
|
|
</p>
|
|
<p>What this means:</p>
|
|
<ul style="text-align: left; color: #4B5563;">
|
|
<li>You will no longer be able to log in or post content</li>
|
|
<li>Your profile will not be visible to other users</li>
|
|
</ul>
|
|
<p>If you believe this action was taken in error, you can submit an appeal. Our team will review your case within 5 business days.</p>
|
|
`, toName, reason)
|
|
|
|
appealURL := "mailto:appeals@sojorn.net?subject=Account%20Appeal"
|
|
footer := `<p style="color: #9CA3AF; font-size: 12px; margin-top: 16px;">You can also reply directly to this email to submit your appeal.</p>`
|
|
|
|
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 {
|
|
subject := "Your Sojorn account has been temporarily suspended"
|
|
|
|
title := "Account Temporarily Suspended"
|
|
header := "Your account has been temporarily suspended"
|
|
content := fmt.Sprintf(`
|
|
<p>Hi %s,</p>
|
|
<p>Your Sojorn account has been <strong>temporarily suspended for %s</strong> due to a violation of our <a href="https://sojorn.net/terms" style="color: #4338CA;">Community Guidelines</a>.</p>
|
|
<p style="background: #FFFBEB; border-left: 4px solid #F59E0B; padding: 12px 16px; border-radius: 4px; margin: 16px 0;">
|
|
<strong>Reason:</strong> %s
|
|
</p>
|
|
<p>Your account will be automatically restored after the suspension period. Further violations may result in a permanent ban.</p>
|
|
`, toName, duration, reason)
|
|
|
|
footer := ``
|
|
htmlBody := s.buildHTMLEmail(title, header, content, "https://sojorn.net/terms", "Review Community Guidelines", footer)
|
|
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) SendPostRemovalEmail(toEmail, toName, reason string, strikeCount int) error {
|
|
subject := "A post on your Sojorn account was removed"
|
|
|
|
title := "Content Removed"
|
|
header := "Your post has been removed"
|
|
|
|
strikeWarning := ""
|
|
if strikeCount >= 5 {
|
|
strikeWarning = `<p style="color: #EF4444; font-weight: 600;">Warning: You are close to a permanent suspension. Please review our Community Guidelines carefully.</p>`
|
|
} else if strikeCount >= 3 {
|
|
strikeWarning = `<p style="color: #F59E0B; font-weight: 600;">Continued violations may result in a temporary or permanent suspension of your account.</p>`
|
|
}
|
|
|
|
content := fmt.Sprintf(`
|
|
<p>Hi %s,</p>
|
|
<p>One of your posts has been removed by our moderation team for violating our <a href="https://sojorn.net/terms" style="color: #4338CA;">Community Guidelines</a>.</p>
|
|
<p style="background: #FEF2F2; border-left: 4px solid #EF4444; padding: 12px 16px; border-radius: 4px; margin: 16px 0;">
|
|
<strong>Reason:</strong> %s
|
|
</p>
|
|
<p>This is <strong>strike %d</strong> on your account. Strikes are tracked over a 30-day rolling window.</p>
|
|
%s
|
|
<p>If you believe this was in error, you can reply to this email.</p>
|
|
`, toName, reason, strikeCount, strikeWarning)
|
|
|
|
footer := ``
|
|
htmlBody := s.buildHTMLEmail(title, header, content, "https://sojorn.net/terms", "Review Community Guidelines", footer)
|
|
textBody := fmt.Sprintf("Hi %s,\n\nOne of your posts has been removed.\n\nReason: %s\nStrike %d on your account.\n\nPlease review our Community Guidelines.\n\n— The Sojorn Team", toName, reason, strikeCount)
|
|
|
|
return s.sendEmail(toEmail, toName, subject, htmlBody, textBody)
|
|
}
|
|
|
|
func (s *EmailService) SendAccountRestoredEmail(toEmail, toName, reason string) error {
|
|
subject := "Your Sojorn account has been restored"
|
|
|
|
title := "Account Restored"
|
|
header := "Welcome back!"
|
|
content := fmt.Sprintf(`
|
|
<p>Hi %s,</p>
|
|
<p>Great news — your Sojorn account has been <strong>restored</strong> and is fully active again.</p>
|
|
<p style="background: #F0FDF4; border-left: 4px solid #22C55E; padding: 12px 16px; border-radius: 4px; margin: 16px 0;">
|
|
<strong>Note:</strong> %s
|
|
</p>
|
|
<p>All of your previous posts and comments have been restored and are visible again. Please review our <a href="https://sojorn.net/terms" style="color: #4338CA;">Community Guidelines</a> to ensure continued access to the platform.</p>
|
|
`, toName, reason)
|
|
|
|
footer := ``
|
|
htmlBody := s.buildHTMLEmail(title, header, content, "https://sojorn.net", "Open Sojorn", footer)
|
|
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) AddSubscriber(email, name string) {
|
|
// SendPulse Addressbook API implementation omitted for brevity, focusing on email first
|
|
// Endpoint: POST /addressbooks/{id}/emails
|
|
}
|
|
|
|
func (s *EmailService) buildHTMLEmail(title, header, content, buttonURL, buttonText, footer string) string {
|
|
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; 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>
|
|
|
|
<!-- 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
|
|
</td></tr>
|
|
|
|
<!-- 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;">© 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> •
|
|
<a href="https://sojorn.net/privacy" style="color: #9CA3AF; text-decoration: none;">Privacy</a> •
|
|
<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)
|
|
}
|