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:
2026-08-09 22:47:43 +02:00
commit 8580c996c3
1285 changed files with 325780 additions and 0 deletions
+108
View File
@@ -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
}
+54
View File
@@ -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))
}
}
+58
View File
@@ -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")
}
}
+68
View File
@@ -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
}
+122
View File
@@ -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
}
+613
View File
@@ -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 ""
}
+98
View File
@@ -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")
}
+118
View File
@@ -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")
}
}
+194
View File
@@ -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))
}
+125
View File
@@ -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)
}
}
+70
View File
@@ -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
}