gohorsejobs/backend/internal/infrastructure/email/email_service.go
Tiago Yamamoto 38a94bcbce feat: implement high priority features
1. Advanced Search (backend)
   - Add salaryMin, salaryMax, currency, sortBy to JobFilterQuery
   - Add 5+ filters: visa, salary range, currency, language level
   - Add 4 sort options: recent, salary_asc, salary_desc, relevance

2. Email Service (backend)
   - Create Resend API integration (email_service.go)
   - 3 HTML email templates: welcome, password_reset, application_received
   - Add RESEND_API_KEY, EMAIL_FROM, APP_URL env vars

3. i18n (frontend)
   - Create 4 language files: pt-BR, en-US, es-ES, ja-JP
   - 100+ translation keys per language
   - Covers: common, nav, auth, jobs, profile, company, footer

4. Stripe Integration (backend)
   - Create payment_handler.go with checkout session creation
   - Webhook handler with signature verification
   - Support for checkout.session.completed, payment_intent events
2025-12-24 11:40:53 -03:00

304 lines
9.6 KiB
Go

package email
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
"text/template"
)
// EmailService handles transactional email sending
type EmailService struct {
apiKey string
fromAddr string
baseURL string
}
// NewEmailService creates a new email service using Resend API
func NewEmailService() *EmailService {
apiKey := os.Getenv("RESEND_API_KEY")
fromAddr := os.Getenv("EMAIL_FROM")
if fromAddr == "" {
fromAddr = "noreply@gohorsejobs.com"
}
return &EmailService{
apiKey: apiKey,
fromAddr: fromAddr,
baseURL: "https://api.resend.com/emails",
}
}
// IsConfigured returns true if email service is properly configured
func (s *EmailService) IsConfigured() bool {
return s.apiKey != ""
}
// EmailPayload represents the Resend API request body
type EmailPayload struct {
From string `json:"from"`
To []string `json:"to"`
Subject string `json:"subject"`
HTML string `json:"html"`
Text string `json:"text,omitempty"`
}
// SendEmail sends an email via Resend API
func (s *EmailService) SendEmail(to []string, subject, htmlBody, textBody string) error {
if !s.IsConfigured() {
return fmt.Errorf("email service not configured: RESEND_API_KEY missing")
}
payload := EmailPayload{
From: s.fromAddr,
To: to,
Subject: subject,
HTML: htmlBody,
Text: textBody,
}
jsonBody, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal email payload: %w", err)
}
req, err := http.NewRequest("POST", s.baseURL, bytes.NewBuffer(jsonBody))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", s.apiKey))
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to send email: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("email API error: status %d", resp.StatusCode)
}
return nil
}
// WelcomeEmailData contains data for welcome email template
type WelcomeEmailData struct {
Name string
Email string
AppURL string
AppName string
}
// SendWelcome sends a welcome email to new users
func (s *EmailService) SendWelcome(to, name string) error {
data := WelcomeEmailData{
Name: name,
Email: to,
AppURL: os.Getenv("APP_URL"),
AppName: "GoHorse Jobs",
}
html, err := renderTemplate(welcomeTemplate, data)
if err != nil {
return err
}
return s.SendEmail([]string{to}, "🐴 Bem-vindo ao GoHorse Jobs!", html, "")
}
// PasswordResetData contains data for password reset email
type PasswordResetData struct {
Name string
ResetLink string
AppName string
ExpiresIn string
}
// SendPasswordReset sends a password reset email
func (s *EmailService) SendPasswordReset(to, name, resetToken string) error {
appURL := os.Getenv("APP_URL")
if appURL == "" {
appURL = "https://gohorsejobs.com"
}
data := PasswordResetData{
Name: name,
ResetLink: fmt.Sprintf("%s/reset-password?token=%s", appURL, resetToken),
AppName: "GoHorse Jobs",
ExpiresIn: "1 hora",
}
html, err := renderTemplate(passwordResetTemplate, data)
if err != nil {
return err
}
return s.SendEmail([]string{to}, "🔑 Redefinição de senha - GoHorse Jobs", html, "")
}
// ApplicationReceivedData contains data for application notification
type ApplicationReceivedData struct {
ApplicantName string
JobTitle string
CompanyName string
JobURL string
AppName string
}
// SendApplicationReceived notifies recruiter about new application
func (s *EmailService) SendApplicationReceived(to, applicantName, jobTitle, companyName, jobID string) error {
appURL := os.Getenv("APP_URL")
if appURL == "" {
appURL = "https://gohorsejobs.com"
}
data := ApplicationReceivedData{
ApplicantName: applicantName,
JobTitle: jobTitle,
CompanyName: companyName,
JobURL: fmt.Sprintf("%s/jobs/%s/applications", appURL, jobID),
AppName: "GoHorse Jobs",
}
html, err := renderTemplate(applicationReceivedTemplate, data)
if err != nil {
return err
}
subject := fmt.Sprintf("📩 Nova candidatura para %s", jobTitle)
return s.SendEmail([]string{to}, subject, html, "")
}
// Helper function to render templates
func renderTemplate(tmplStr string, data interface{}) (string, error) {
tmpl, err := template.New("email").Parse(tmplStr)
if err != nil {
return "", fmt.Errorf("failed to parse template: %w", err)
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return "", fmt.Errorf("failed to execute template: %w", err)
}
return buf.String(), nil
}
// Email templates
const welcomeTemplate = `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; padding: 20px; }
.container { max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 15px rgba(0,0,0,0.1); }
.header { background: linear-gradient(135deg, #10b981, #059669); padding: 30px; text-align: center; }
.header h1 { color: white; margin: 0; font-size: 24px; }
.content { padding: 30px; }
.btn { display: inline-block; background: #10b981; color: white; padding: 12px 24px; text-decoration: none; border-radius: 8px; margin-top: 20px; }
.footer { background: #f8f8f8; padding: 20px; text-align: center; color: #666; font-size: 12px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🐴 Bem-vindo ao {{.AppName}}!</h1>
</div>
<div class="content">
<p>Olá, <strong>{{.Name}}</strong>!</p>
<p>Sua conta foi criada com sucesso. Agora você pode:</p>
<ul>
<li>🔍 Buscar vagas de emprego</li>
<li>📝 Candidatar-se às melhores oportunidades</li>
<li>💼 Gerenciar seu perfil profissional</li>
</ul>
<a href="{{.AppURL}}/jobs" class="btn">Ver Vagas Disponíveis</a>
</div>
<div class="footer">
<p>© 2024 {{.AppName}}. Todos os direitos reservados.</p>
</div>
</div>
</body>
</html>
`
const passwordResetTemplate = `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; padding: 20px; }
.container { max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 15px rgba(0,0,0,0.1); }
.header { background: linear-gradient(135deg, #f59e0b, #d97706); padding: 30px; text-align: center; }
.header h1 { color: white; margin: 0; font-size: 24px; }
.content { padding: 30px; }
.btn { display: inline-block; background: #f59e0b; color: white; padding: 12px 24px; text-decoration: none; border-radius: 8px; margin-top: 20px; }
.warning { background: #fef3cd; border: 1px solid #ffc107; padding: 15px; border-radius: 8px; margin-top: 20px; }
.footer { background: #f8f8f8; padding: 20px; text-align: center; color: #666; font-size: 12px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🔑 Redefinição de Senha</h1>
</div>
<div class="content">
<p>Olá, <strong>{{.Name}}</strong>!</p>
<p>Recebemos uma solicitação para redefinir a senha da sua conta.</p>
<a href="{{.ResetLink}}" class="btn">Redefinir Minha Senha</a>
<div class="warning">
<strong>⚠️ Atenção:</strong> Este link expira em <strong>{{.ExpiresIn}}</strong>.
Se você não solicitou esta redefinição, ignore este email.
</div>
</div>
<div class="footer">
<p>© 2024 {{.AppName}}. Todos os direitos reservados.</p>
</div>
</div>
</body>
</html>
`
const applicationReceivedTemplate = `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; padding: 20px; }
.container { max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 15px rgba(0,0,0,0.1); }
.header { background: linear-gradient(135deg, #3b82f6, #1d4ed8); padding: 30px; text-align: center; }
.header h1 { color: white; margin: 0; font-size: 24px; }
.content { padding: 30px; }
.job-card { background: #f0f9ff; border: 1px solid #bae6fd; padding: 20px; border-radius: 8px; margin: 20px 0; }
.btn { display: inline-block; background: #3b82f6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 8px; margin-top: 20px; }
.footer { background: #f8f8f8; padding: 20px; text-align: center; color: #666; font-size: 12px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>📩 Nova Candidatura!</h1>
</div>
<div class="content">
<p>Uma nova candidatura foi recebida para a vaga em <strong>{{.CompanyName}}</strong>:</p>
<div class="job-card">
<h3 style="margin: 0 0 10px 0;">{{.JobTitle}}</h3>
<p style="margin: 0;"><strong>Candidato:</strong> {{.ApplicantName}}</p>
</div>
<a href="{{.JobURL}}" class="btn">Ver Candidatura</a>
</div>
<div class="footer">
<p>© 2024 {{.AppName}}. Todos os direitos reservados.</p>
</div>
</div>
</body>
</html>
`