Files
greeneclipse 8580c996c3 Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
2026-08-09 22:47:43 +02:00

614 lines
18 KiB
Go

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 ""
}