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 := `
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 := `|
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 := `|
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.
` 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(`Hi %s,
After a review of your recent activity, your Sojorn account has been permanently suspended for violating our Community Guidelines.
Reason: %s
What this means:
If you believe this action was taken in error, you can submit an appeal. Our team will review your case within 5 business days.
`, 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 { 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. Further violations may result in a permanent ban.
`, 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 = `Warning: You are close to a permanent suspension. Please review our Community Guidelines carefully.
` } else if strikeCount >= 3 { strikeWarning = `Continued violations may result in a temporary or permanent suspension of your account.
` } content := fmt.Sprintf(`Hi %s,
One of your posts has been removed by our moderation team for violating our Community Guidelines.
Reason: %s
This is strike %d on your account. Strikes are tracked over a 30-day rolling window.
%sIf you believe this was in error, you can reply to this email.
`, 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) 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(`