package email import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "log" "net/http" "strings" "time" "github.com/google/uuid" "github.com/descrybe/descrybe-v2/apps/api/internal/platformsettings" "github.com/descrybe/descrybe-v2/apps/api/internal/security" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) // EnvConfig holds process-level email defaults (platform fallback / dry-run). type EnvConfig struct { AppEncryptionKey string CredentialsEncryptionKey string TokenSigningSecret string DatabaseURL string PublicAPIURL string WebOrigin string EmailDryRun bool ResendAPIKey string SMTPHost string SMTPPort string SMTPUser string SMTPPassword string SMTPFrom string SendRPM int SendRPH int } type Service struct { Pool *pgxpool.Pool Key []byte Env EnvConfig HTTPClient *http.Client rpm *slidingLimiter rph *slidingLimiter // Platform is optional; when set, Resend/SMTP/dry-run prefer admin settings over Env. Platform *platformsettings.Service } func NewService(pool *pgxpool.Pool, env EnvConfig) *Service { keyMaterial := firstNonEmpty(env.AppEncryptionKey, env.CredentialsEncryptionKey, env.TokenSigningSecret) rpm := env.SendRPM if rpm <= 0 { rpm = 30 } rph := env.SendRPH if rph <= 0 { rph = 500 } return &Service{ Pool: pool, Key: DeriveKey(keyMaterial, env.DatabaseURL), Env: env, HTTPClient: &http.Client{ Timeout: 20 * time.Second, }, rpm: newSlidingLimiter(rpm, time.Minute), rph: newSlidingLimiter(rph, time.Hour), } } type providerSecrets struct { APIKey string `json:"api_key,omitempty"` SMTPHost string `json:"smtp_host,omitempty"` SMTPPort string `json:"smtp_port,omitempty"` SMTPUser string `json:"smtp_user,omitempty"` SMTPPassword string `json:"smtp_password,omitempty"` } type providerConfigJSON struct { Domain string `json:"domain,omitempty"` ReplyTo string `json:"reply_to,omitempty"` } type storedProvider struct { id, providerType, fromEmail, fromName, secretsEnc string config []byte status string verifiedAt, createdAt, updatedAt time.Time verifiedAtPtr *time.Time lastError *string } func (s *Service) loadStored(ctx context.Context, companyID uuid.UUID) (storedProvider, error) { var sp storedProvider var verifiedAt *time.Time err := s.Pool.QueryRow(ctx, ` SELECT id::text, provider_type, from_email, from_name, secrets_enc, config, status, verified_at, last_error, created_at, updated_at FROM email_providers WHERE company_id = $1`, companyID).Scan( &sp.id, &sp.providerType, &sp.fromEmail, &sp.fromName, &sp.secretsEnc, &sp.config, &sp.status, &verifiedAt, &sp.lastError, &sp.createdAt, &sp.updatedAt, ) sp.verifiedAtPtr = verifiedAt if verifiedAt != nil { sp.verifiedAt = *verifiedAt } return sp, err } func (s *Service) decryptSecrets(enc string) (providerSecrets, error) { var out providerSecrets if enc == "" { return out, nil } plain, err := DecryptSecret(s.Key, enc) if err != nil { return out, err } if plain == "" { return out, nil } if err := json.Unmarshal([]byte(plain), &out); err != nil { return out, err } return out, nil } func (s *Service) parseConfig(raw []byte) providerConfigJSON { var c providerConfigJSON _ = json.Unmarshal(raw, &c) return c } func (s *Service) dryRunState(ctx context.Context, companyID uuid.UUID) (bool, string) { if s.Platform != nil { if resolved, err := s.Platform.ResolveEmailDryRun(ctx); err == nil { if resolved.DryRun { if resolved.Source == platformsettings.SourceDB { return true, "platform_settings.email_dry_run" } if resolved.Source == platformsettings.SourceEnv { return true, "EMAIL_DRY_RUN=true" } return true, "email_dry_run_default" } // explicitly false from settings/env — continue to free-plan check } else if s.Env.EmailDryRun { return true, "EMAIL_DRY_RUN=true" } } else if s.Env.EmailDryRun { return true, "EMAIL_DRY_RUN=true" } if s.isFreePlan(ctx, companyID) { return true, "free_plan" } return false, "" } func (s *Service) platformResendKey(ctx context.Context) string { if s.Platform != nil { if resolved, err := s.Platform.ResolveResend(ctx); err == nil && strings.TrimSpace(resolved.APIKey) != "" { return strings.TrimSpace(resolved.APIKey) } } return strings.TrimSpace(s.Env.ResendAPIKey) } func (s *Service) platformSMTP(ctx context.Context) (host, port, user, pass string) { if s.Platform != nil { if resolved, err := s.Platform.ResolveSMTP(ctx); err == nil { return resolved.Host, resolved.Port, resolved.User, resolved.Password } } return strings.TrimSpace(s.Env.SMTPHost), strings.TrimSpace(s.Env.SMTPPort), strings.TrimSpace(s.Env.SMTPUser), s.Env.SMTPPassword } func (s *Service) isFreePlan(ctx context.Context, companyID uuid.UUID) bool { var name string err := s.Pool.QueryRow(ctx, ` SELECT LOWER(p.name) FROM company_plans cp JOIN plans p ON p.id = cp.plan_id WHERE cp.company_id = $1 AND cp.is_active = true ORDER BY cp.updated_at DESC LIMIT 1`, companyID).Scan(&name) if err != nil { return false } return name == "free" || strings.HasPrefix(name, "free ") } func (s *Service) GetConfig(ctx context.Context, companyID uuid.UUID) (PublicConfig, error) { dry, reason := s.dryRunState(ctx, companyID) sp, err := s.loadStored(ctx, companyID) if errors.Is(err, pgx.ErrNoRows) { hint := "" if s.platformResendKey(ctx) != "" { hint = ProviderResend } else if host, _, _, _ := s.platformSMTP(ctx); host != "" { hint = ProviderSMTP } return PublicConfig{ Provider: ProviderSMTP, Configured: false, DryRunForced: dry, DryRunReason: reason, CanSendReal: false, EnvProviderHint: hint, }, nil } if err != nil { return PublicConfig{}, err } secrets, _ := s.decryptSecrets(sp.secretsEnc) cfg := s.parseConfig(sp.config) domain := cfg.Domain if domain == "" { domain = domainOfEmail(sp.fromEmail) } verified := sp.status == "verified" _, _, _, platPass := s.platformSMTP(ctx) return PublicConfig{ Provider: sp.providerType, FromEmail: sp.fromEmail, FromName: sp.fromName, ReplyTo: cfg.ReplyTo, Domain: domain, SMTPHost: secrets.SMTPHost, SMTPPort: secrets.SMTPPort, SMTPUser: secrets.SMTPUser, IsEnabled: sp.status != "error", Configured: true, DomainVerified: verified, FromVerified: verified, Verified: verified, VerifiedAt: sp.verifiedAtPtr, HasAPIKey: secrets.APIKey != "" || s.platformResendKey(ctx) != "", HasSMTPPassword: secrets.SMTPPassword != "" || platPass != "", LastTestStatus: statusPtr(sp.status), DryRunForced: dry, DryRunReason: reason, CanSendReal: verified && !dry, }, nil } func statusPtr(s string) *string { return &s } func (s *Service) UpdateConfig(ctx context.Context, companyID uuid.UUID, in UpdateInput) (PublicConfig, error) { provider := strings.ToLower(strings.TrimSpace(in.Provider)) if provider == "" { provider = ProviderSMTP } if provider != ProviderResend && provider != ProviderSMTP { return PublicConfig{}, ClientMsg("provider must be resend or smtp") } fromEmail, err := parseAddress(in.FromEmail) if err != nil && strings.TrimSpace(in.FromEmail) != "" { return PublicConfig{}, err } domain := strings.ToLower(strings.TrimSpace(in.Domain)) if domain == "" && fromEmail != "" { domain = domainOfEmail(fromEmail) } if fromEmail != "" && domain != "" && domainOfEmail(fromEmail) != domain { return PublicConfig{}, ClientMsg("from_email domain must match domain field") } var secrets providerSecrets existing, err := s.loadStored(ctx, companyID) if err == nil { secrets, _ = s.decryptSecrets(existing.secretsEnc) } else if !errors.Is(err, pgx.ErrNoRows) { return PublicConfig{}, err } if strings.TrimSpace(in.APIKey) != "" { secrets.APIKey = strings.TrimSpace(in.APIKey) } if strings.TrimSpace(in.SMTPHost) != "" { host := strings.TrimSpace(in.SMTPHost) if err := security.AssertDialableSMTPHost(ctx, host); err != nil { return PublicConfig{}, ErrSMTPHostBlocked } secrets.SMTPHost = host } if strings.TrimSpace(in.SMTPPort) != "" { secrets.SMTPPort = strings.TrimSpace(in.SMTPPort) } else if secrets.SMTPPort == "" { secrets.SMTPPort = "587" } if strings.TrimSpace(in.SMTPUser) != "" { secrets.SMTPUser = strings.TrimSpace(in.SMTPUser) } if strings.TrimSpace(in.SMTPPassword) != "" { secrets.SMTPPassword = strings.TrimSpace(in.SMTPPassword) } secBytes, err := json.Marshal(secrets) if err != nil { return PublicConfig{}, err } enc, err := EncryptSecret(s.Key, string(secBytes)) if err != nil { return PublicConfig{}, err } cfgBytes, err := json.Marshal(providerConfigJSON{ Domain: domain, ReplyTo: strings.TrimSpace(in.ReplyTo), }) if err != nil { return PublicConfig{}, err } _, err = s.Pool.Exec(ctx, ` INSERT INTO email_providers (company_id, provider_type, from_email, from_name, secrets_enc, config, status) VALUES ($1,$2,$3,$4,$5,$6::jsonb,'unverified') ON CONFLICT (company_id) DO UPDATE SET provider_type = EXCLUDED.provider_type, from_email = EXCLUDED.from_email, from_name = EXCLUDED.from_name, secrets_enc = EXCLUDED.secrets_enc, config = EXCLUDED.config, status = 'unverified', verified_at = NULL, last_error = NULL, updated_at = now()`, companyID, provider, fromEmail, strings.TrimSpace(in.FromName), enc, string(cfgBytes), ) if err != nil { return PublicConfig{}, err } return s.GetConfig(ctx, companyID) } func (s *Service) transportFor(sp storedProvider, secrets providerSecrets) (Transport, FromIdentity, error) { fromEmail := strings.TrimSpace(sp.fromEmail) if fromEmail == "" { return nil, FromIdentity{}, ErrInvalidFrom } cfg := s.parseConfig(sp.config) from := FromIdentity{Email: fromEmail, Name: sp.fromName, ReplyTo: cfg.ReplyTo} switch sp.providerType { case ProviderResend: apiKey := secrets.APIKey if apiKey == "" { apiKey = s.platformResendKey(context.Background()) } if apiKey == "" { return nil, from, ErrProviderMisconfig } return newResendTransport(apiKey, s.HTTPClient), from, nil case ProviderSMTP: host := secrets.SMTPHost user := secrets.SMTPUser pass := secrets.SMTPPassword port := secrets.SMTPPort if host == "" || user == "" || pass == "" || port == "" { ph, pp, pu, pw := s.platformSMTP(context.Background()) if host == "" { host = ph } if user == "" { user = pu } if pass == "" { pass = pw } if port == "" { port = pp } } if host == "" { return nil, from, ErrProviderMisconfig } if err := security.AssertDialableSMTPHost(context.Background(), host); err != nil { return nil, from, ErrSMTPHostBlocked } return newSMTPTransport(host, port, user, pass), from, nil default: return nil, from, ErrProviderMisconfig } } func (s *Service) VerifyDomain(ctx context.Context, companyID uuid.UUID) (PublicConfig, string, error) { sp, err := s.loadStored(ctx, companyID) if errors.Is(err, pgx.ErrNoRows) { return PublicConfig{}, "", ErrNotConfigured } if err != nil { return PublicConfig{}, "", err } secrets, err := s.decryptSecrets(sp.secretsEnc) if err != nil { return PublicConfig{}, "", err } cfg := s.parseConfig(sp.config) domain := cfg.Domain if domain == "" { domain = domainOfEmail(sp.fromEmail) } if sp.fromEmail == "" || domain == "" { return PublicConfig{}, "", ClientMsg("from_email and domain are required") } if domainOfEmail(sp.fromEmail) != strings.ToLower(domain) { return PublicConfig{}, "", ClientMsg(fmt.Sprintf("from_email must use domain %s", domain)) } msg := "from address matches domain" ok := true if sp.providerType == ProviderResend { apiKey := secrets.APIKey if apiKey == "" { apiKey = s.platformResendKey(ctx) } if apiKey == "" { return PublicConfig{}, "", ErrProviderMisconfig } var detail string ok, detail, err = verifyResendDomain(ctx, apiKey, domain, s.HTTPClient) if err != nil { return PublicConfig{}, "", err } msg = detail } else { msg = "SMTP domain matched — send a test email to mark verified" ok = false // require successful test for SMTP } if ok { _, err = s.Pool.Exec(ctx, ` UPDATE email_providers SET status='verified', verified_at=now(), last_error=NULL, updated_at=now() WHERE company_id=$1`, companyID) } else if sp.providerType == ProviderResend { _, err = s.Pool.Exec(ctx, ` UPDATE email_providers SET status='error', last_error=$2, updated_at=now() WHERE company_id=$1`, companyID, msg) } if err != nil { return PublicConfig{}, "", err } out, err := s.GetConfig(ctx, companyID) return out, msg, err } func (s *Service) markVerified(ctx context.Context, companyID uuid.UUID) error { _, err := s.Pool.Exec(ctx, ` UPDATE email_providers SET status='verified', verified_at=now(), last_error=NULL, updated_at=now() WHERE company_id=$1`, companyID) return err } func (s *Service) allowSend(companyID uuid.UUID) bool { key := companyID.String() return s.rpm.allow(key) && s.rph.allow(key) } func hashEmail(email string) string { sum := sha256.Sum256([]byte(normalizeEmail(email))) return hex.EncodeToString(sum[:]) } // Send delivers test or blast emails. Blasts require confirm_understood == "I understand". func (s *Service) Send(ctx context.Context, companyID uuid.UUID, req SendRequest) (SendResult, error) { mode := strings.ToLower(strings.TrimSpace(req.Mode)) if mode == "" { mode = "test" } if mode != "test" && mode != "blast" { return SendResult{}, ClientMsg("mode must be test or blast") } if mode == "blast" && strings.TrimSpace(req.ConfirmUnderstood) != ConfirmUnderstoodPhrase { return SendResult{}, ErrMissingConfirm } if len(req.To) == 0 { return SendResult{}, ErrInvalidRecipient } if len(req.To) > 100 { return SendResult{}, ClientMsg("max 100 recipients per request") } if !s.allowSend(companyID) { return SendResult{}, ErrRateLimited } sp, err := s.loadStored(ctx, companyID) if errors.Is(err, pgx.ErrNoRows) { return SendResult{}, ErrNotConfigured } if err != nil { return SendResult{}, err } dry, reason := s.dryRunState(ctx, companyID) if req.ForceDryRun { dry = true if reason == "" { reason = "force_dry_run" } } if mode == "blast" && !dry && sp.status != "verified" { return SendResult{}, ErrNotVerified } secrets, err := s.decryptSecrets(sp.secretsEnc) if err != nil { return SendResult{}, err } transport, from, err := s.transportFor(sp, secrets) if err != nil { return SendResult{}, err } var campaignID *uuid.UUID if req.CampaignID != nil && strings.TrimSpace(*req.CampaignID) != "" { id, err := uuid.Parse(strings.TrimSpace(*req.CampaignID)) if err != nil { return SendResult{}, ClientMsg("invalid campaign_id") } campaignID = &id } kind := mode if kind != "test" { kind = "blast" } out := SendResult{DryRun: dry, Reason: reason, Results: make([]RecipientResult, 0, len(req.To))} for _, rawTo := range req.To { to, err := parseAddress(rawTo) if err != nil { out.Failed++ out.Results = append(out.Results, RecipientResult{Status: StatusFailed, Error: "invalid recipient"}) continue } unsub, err := s.IsUnsubscribed(ctx, companyID, to) if err != nil { return out, err } if unsub { _ = s.logSend(ctx, companyID, campaignID, to, req.Subject, "unsubscribed", dry, kind, transport.Name(), "unsubscribed") out.Skipped++ out.Results = append(out.Results, RecipientResult{Status: StatusSkippedUnsub}) continue } _, pageURL, apiURL, err := s.ensureUnsubscribeToken(ctx, companyID, to) if err != nil { return out, err } html, text := injectUnsubscribeFooter(security.SanitizeEmailHTML(req.HTML), req.Text, pageURL) html = security.SanitizeEmailHTML(html) headers := listUnsubscribeHeaders(apiURL, "") if dry { log.Printf("email: dry-run company=%s provider=%s subject=%q", companyID, transport.Name(), req.Subject) _ = s.logSend(ctx, companyID, campaignID, to, req.Subject, "skipped", true, kind, transport.Name(), reason) out.Sent++ out.Results = append(out.Results, RecipientResult{Status: StatusDryRun}) continue } msg := Outbound{To: to, Subject: req.Subject, Text: text, HTML: html, Headers: headers} if err := transport.Send(ctx, from, msg); err != nil { log.Printf("email: send failed company=%s provider=%s", companyID, transport.Name()) _ = s.logSend(ctx, companyID, campaignID, to, req.Subject, "failed", false, kind, transport.Name(), "send failed") out.Failed++ out.Results = append(out.Results, RecipientResult{Status: StatusFailed, Error: "send failed"}) continue } _ = s.logSend(ctx, companyID, campaignID, to, req.Subject, "sent", false, kind, transport.Name(), "") if mode == "test" { _ = s.markVerified(ctx, companyID) } out.Sent++ out.Results = append(out.Results, RecipientResult{Status: StatusSent}) } return out, nil } func (s *Service) logSend(ctx context.Context, companyID uuid.UUID, campaignID *uuid.UUID, to, subject, status string, dry bool, kind, provider, errMsg string) error { // 011 schema: recipient_email, recipient_hash, kind, status — dry-run stored as skipped + error note. st := status if dry && st != "unsubscribed" { st = "skipped" } _, err := s.Pool.Exec(ctx, ` INSERT INTO email_sends (company_id, campaign_id, recipient_email, recipient_hash, kind, status, error, sent_at) VALUES ($1,$2,$3,$4,$5,$6,$7, CASE WHEN $6 = 'sent' THEN now() ELSE NULL END)`, companyID, campaignID, to, hashEmail(to), kind, st, nullIfEmpty(errMsg)) _ = subject _ = provider return err } func nullIfEmpty(s string) *string { if strings.TrimSpace(s) == "" { return nil } return &s } func firstNonEmpty(vals ...string) string { for _, v := range vals { if strings.TrimSpace(v) != "" { return v } } return "" }