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,108 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/config"
|
||||
)
|
||||
|
||||
const encPrefix = "enc:v1:"
|
||||
|
||||
// DeriveKey builds a 32-byte AES key from APP_ENCRYPTION_KEY (preferred),
|
||||
// CREDENTIALS_ENCRYPTION_KEY, TOKEN_SIGNING_SECRET, or DATABASE_URL material.
|
||||
// In production, explicitKey is required; empty returns nil (fail closed).
|
||||
func DeriveKey(explicitKey, fallbackMaterial string) []byte {
|
||||
explicitKey = strings.TrimSpace(explicitKey)
|
||||
if explicitKey != "" {
|
||||
if b, err := decodeKeyMaterial(explicitKey); err == nil {
|
||||
return b
|
||||
}
|
||||
sum := sha256.Sum256([]byte(explicitKey))
|
||||
return sum[:]
|
||||
}
|
||||
if config.IsProductionEnv() {
|
||||
return nil
|
||||
}
|
||||
sum := sha256.Sum256([]byte("descrybe-email-v1|" + fallbackMaterial))
|
||||
return sum[:]
|
||||
}
|
||||
|
||||
func decodeKeyMaterial(s string) ([]byte, error) {
|
||||
if b, err := base64.StdEncoding.DecodeString(s); err == nil && len(b) == 32 {
|
||||
return b, nil
|
||||
}
|
||||
if b, err := base64.RawStdEncoding.DecodeString(s); err == nil && len(b) == 32 {
|
||||
return b, nil
|
||||
}
|
||||
if b, err := hex.DecodeString(s); err == nil && len(b) == 32 {
|
||||
return b, nil
|
||||
}
|
||||
return nil, errors.New("invalid key material")
|
||||
}
|
||||
|
||||
func EncryptSecret(key []byte, plaintext string) (string, error) {
|
||||
if plaintext == "" {
|
||||
return "", nil
|
||||
}
|
||||
if len(key) != 32 {
|
||||
return "", errors.New("encryption key must be 32 bytes")
|
||||
}
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return "", err
|
||||
}
|
||||
sealed := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
|
||||
return encPrefix + base64.RawStdEncoding.EncodeToString(sealed), nil
|
||||
}
|
||||
|
||||
func DecryptSecret(key []byte, stored string) (string, error) {
|
||||
if stored == "" {
|
||||
return "", nil
|
||||
}
|
||||
if !strings.HasPrefix(stored, encPrefix) {
|
||||
if config.IsProductionEnv() {
|
||||
return "", errors.New("plaintext secrets are not allowed when APP_ENV=production")
|
||||
}
|
||||
return stored, nil
|
||||
}
|
||||
if len(key) != 32 {
|
||||
return "", errors.New("encryption key must be 32 bytes")
|
||||
}
|
||||
raw, err := base64.RawStdEncoding.DecodeString(strings.TrimPrefix(stored, encPrefix))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(raw) < gcm.NonceSize() {
|
||||
return "", errors.New("ciphertext too short")
|
||||
}
|
||||
nonce, ciphertext := raw[:gcm.NonceSize()], raw[gcm.NonceSize():]
|
||||
plain, err := gcm.Open(nil, nonce, ciphertext, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(plain), nil
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package email
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestEncryptDecryptRoundTrip(t *testing.T) {
|
||||
t.Setenv("APP_ENV", "development")
|
||||
key := DeriveKey("0123456789abcdef0123456789abcdef", "")
|
||||
enc, err := EncryptSecret(key, "re_test_secret")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if enc == "" || enc == "re_test_secret" {
|
||||
t.Fatal("expected ciphertext")
|
||||
}
|
||||
plain, err := DecryptSecret(key, enc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if plain != "re_test_secret" {
|
||||
t.Fatalf("got %q", plain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveKeyHex(t *testing.T) {
|
||||
t.Setenv("APP_ENV", "development")
|
||||
hexKey := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
key := DeriveKey(hexKey, "fallback")
|
||||
if len(key) != 32 {
|
||||
t.Fatalf("len=%d", len(key))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptLegacyPlaintextRejectedInProduction(t *testing.T) {
|
||||
t.Setenv("APP_ENV", "production")
|
||||
key := DeriveKey("x", "y")
|
||||
if _, err := DecryptSecret(key, "legacy-plain"); err == nil {
|
||||
t.Fatal("expected plaintext decrypt rejected in production")
|
||||
}
|
||||
t.Setenv("APP_ENV", "development")
|
||||
plain, err := DecryptSecret(key, "legacy-plain")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if plain != "legacy-plain" {
|
||||
t.Fatalf("got %q", plain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveKeyRejectsFallbackInProduction(t *testing.T) {
|
||||
t.Setenv("APP_ENV", "production")
|
||||
if key := DeriveKey("", "postgres://local"); key != nil {
|
||||
t.Fatalf("expected nil key without explicit material in production, got len=%d", len(key))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package email
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestConfirmUnderstoodPhrase(t *testing.T) {
|
||||
if ConfirmUnderstoodPhrase != "I understand" {
|
||||
t.Fatalf("unexpected phrase %q", ConfirmUnderstoodPhrase)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDomainOfEmail(t *testing.T) {
|
||||
if got := domainOfEmail("Alice@Example.COM"); got != "example.com" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectUnsubscribeFooter(t *testing.T) {
|
||||
html, text := injectUnsubscribeFooter("<p>Hi</p>", "Hi", "https://app/unsubscribe?token=x")
|
||||
if !containsFold(html, "unsubscribe") || !containsFold(text, "unsubscribe") {
|
||||
t.Fatalf("expected unsubscribe footer")
|
||||
}
|
||||
}
|
||||
|
||||
func containsFold(s, sub string) bool {
|
||||
return len(s) >= len(sub) && (s == sub || len(sub) == 0 ||
|
||||
(len(s) > 0 && (stringIndexFold(s, sub) >= 0)))
|
||||
}
|
||||
|
||||
func stringIndexFold(s, sub string) int {
|
||||
ls, lsub := len(s), len(sub)
|
||||
for i := 0; i+lsub <= ls; i++ {
|
||||
ok := true
|
||||
for j := 0; j < lsub; j++ {
|
||||
a, b := s[i+j], sub[j]
|
||||
if a >= 'A' && a <= 'Z' {
|
||||
a += 'a' - 'A'
|
||||
}
|
||||
if b >= 'A' && b <= 'Z' {
|
||||
b += 'a' - 'A'
|
||||
}
|
||||
if a != b {
|
||||
ok = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if ok {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func TestListUnsubscribeHeaders(t *testing.T) {
|
||||
h := listUnsubscribeHeaders("https://api/u?t=1", "")
|
||||
if h["List-Unsubscribe"] == "" || h["List-Unsubscribe-Post"] == "" {
|
||||
t.Fatal("missing headers")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// slidingLimiter is an in-process email send budget (per key).
|
||||
// Not shared across API replicas; RATE_LIMIT_REPLICAS does not divide this limiter.
|
||||
// Multi-replica hard caps need edge/WAF (or a future shared store).
|
||||
type slidingLimiter struct {
|
||||
mu sync.Mutex
|
||||
window time.Duration
|
||||
limit int
|
||||
hits map[string][]time.Time
|
||||
lastGC time.Time
|
||||
}
|
||||
|
||||
func newSlidingLimiter(limit int, window time.Duration) *slidingLimiter {
|
||||
if limit <= 0 {
|
||||
limit = 30
|
||||
}
|
||||
if window <= 0 {
|
||||
window = time.Minute
|
||||
}
|
||||
return &slidingLimiter{
|
||||
window: window,
|
||||
limit: limit,
|
||||
hits: make(map[string][]time.Time),
|
||||
lastGC: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
func (l *slidingLimiter) allow(key string) bool {
|
||||
now := time.Now()
|
||||
cutoff := now.Add(-l.window)
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
if now.Sub(l.lastGC) > l.window {
|
||||
for k, ts := range l.hits {
|
||||
kept := ts[:0]
|
||||
for _, t := range ts {
|
||||
if t.After(cutoff) {
|
||||
kept = append(kept, t)
|
||||
}
|
||||
}
|
||||
if len(kept) == 0 {
|
||||
delete(l.hits, k)
|
||||
} else {
|
||||
l.hits[k] = kept
|
||||
}
|
||||
}
|
||||
l.lastGC = now
|
||||
}
|
||||
ts := l.hits[key]
|
||||
kept := ts[:0]
|
||||
for _, t := range ts {
|
||||
if t.After(cutoff) {
|
||||
kept = append(kept, t)
|
||||
}
|
||||
}
|
||||
if len(kept) >= l.limit {
|
||||
l.hits[key] = kept
|
||||
return false
|
||||
}
|
||||
l.hits[key] = append(kept, now)
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type resendTransport struct {
|
||||
apiKey string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func newResendTransport(apiKey string, client *http.Client) *resendTransport {
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 20 * time.Second}
|
||||
}
|
||||
return &resendTransport{apiKey: apiKey, client: client}
|
||||
}
|
||||
|
||||
func (r *resendTransport) Name() string { return ProviderResend }
|
||||
|
||||
func (r *resendTransport) Send(ctx context.Context, from FromIdentity, msg Outbound) error {
|
||||
to := strings.TrimSpace(msg.To)
|
||||
if to == "" {
|
||||
return ErrInvalidRecipient
|
||||
}
|
||||
payload := map[string]any{
|
||||
"from": from.Formatted(),
|
||||
"to": []string{to},
|
||||
"subject": msg.Subject,
|
||||
}
|
||||
if strings.TrimSpace(msg.HTML) != "" {
|
||||
payload["html"] = msg.HTML
|
||||
}
|
||||
if strings.TrimSpace(msg.Text) != "" {
|
||||
payload["text"] = msg.Text
|
||||
}
|
||||
if strings.TrimSpace(from.ReplyTo) != "" {
|
||||
payload["reply_to"] = from.ReplyTo
|
||||
}
|
||||
if len(msg.Headers) > 0 {
|
||||
payload["headers"] = msg.Headers
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.resend.com/emails", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+r.apiKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res, err := r.client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resend request failed")
|
||||
}
|
||||
defer res.Body.Close()
|
||||
raw, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20))
|
||||
if res.StatusCode >= 300 {
|
||||
return fmt.Errorf("resend api %d", res.StatusCode)
|
||||
}
|
||||
_ = raw
|
||||
return nil
|
||||
}
|
||||
|
||||
// verifyResendDomain checks the API key can list domains and that domain appears verified.
|
||||
// When Resend returns no domains (sandbox), returns ok=false with a clear message.
|
||||
func verifyResendDomain(ctx context.Context, apiKey, domain string, client *http.Client) (bool, string, error) {
|
||||
domain = strings.ToLower(strings.TrimSpace(domain))
|
||||
if domain == "" {
|
||||
return false, "domain required", nil
|
||||
}
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 15 * time.Second}
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.resend.com/domains", nil)
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
return false, "", fmt.Errorf("resend domains request failed")
|
||||
}
|
||||
defer res.Body.Close()
|
||||
raw, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
if res.StatusCode == http.StatusUnauthorized {
|
||||
return false, "invalid Resend API key", nil
|
||||
}
|
||||
if res.StatusCode >= 300 {
|
||||
return false, fmt.Sprintf("resend domains api %d", res.StatusCode), nil
|
||||
}
|
||||
var parsed struct {
|
||||
Data []struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||
return false, "unexpected resend response", nil
|
||||
}
|
||||
for _, d := range parsed.Data {
|
||||
if strings.EqualFold(d.Name, domain) {
|
||||
st := strings.ToLower(strings.TrimSpace(d.Status))
|
||||
if st == "verified" || st == "ok" || st == "active" {
|
||||
return true, "domain verified with Resend", nil
|
||||
}
|
||||
return false, fmt.Sprintf("Resend domain status is %q", d.Status), nil
|
||||
}
|
||||
}
|
||||
return false, "domain not found in Resend account — add and verify it in the Resend dashboard", nil
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var smtpSendMail = smtp.SendMail
|
||||
|
||||
type smtpTransport struct {
|
||||
host string
|
||||
port string
|
||||
user string
|
||||
password string
|
||||
}
|
||||
|
||||
func newSMTPTransport(host, port, user, password string) *smtpTransport {
|
||||
if strings.TrimSpace(port) == "" {
|
||||
port = "587"
|
||||
}
|
||||
return &smtpTransport{host: host, port: port, user: user, password: password}
|
||||
}
|
||||
|
||||
func (s *smtpTransport) Name() string { return ProviderSMTP }
|
||||
|
||||
func (s *smtpTransport) Send(ctx context.Context, from FromIdentity, msg Outbound) error {
|
||||
_ = ctx
|
||||
to := strings.TrimSpace(msg.To)
|
||||
if to == "" {
|
||||
return ErrInvalidRecipient
|
||||
}
|
||||
if hasHeaderBreak(to) {
|
||||
return ErrInvalidRecipient
|
||||
}
|
||||
fromAddr := strings.TrimSpace(from.Email)
|
||||
if fromAddr == "" {
|
||||
return ErrInvalidFrom
|
||||
}
|
||||
if hasHeaderBreak(fromAddr) || hasHeaderBreak(from.Name) {
|
||||
return ErrInvalidFrom
|
||||
}
|
||||
if hasHeaderBreak(msg.Subject) {
|
||||
return fmt.Errorf("invalid subject")
|
||||
}
|
||||
replyTo := strings.TrimSpace(from.ReplyTo)
|
||||
if replyTo != "" && hasHeaderBreak(replyTo) {
|
||||
return fmt.Errorf("invalid reply-to")
|
||||
}
|
||||
addr := net.JoinHostPort(s.host, s.port)
|
||||
boundary := "descrybe_mkt_7f3a"
|
||||
var body strings.Builder
|
||||
body.WriteString(fmt.Sprintf("From: %s\r\n", from.Formatted()))
|
||||
body.WriteString(fmt.Sprintf("To: %s\r\n", to))
|
||||
body.WriteString(fmt.Sprintf("Subject: %s\r\n", msg.Subject))
|
||||
if replyTo != "" {
|
||||
body.WriteString(fmt.Sprintf("Reply-To: %s\r\n", replyTo))
|
||||
}
|
||||
for k, v := range msg.Headers {
|
||||
k = strings.TrimSpace(k)
|
||||
v = strings.TrimSpace(v)
|
||||
if k == "" || v == "" {
|
||||
continue
|
||||
}
|
||||
if hasHeaderBreak(k) || strings.Contains(k, ":") {
|
||||
return fmt.Errorf("invalid header name")
|
||||
}
|
||||
if hasHeaderBreak(v) {
|
||||
return fmt.Errorf("invalid header value")
|
||||
}
|
||||
body.WriteString(fmt.Sprintf("%s: %s\r\n", k, v))
|
||||
}
|
||||
body.WriteString("MIME-Version: 1.0\r\n")
|
||||
if strings.TrimSpace(msg.HTML) != "" {
|
||||
body.WriteString(fmt.Sprintf("Content-Type: multipart/alternative; boundary=%s\r\n\r\n", boundary))
|
||||
body.WriteString(fmt.Sprintf("--%s\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n%s\r\n", boundary, msg.Text))
|
||||
body.WriteString(fmt.Sprintf("--%s\r\nContent-Type: text/html; charset=UTF-8\r\n\r\n%s\r\n", boundary, msg.HTML))
|
||||
body.WriteString(fmt.Sprintf("--%s--\r\n", boundary))
|
||||
} else {
|
||||
body.WriteString("Content-Type: text/plain; charset=UTF-8\r\n\r\n")
|
||||
body.WriteString(msg.Text)
|
||||
}
|
||||
|
||||
var auth smtp.Auth
|
||||
if s.user != "" {
|
||||
auth = smtp.PlainAuth("", s.user, s.password, s.host)
|
||||
}
|
||||
if err := smtpSendMail(addr, auth, fromAddr, []string{to}, []byte(body.String())); err != nil {
|
||||
return fmt.Errorf("smtp send failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func hasHeaderBreak(v string) bool {
|
||||
return strings.ContainsAny(v, "\r\n")
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSMTPTransportSendBuildsHeadersForValidInput(t *testing.T) {
|
||||
transport := newSMTPTransport("smtp.example.com", "587", "user", "pass")
|
||||
|
||||
var captured string
|
||||
called := false
|
||||
prev := smtpSendMail
|
||||
smtpSendMail = func(addr string, auth smtp.Auth, from string, to []string, msg []byte) error {
|
||||
called = true
|
||||
if addr != "smtp.example.com:587" {
|
||||
t.Fatalf("addr=%q", addr)
|
||||
}
|
||||
if from != "sender@example.com" {
|
||||
t.Fatalf("from=%q", from)
|
||||
}
|
||||
if len(to) != 1 || to[0] != "recipient@example.com" {
|
||||
t.Fatalf("to=%v", to)
|
||||
}
|
||||
captured = string(msg)
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() { smtpSendMail = prev })
|
||||
|
||||
err := transport.Send(context.Background(), FromIdentity{
|
||||
Email: "sender@example.com",
|
||||
Name: "Descrybe Team",
|
||||
ReplyTo: "reply@example.com",
|
||||
}, Outbound{
|
||||
To: "recipient@example.com",
|
||||
Subject: "Hello there",
|
||||
Text: "plain body",
|
||||
HTML: "<p>html body</p>",
|
||||
Headers: map[string]string{"List-Unsubscribe": "<https://example.com/unsub>"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !called {
|
||||
t.Fatal("expected smtpSendMail to be called")
|
||||
}
|
||||
for _, want := range []string{
|
||||
"From: Descrybe Team <sender@example.com>",
|
||||
"To: recipient@example.com",
|
||||
"Subject: Hello there",
|
||||
"Reply-To: reply@example.com",
|
||||
"List-Unsubscribe: <https://example.com/unsub>",
|
||||
} {
|
||||
if !strings.Contains(captured, want) {
|
||||
t.Fatalf("message missing %q:\n%s", want, captured)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSMTPTransportSendRejectsHeaderInjection(t *testing.T) {
|
||||
cases := []Outbound{
|
||||
{To: "recipient@example.com", Subject: "ok\r\nBcc:evil@example.com", Text: "body"},
|
||||
{To: "recipient@example.com", Subject: "ok", Text: "body", Headers: map[string]string{"X-Test\r\nBcc": "1"}},
|
||||
{To: "recipient@example.com", Subject: "ok", Text: "body", Headers: map[string]string{"X-Test": "1\r\nBcc:evil@example.com"}},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
transport := newSMTPTransport("smtp.example.com", "587", "", "")
|
||||
called := false
|
||||
prev := smtpSendMail
|
||||
smtpSendMail = func(addr string, auth smtp.Auth, from string, to []string, msg []byte) error {
|
||||
called = true
|
||||
return nil
|
||||
}
|
||||
|
||||
err := transport.Send(context.Background(), FromIdentity{
|
||||
Email: "sender@example.com",
|
||||
ReplyTo: "reply@example.com",
|
||||
}, tc)
|
||||
smtpSendMail = prev
|
||||
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for %#v", tc)
|
||||
}
|
||||
if called {
|
||||
t.Fatalf("smtpSendMail should not be called for %#v", tc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSMTPTransportSendRejectsReplyToInjection(t *testing.T) {
|
||||
transport := newSMTPTransport("smtp.example.com", "587", "", "")
|
||||
|
||||
called := false
|
||||
prev := smtpSendMail
|
||||
smtpSendMail = func(addr string, auth smtp.Auth, from string, to []string, msg []byte) error {
|
||||
called = true
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() { smtpSendMail = prev })
|
||||
|
||||
err := transport.Send(context.Background(), FromIdentity{
|
||||
Email: "sender@example.com",
|
||||
ReplyTo: "reply@example.com\r\nBcc:evil@example.com",
|
||||
}, Outbound{
|
||||
To: "recipient@example.com",
|
||||
Subject: "safe",
|
||||
Text: "body",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid reply-to error")
|
||||
}
|
||||
if called {
|
||||
t.Fatal("smtpSendMail should not be called")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/mail"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
ProviderResend = "resend"
|
||||
ProviderSMTP = "smtp"
|
||||
|
||||
// ConfirmUnderstoodPhrase must be sent as confirm_understood on real blasts.
|
||||
ConfirmUnderstoodPhrase = "I understand"
|
||||
|
||||
StatusSent = "sent"
|
||||
StatusFailed = "failed"
|
||||
StatusDryRun = "dry_run"
|
||||
StatusSkippedUnsub = "skipped_unsubscribed"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotConfigured = errors.New("email provider not configured")
|
||||
ErrNotEnabled = errors.New("email provider is disabled")
|
||||
ErrNotVerified = errors.New("from address or domain not verified")
|
||||
ErrMissingConfirm = errors.New(`confirmation required: set confirm_understood to "I understand"`)
|
||||
ErrRateLimited = errors.New("email send rate limit exceeded")
|
||||
ErrInvalidFrom = errors.New("invalid from address")
|
||||
ErrInvalidRecipient = errors.New("invalid recipient")
|
||||
ErrProviderMisconfig = errors.New("email provider credentials incomplete")
|
||||
ErrSMTPHostBlocked = errors.New("smtp_host is not allowed")
|
||||
)
|
||||
|
||||
// clientError is a validation message safe to return to API clients.
|
||||
type clientError struct {
|
||||
msg string
|
||||
}
|
||||
|
||||
func (e *clientError) Error() string { return e.msg }
|
||||
|
||||
// ClientMsg marks a message as safe to expose in HTTP 4xx responses.
|
||||
func ClientMsg(msg string) error {
|
||||
return &clientError{msg: msg}
|
||||
}
|
||||
|
||||
// ClientError reports whether err is a known client-facing email error.
|
||||
func ClientError(err error) (msg string, ok bool) {
|
||||
if err == nil {
|
||||
return "", false
|
||||
}
|
||||
var ce *clientError
|
||||
if errors.As(err, &ce) {
|
||||
return ce.msg, true
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, ErrNotConfigured),
|
||||
errors.Is(err, ErrNotEnabled),
|
||||
errors.Is(err, ErrNotVerified),
|
||||
errors.Is(err, ErrMissingConfirm),
|
||||
errors.Is(err, ErrRateLimited),
|
||||
errors.Is(err, ErrInvalidFrom),
|
||||
errors.Is(err, ErrInvalidRecipient),
|
||||
errors.Is(err, ErrProviderMisconfig),
|
||||
errors.Is(err, ErrSMTPHostBlocked):
|
||||
return err.Error(), true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// Outbound is one marketing email. Callers must not log To (PII).
|
||||
type Outbound struct {
|
||||
To string
|
||||
Subject string
|
||||
Text string
|
||||
HTML string
|
||||
Headers map[string]string
|
||||
}
|
||||
|
||||
type Transport interface {
|
||||
Send(ctx context.Context, from FromIdentity, msg Outbound) error
|
||||
Name() string
|
||||
}
|
||||
|
||||
type FromIdentity struct {
|
||||
Email string
|
||||
Name string
|
||||
ReplyTo string
|
||||
}
|
||||
|
||||
func (f FromIdentity) Formatted() string {
|
||||
email := strings.TrimSpace(f.Email)
|
||||
name := strings.TrimSpace(f.Name)
|
||||
if name == "" {
|
||||
return email
|
||||
}
|
||||
return fmt.Sprintf("%s <%s>", name, email)
|
||||
}
|
||||
|
||||
type PublicConfig struct {
|
||||
Provider string `json:"provider"`
|
||||
FromEmail string `json:"from_email"`
|
||||
FromName string `json:"from_name"`
|
||||
ReplyTo string `json:"reply_to"`
|
||||
Domain string `json:"domain"`
|
||||
SMTPHost string `json:"smtp_host,omitempty"`
|
||||
SMTPPort string `json:"smtp_port,omitempty"`
|
||||
SMTPUser string `json:"smtp_user,omitempty"`
|
||||
IsEnabled bool `json:"is_enabled"`
|
||||
Configured bool `json:"configured"`
|
||||
DomainVerified bool `json:"domain_verified"`
|
||||
FromVerified bool `json:"from_verified"`
|
||||
Verified bool `json:"verified"`
|
||||
VerifiedAt *time.Time `json:"verified_at,omitempty"`
|
||||
HasAPIKey bool `json:"has_api_key"`
|
||||
HasSMTPPassword bool `json:"has_smtp_password"`
|
||||
LastTestAt *time.Time `json:"last_test_at,omitempty"`
|
||||
LastTestStatus *string `json:"last_test_status,omitempty"`
|
||||
DryRunForced bool `json:"dry_run_forced"`
|
||||
DryRunReason string `json:"dry_run_reason,omitempty"`
|
||||
CanSendReal bool `json:"can_send_real"`
|
||||
EnvProviderHint string `json:"env_provider_hint,omitempty"`
|
||||
}
|
||||
|
||||
type UpdateInput struct {
|
||||
Provider string `json:"provider"`
|
||||
FromEmail string `json:"from_email"`
|
||||
FromName string `json:"from_name"`
|
||||
ReplyTo string `json:"reply_to"`
|
||||
Domain string `json:"domain"`
|
||||
APIKey string `json:"api_key"`
|
||||
SMTPHost string `json:"smtp_host"`
|
||||
SMTPPort string `json:"smtp_port"`
|
||||
SMTPUser string `json:"smtp_user"`
|
||||
SMTPPassword string `json:"smtp_password"`
|
||||
IsEnabled bool `json:"is_enabled"`
|
||||
}
|
||||
|
||||
type SendRequest struct {
|
||||
To []string `json:"to"`
|
||||
Subject string `json:"subject"`
|
||||
Text string `json:"text"`
|
||||
HTML string `json:"html"`
|
||||
CampaignID *string `json:"campaign_id"`
|
||||
Mode string `json:"mode"` // test | blast
|
||||
ConfirmUnderstood string `json:"confirm_understood"`
|
||||
ForceDryRun bool `json:"force_dry_run"`
|
||||
}
|
||||
|
||||
type SendResult struct {
|
||||
DryRun bool `json:"dry_run"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Sent int `json:"sent"`
|
||||
Skipped int `json:"skipped"`
|
||||
Failed int `json:"failed"`
|
||||
Results []RecipientResult `json:"results"`
|
||||
}
|
||||
|
||||
type RecipientResult struct {
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func parseAddress(raw string) (string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "", ErrInvalidFrom
|
||||
}
|
||||
addr, err := mail.ParseAddress(raw)
|
||||
if err != nil {
|
||||
// Accept bare emails that ParseAddress rejects without display name edge cases.
|
||||
if strings.Contains(raw, "@") && !strings.ContainsAny(raw, "<>") {
|
||||
return strings.ToLower(raw), nil
|
||||
}
|
||||
return "", ErrInvalidFrom
|
||||
}
|
||||
return strings.ToLower(strings.TrimSpace(addr.Address)), nil
|
||||
}
|
||||
|
||||
func domainOfEmail(email string) string {
|
||||
email = strings.ToLower(strings.TrimSpace(email))
|
||||
i := strings.LastIndex(email, "@")
|
||||
if i < 0 || i == len(email)-1 {
|
||||
return ""
|
||||
}
|
||||
return email[i+1:]
|
||||
}
|
||||
|
||||
func normalizeEmail(email string) string {
|
||||
return strings.ToLower(strings.TrimSpace(email))
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
func (s *Service) IsUnsubscribed(ctx context.Context, companyID uuid.UUID, emailAddr string) (bool, error) {
|
||||
emailAddr = normalizeEmail(emailAddr)
|
||||
var at *time.Time
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT unsubscribed_at FROM email_unsubscribes
|
||||
WHERE company_id = $1 AND email_hash = $2`, companyID, hashEmail(emailAddr)).Scan(&at)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return at != nil, nil
|
||||
}
|
||||
|
||||
func (s *Service) ensureUnsubscribeToken(ctx context.Context, companyID uuid.UUID, emailAddr string) (token, pageURL, apiURL string, err error) {
|
||||
emailAddr = normalizeEmail(emailAddr)
|
||||
h := hashEmail(emailAddr)
|
||||
err = s.Pool.QueryRow(ctx, `
|
||||
SELECT token FROM email_unsubscribes WHERE company_id = $1 AND email_hash = $2`,
|
||||
companyID, h).Scan(&token)
|
||||
if err == nil {
|
||||
pageURL = UnsubscribePageURL(s.Env.WebOrigin, token)
|
||||
apiURL = UnsubscribeURL(s.Env.PublicAPIURL, token)
|
||||
return token, pageURL, apiURL, nil
|
||||
}
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", "", "", err
|
||||
}
|
||||
token, err = newUnsubscribeToken()
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
_, err = s.Pool.Exec(ctx, `
|
||||
INSERT INTO email_unsubscribes (company_id, email, email_hash, token, unsubscribed_at)
|
||||
VALUES ($1, $2, $3, $4, NULL)
|
||||
ON CONFLICT (company_id, email_hash) DO NOTHING`, companyID, emailAddr, h, token)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
err = s.Pool.QueryRow(ctx, `
|
||||
SELECT token FROM email_unsubscribes WHERE company_id = $1 AND email_hash = $2`,
|
||||
companyID, h).Scan(&token)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
pageURL = UnsubscribePageURL(s.Env.WebOrigin, token)
|
||||
apiURL = UnsubscribeURL(s.Env.PublicAPIURL, token)
|
||||
return token, pageURL, apiURL, nil
|
||||
}
|
||||
|
||||
const maxUnsubscribeReasonLen = 500
|
||||
|
||||
// UnsubscribeInfo is the public unsubscribe API response. It must not leak
|
||||
// tenant identifiers or recipient emails (even masked) to unauthenticated callers.
|
||||
type UnsubscribeInfo struct {
|
||||
AlreadyDone bool `json:"already_unsubscribed"`
|
||||
OK bool `json:"ok"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func (s *Service) LookupUnsubscribeToken(ctx context.Context, token string) (companyID uuid.UUID, emailAddr string, unsubscribed bool, err error) {
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return uuid.Nil, "", false, pgx.ErrNoRows
|
||||
}
|
||||
var at *time.Time
|
||||
err = s.Pool.QueryRow(ctx, `
|
||||
SELECT company_id, email, unsubscribed_at FROM email_unsubscribes WHERE token = $1`, token).Scan(
|
||||
&companyID, &emailAddr, &at)
|
||||
if err != nil {
|
||||
return uuid.Nil, "", false, err
|
||||
}
|
||||
return companyID, emailAddr, at != nil, nil
|
||||
}
|
||||
|
||||
func (s *Service) UnsubscribeByToken(ctx context.Context, token, reason string) (UnsubscribeInfo, error) {
|
||||
_, _, already, err := s.LookupUnsubscribeToken(ctx, token)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return UnsubscribeInfo{OK: false, Message: "invalid or expired unsubscribe link"}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return UnsubscribeInfo{}, err
|
||||
}
|
||||
if already {
|
||||
return UnsubscribeInfo{
|
||||
OK: true,
|
||||
AlreadyDone: true,
|
||||
Message: "already unsubscribed",
|
||||
}, nil
|
||||
}
|
||||
reason = clampUnsubscribeReason(reason)
|
||||
now := time.Now().UTC()
|
||||
_, err = s.Pool.Exec(ctx, `
|
||||
UPDATE email_unsubscribes SET unsubscribed_at = $2, reason = $3
|
||||
WHERE token = $1 AND unsubscribed_at IS NULL`,
|
||||
token, now, reason)
|
||||
if err != nil {
|
||||
return UnsubscribeInfo{}, err
|
||||
}
|
||||
return UnsubscribeInfo{
|
||||
OK: true,
|
||||
Message: "unsubscribed",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func clampUnsubscribeReason(reason string) string {
|
||||
reason = strings.TrimSpace(reason)
|
||||
if len(reason) > maxUnsubscribeReasonLen {
|
||||
return reason[:maxUnsubscribeReasonLen]
|
||||
}
|
||||
return reason
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUnsubscribeInfoOmitsTenantPII(t *testing.T) {
|
||||
t.Parallel()
|
||||
info := UnsubscribeInfo{
|
||||
OK: true,
|
||||
AlreadyDone: true,
|
||||
Message: "already unsubscribed",
|
||||
}
|
||||
b, err := json.Marshal(info)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw := string(b)
|
||||
for _, leak := range []string{"company_id", "email"} {
|
||||
if strings.Contains(raw, leak) {
|
||||
t.Fatalf("public unsubscribe JSON must not include %q: %s", leak, raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClampUnsubscribeReason(t *testing.T) {
|
||||
t.Parallel()
|
||||
long := strings.Repeat("a", maxUnsubscribeReasonLen+50)
|
||||
got := clampUnsubscribeReason(long)
|
||||
if len(got) != maxUnsubscribeReasonLen {
|
||||
t.Fatalf("len=%d want %d", len(got), maxUnsubscribeReasonLen)
|
||||
}
|
||||
if got := clampUnsubscribeReason(" ok "); got != "ok" {
|
||||
t.Fatalf("trim failed: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func newUnsubscribeToken() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func UnsubscribeURL(publicAPIURL, token string) string {
|
||||
base := strings.TrimRight(strings.TrimSpace(publicAPIURL), "/")
|
||||
if base == "" {
|
||||
base = "http://localhost:8080"
|
||||
}
|
||||
return fmt.Sprintf("%s/api/public/unsubscribe?token=%s", base, token)
|
||||
}
|
||||
|
||||
func UnsubscribePageURL(webOrigin, token string) string {
|
||||
base := strings.TrimRight(strings.TrimSpace(webOrigin), "/")
|
||||
if base == "" {
|
||||
base = "http://localhost:5174"
|
||||
}
|
||||
return fmt.Sprintf("%s/unsubscribe?token=%s", base, token)
|
||||
}
|
||||
|
||||
func listUnsubscribeHeaders(oneClickURL, mailtoFallback string) map[string]string {
|
||||
h := map[string]string{
|
||||
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
|
||||
}
|
||||
parts := make([]string, 0, 2)
|
||||
if oneClickURL != "" {
|
||||
parts = append(parts, "<"+oneClickURL+">")
|
||||
}
|
||||
if mailtoFallback != "" {
|
||||
parts = append(parts, "<mailto:"+mailtoFallback+">")
|
||||
}
|
||||
if len(parts) > 0 {
|
||||
h["List-Unsubscribe"] = strings.Join(parts, ", ")
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func injectUnsubscribeFooter(html, text, pageURL string) (string, string) {
|
||||
link := strings.TrimSpace(pageURL)
|
||||
if link == "" {
|
||||
return html, text
|
||||
}
|
||||
footerHTML := fmt.Sprintf(
|
||||
`<hr style="border:none;border-top:1px solid #e5e7eb;margin:24px 0"/><p style="font-size:12px;color:#6b7280">You received this email because you opted in to marketing from this store. <a href="%s">Unsubscribe</a>.</p>`,
|
||||
link,
|
||||
)
|
||||
footerText := fmt.Sprintf("\n\n---\nUnsubscribe: %s\n", link)
|
||||
if strings.TrimSpace(html) != "" && !strings.Contains(strings.ToLower(html), "unsubscribe") {
|
||||
html = html + footerHTML
|
||||
}
|
||||
if strings.TrimSpace(text) != "" && !strings.Contains(strings.ToLower(text), "unsubscribe") {
|
||||
text = text + footerText
|
||||
} else if strings.TrimSpace(text) == "" && strings.TrimSpace(html) != "" {
|
||||
text = "Unsubscribe: " + link
|
||||
}
|
||||
return html, text
|
||||
}
|
||||
Reference in New Issue
Block a user