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(`

Welcome to Sojorn, %s

Thanks for signing up! To get started, please verify your email address by clicking the button below.

Verify Email Address

If the button doesn't work, copy and paste this link into your browser:

%s
`, 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(`

Reset Password for %s

You requested a password reset. Click the link below to set a new password:

Reset Password

This link expires in 1 hour.

If you did not request this, please ignore this email.

`, 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 }