614 lines
18 KiB
Go
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 ""
|
||
|
|
}
|