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.
This commit is contained in:
@@ -0,0 +1,613 @@
|
||||
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 ""
|
||||
}
|
||||
Reference in New Issue
Block a user