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
304 lines
9.6 KiB
Go
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>
|
|
`
|