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
+246
View File
@@ -0,0 +1,246 @@
package campaigns
import (
"context"
"encoding/json"
"strings"
"github.com/descrybe/descrybe-v2/apps/api/internal/woocommerce"
"github.com/google/uuid"
)
// AudienceFilter is the structured form of email_campaigns.audience_filter.
// UI shape uses type + category_ids; API/docs also accept bought_category directly.
type AudienceFilter struct {
Type string `json:"type,omitempty"`
CategoryIDs []string `json:"category_ids,omitempty"`
BoughtCategory string `json:"bought_category,omitempty"`
NotBoughtCategory string `json:"not_bought_category,omitempty"`
BoughtCategories []string `json:"bought_categories,omitempty"`
Emails []string `json:"emails,omitempty"`
}
// ResolveAudience returns campaign recipients from explicit emails and/or Woo order history.
// Bought/not-bought category matching is best-effort over synced woo_orders / order_items.
func (s *Service) ResolveAudience(ctx context.Context, companyID uuid.UUID, filter AudienceFilter, limit int) (woocommerce.AudienceResult, error) {
if limit <= 0 {
limit = 500
}
if limit > 5000 {
limit = 5000
}
boughtList, notBought, err := s.resolveBoughtCategories(ctx, companyID, filter)
if err != nil {
return woocommerce.AudienceResult{}, err
}
if len(boughtList) > 0 {
woo := &woocommerce.Service{Pool: s.Pool}
if len(boughtList) == 1 && boughtList[0] == "__any_order__" {
res, err := woo.AudienceAnyOrdersExcept(ctx, companyID, notBought, limit)
if err != nil {
return woocommerce.AudienceResult{}, err
}
seen := map[string]struct{}{}
for _, c := range res.Customers {
seen[strings.ToLower(c.Email)] = struct{}{}
}
for _, raw := range filter.Emails {
if len(res.Customers) >= limit {
break
}
email, err := NormalizeEmail(raw)
if err != nil {
continue
}
if _, ok := seen[email]; ok {
continue
}
res.Customers = append(res.Customers, woocommerce.AudienceCustomer{Email: email})
seen[email] = struct{}{}
}
res.Total = len(res.Customers)
return res, nil
}
merged := woocommerce.AudienceResult{
Customers: make([]woocommerce.AudienceCustomer, 0),
Note: "best-effort from synced Woo orders (campaign audience_filter)",
}
seen := map[string]struct{}{}
for _, bought := range boughtList {
if len(merged.Customers) >= limit {
break
}
res, err := woo.AudienceBoughtCategories(ctx, companyID, bought, notBought, limit)
if err != nil {
return woocommerce.AudienceResult{}, err
}
if res.Note != "" {
merged.Note = res.Note
}
for _, c := range res.Customers {
email := strings.ToLower(strings.TrimSpace(c.Email))
if email == "" {
continue
}
if _, ok := seen[email]; ok {
continue
}
seen[email] = struct{}{}
merged.Customers = append(merged.Customers, c)
if len(merged.Customers) >= limit {
break
}
}
}
for _, raw := range filter.Emails {
if len(merged.Customers) >= limit {
break
}
email, err := NormalizeEmail(raw)
if err != nil {
continue
}
if _, ok := seen[email]; ok {
continue
}
merged.Customers = append(merged.Customers, woocommerce.AudienceCustomer{Email: email})
seen[email] = struct{}{}
}
merged.Total = len(merged.Customers)
return merged, nil
}
out := woocommerce.AudienceResult{
Customers: make([]woocommerce.AudienceCustomer, 0),
Note: "explicit email list (no bought_category filter)",
}
seen := map[string]struct{}{}
for _, raw := range filter.Emails {
email, err := NormalizeEmail(raw)
if err != nil {
continue
}
if _, ok := seen[email]; ok {
continue
}
out.Customers = append(out.Customers, woocommerce.AudienceCustomer{Email: email})
seen[email] = struct{}{}
if len(out.Customers) >= limit {
break
}
}
out.Total = len(out.Customers)
return out, nil
}
func (s *Service) resolveBoughtCategories(ctx context.Context, companyID uuid.UUID, filter AudienceFilter) ([]string, string, error) {
notBought := strings.TrimSpace(filter.NotBoughtCategory)
bought := make([]string, 0)
add := func(v string) {
v = strings.TrimSpace(v)
if v == "" {
return
}
for _, existing := range bought {
if strings.EqualFold(existing, v) {
return
}
}
bought = append(bought, v)
}
add(filter.BoughtCategory)
for _, v := range filter.BoughtCategories {
add(v)
}
typ := strings.ToLower(strings.TrimSpace(filter.Type))
names, err := s.categoryNamesByIDs(ctx, companyID, filter.CategoryIDs)
if err != nil {
return nil, "", err
}
switch typ {
case "purchased", "by_category":
for _, name := range names {
add(name)
}
case "not_purchased":
if notBought == "" && len(names) > 0 {
notBought = names[0]
}
if len(bought) == 0 {
return []string{"__any_order__"}, notBought, nil
}
default:
// Keep explicit bought_category / bought_categories when type is empty/all.
if len(bought) == 0 {
for _, name := range names {
add(name)
}
}
}
return bought, notBought, nil
}
func (s *Service) categoryNamesByIDs(ctx context.Context, companyID uuid.UUID, rawIDs []string) ([]string, error) {
ids := make([]uuid.UUID, 0, len(rawIDs))
for _, raw := range rawIDs {
id, err := uuid.Parse(strings.TrimSpace(raw))
if err != nil {
continue
}
ids = append(ids, id)
}
if len(ids) == 0 {
return nil, nil
}
rows, err := s.Pool.Query(ctx, `
SELECT name FROM categories
WHERE company_id = $1 AND id = ANY($2::uuid[]) AND is_active = true`, companyID, ids)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]string, 0, len(ids))
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, err
}
name = strings.TrimSpace(name)
if name != "" {
out = append(out, name)
}
}
return out, rows.Err()
}
// ResolveAudienceMap accepts the loose map[string]any shape used by campaign Create/Update inputs.
func (s *Service) ResolveAudienceMap(ctx context.Context, companyID uuid.UUID, raw map[string]any, limit int) (woocommerce.AudienceResult, error) {
return s.ResolveAudience(ctx, companyID, AudienceFilterFromMap(raw), limit)
}
// AudienceFilterFromMap converts a JSON-object audience_filter into AudienceFilter.
func AudienceFilterFromMap(raw map[string]any) AudienceFilter {
if raw == nil {
return AudienceFilter{}
}
b, err := json.Marshal(raw)
if err != nil {
return AudienceFilter{}
}
return ParseAudienceFilter(b)
}
// ParseAudienceFilter decodes audience_filter JSONB.
func ParseAudienceFilter(raw []byte) AudienceFilter {
var f AudienceFilter
if len(raw) == 0 {
return f
}
_ = json.Unmarshal(raw, &f)
return f
}
@@ -0,0 +1,31 @@
package campaigns
import "testing"
func TestParseAudienceFilterUIShape(t *testing.T) {
raw := []byte(`{"type":"purchased","category_ids":["11111111-1111-1111-1111-111111111111"],"bought_category":"Demo Electronics"}`)
f := ParseAudienceFilter(raw)
if f.Type != "purchased" {
t.Fatalf("type=%q", f.Type)
}
if f.BoughtCategory != "Demo Electronics" {
t.Fatalf("bought=%q", f.BoughtCategory)
}
if len(f.CategoryIDs) != 1 {
t.Fatalf("category_ids=%v", f.CategoryIDs)
}
}
func TestAudienceFilterFromMap(t *testing.T) {
f := AudienceFilterFromMap(map[string]any{
"type": "not_purchased",
"category_ids": []any{"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"},
"bought_category": "",
})
if f.Type != "not_purchased" {
t.Fatalf("type=%q", f.Type)
}
if len(f.CategoryIDs) != 1 {
t.Fatalf("ids=%v", f.CategoryIDs)
}
}
@@ -0,0 +1,50 @@
package campaigns
import (
"context"
"encoding/json"
"os"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
func TestResolveAudienceSeededWooDemo(t *testing.T) {
dsn := os.Getenv("DATABASE_URL")
if dsn == "" {
t.Skip("DATABASE_URL not set")
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
pg, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatal(err)
}
defer pg.Close()
var companyID uuid.UUID
var af []byte
err = pg.QueryRow(ctx, `
SELECT company_id, audience_filter
FROM email_campaigns
WHERE name LIKE 'Woo demo%'
ORDER BY updated_at DESC LIMIT 1`).Scan(&companyID, &af)
if err != nil {
t.Skip("no seeded woo demo campaign:", err)
}
var m map[string]any
if err := json.Unmarshal(af, &m); err != nil {
t.Fatal(err)
}
svc := &Service{Pool: pg}
res, err := svc.ResolveAudienceMap(ctx, companyID, m, 100)
if err != nil {
t.Fatal(err)
}
if res.Total < 3 {
t.Fatalf("expected >=3 audience customers, got %d (%v)", res.Total, res.Customers)
}
t.Logf("resolved %d customers via campaign filter: %+v", res.Total, res.Customers)
}
+392
View File
@@ -0,0 +1,392 @@
package campaigns
import (
"context"
"encoding/json"
"errors"
"log"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
const (
maxCampaignProductIDs = 100
maxCampaignCategoryIDs = 50
)
// Campaign is the API representation of email_campaigns (+ latest version fields).
type Campaign struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
TemplateKey string `json:"template_key"`
Season string `json:"season,omitempty"`
Status string `json:"status"`
CategoryIDs []uuid.UUID `json:"category_ids"`
ProductIDs []uuid.UUID `json:"product_ids"`
Prompt string `json:"prompt"`
UseDefaultPrompt bool `json:"use_default_prompt"`
AudienceFilter map[string]any `json:"audience_filter"`
ScheduledAt *time.Time `json:"scheduled_at,omitempty"`
SentAt *time.Time `json:"sent_at,omitempty"`
Subject string `json:"subject,omitempty"`
HTMLBody string `json:"html_body,omitempty"`
PlainBody string `json:"plain_body,omitempty"`
LatestVersion *Version `json:"latest_version,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type Version struct {
ID uuid.UUID `json:"id"`
Version int `json:"version"`
Subject string `json:"subject"`
HTMLBody string `json:"html_body"`
PlainBody string `json:"plain_body"`
GenerationMode string `json:"generation_mode"`
GeneratedAt time.Time `json:"generated_at"`
}
type CreateInput struct {
Name string `json:"name"`
TemplateKey string `json:"template_key"`
CategoryIDs []uuid.UUID `json:"category_ids"`
ProductIDs []uuid.UUID `json:"product_ids"`
Prompt string `json:"prompt"`
UseDefaultPrompt *bool `json:"use_default_prompt"`
AudienceFilter map[string]any `json:"audience_filter"`
}
type UpdateInput struct {
Name *string `json:"name"`
TemplateKey *string `json:"template_key"`
Status *string `json:"status"`
CategoryIDs []uuid.UUID `json:"category_ids"`
ProductIDs []uuid.UUID `json:"product_ids"`
Prompt *string `json:"prompt"`
UseDefaultPrompt *bool `json:"use_default_prompt"`
AudienceFilter map[string]any `json:"audience_filter"`
ScheduledAt *time.Time `json:"scheduled_at"`
}
type GenerateInput struct {
Mode string `json:"mode"` // template | ai
Force bool `json:"force"`
UseAI *bool `json:"use_ai"`
}
type SendTestInput struct {
To string `json:"to"`
Email string `json:"email"`
}
type ScheduleInput struct {
ScheduledAt time.Time `json:"scheduled_at"`
}
type SendInput struct {
Confirm bool `json:"confirm"`
Recipients []string `json:"recipients"`
DryRun bool `json:"dry_run"`
}
func (s *Service) List(ctx context.Context, companyID uuid.UUID, limit, offset int) ([]Campaign, int64, error) {
var total int64
if err := s.Pool.QueryRow(ctx, `SELECT count(*) FROM email_campaigns WHERE company_id = $1`, companyID).Scan(&total); err != nil {
return nil, 0, err
}
rows, err := s.Pool.Query(ctx, `
SELECT id, name, template_key, status, category_ids, product_ids, prompt, use_default_prompt,
audience_filter, scheduled_at, sent_at, created_at, updated_at
FROM email_campaigns WHERE company_id = $1
ORDER BY updated_at DESC LIMIT $2 OFFSET $3`, companyID, limit, offset)
if err != nil {
return nil, 0, err
}
defer rows.Close()
out := make([]Campaign, 0)
for rows.Next() {
c, err := scanCampaign(rows)
if err != nil {
return nil, 0, err
}
out = append(out, c)
}
if err := rows.Err(); err != nil {
return nil, 0, err
}
// One round-trip for the page (was N+1 via attachLatestVersion per row).
_ = s.attachLatestVersions(ctx, out)
return out, total, nil
}
func (s *Service) Get(ctx context.Context, companyID, id uuid.UUID) (Campaign, error) {
row := s.Pool.QueryRow(ctx, `
SELECT id, name, template_key, status, category_ids, product_ids, prompt, use_default_prompt,
audience_filter, scheduled_at, sent_at, created_at, updated_at
FROM email_campaigns WHERE company_id = $1 AND id = $2`, companyID, id)
c, err := scanCampaign(row)
if errors.Is(err, pgx.ErrNoRows) {
return Campaign{}, ErrNotFound
}
if err != nil {
return Campaign{}, err
}
_ = s.attachLatestVersion(ctx, &c)
return c, nil
}
func (s *Service) Create(ctx context.Context, companyID uuid.UUID, createdBy *uuid.UUID, in CreateInput) (Campaign, error) {
name := strings.TrimSpace(in.Name)
if name == "" {
return Campaign{}, ErrNameRequired
}
tpl, err := GetTemplate(in.TemplateKey)
if err != nil {
return Campaign{}, err
}
useDefault := true
if in.UseDefaultPrompt != nil {
useDefault = *in.UseDefaultPrompt
}
prompt := SanitizePrompt(in.Prompt)
if useDefault && prompt == "" {
prompt = tpl.DefaultPrompt
}
if err := ValidatePrompt(prompt); err != nil {
return Campaign{}, err
}
af := in.AudienceFilter
if af == nil {
af = map[string]any{}
}
afBytes, err := json.Marshal(af)
if err != nil {
return Campaign{}, err
}
cats := in.CategoryIDs
if cats == nil {
cats = []uuid.UUID{}
}
prods := in.ProductIDs
if prods == nil {
prods = []uuid.UUID{}
}
if err := validateCampaignRefs(cats, prods); err != nil {
return Campaign{}, err
}
var id uuid.UUID
err = s.Pool.QueryRow(ctx, `
INSERT INTO email_campaigns (
company_id, name, template_key, category_ids, product_ids, prompt, use_default_prompt, audience_filter, created_by
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8::jsonb,$9)
RETURNING id`,
companyID, name, tpl.Key, cats, prods, prompt, useDefault, string(afBytes), createdBy,
).Scan(&id)
if err != nil {
return Campaign{}, err
}
return s.Get(ctx, companyID, id)
}
func (s *Service) Update(ctx context.Context, companyID, id uuid.UUID, in UpdateInput) (Campaign, error) {
cur, err := s.Get(ctx, companyID, id)
if err != nil {
return Campaign{}, err
}
name := cur.Name
if in.Name != nil {
name = strings.TrimSpace(*in.Name)
if name == "" {
return Campaign{}, ErrNameRequired
}
}
tplKey := cur.TemplateKey
if in.TemplateKey != nil {
tpl, err := GetTemplate(*in.TemplateKey)
if err != nil {
return Campaign{}, err
}
tplKey = tpl.Key
}
status := cur.Status
if in.Status != nil {
st := strings.TrimSpace(strings.ToLower(*in.Status))
switch st {
case "draft", "ready", "scheduled", "sent", "cancelled":
status = st
default:
return Campaign{}, ErrInvalidStatus
}
}
prompt := cur.Prompt
if in.Prompt != nil {
prompt = SanitizePrompt(*in.Prompt)
if err := ValidatePrompt(prompt); err != nil {
return Campaign{}, err
}
}
useDefault := cur.UseDefaultPrompt
if in.UseDefaultPrompt != nil {
useDefault = *in.UseDefaultPrompt
}
cats := cur.CategoryIDs
if in.CategoryIDs != nil {
cats = in.CategoryIDs
}
prods := cur.ProductIDs
if in.ProductIDs != nil {
prods = in.ProductIDs
}
if err := validateCampaignRefs(cats, prods); err != nil {
return Campaign{}, err
}
af := cur.AudienceFilter
if in.AudienceFilter != nil {
af = in.AudienceFilter
}
afBytes, err := json.Marshal(af)
if err != nil {
return Campaign{}, err
}
scheduledAt := cur.ScheduledAt
if in.ScheduledAt != nil {
scheduledAt = in.ScheduledAt
}
_, err = s.Pool.Exec(ctx, `
UPDATE email_campaigns SET
name=$3, template_key=$4, status=$5, category_ids=$6, product_ids=$7,
prompt=$8, use_default_prompt=$9, audience_filter=$10::jsonb, scheduled_at=$11, updated_at=now()
WHERE company_id=$1 AND id=$2`,
companyID, id, name, tplKey, status, cats, prods, prompt, useDefault, string(afBytes), scheduledAt,
)
if err != nil {
return Campaign{}, err
}
return s.Get(ctx, companyID, id)
}
func (s *Service) Delete(ctx context.Context, companyID, id uuid.UUID) error {
tag, err := s.Pool.Exec(ctx, `DELETE FROM email_campaigns WHERE company_id=$1 AND id=$2`, companyID, id)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
type scannable interface {
Scan(dest ...any) error
}
func scanCampaign(row scannable) (Campaign, error) {
var c Campaign
var af []byte
err := row.Scan(
&c.ID, &c.Name, &c.TemplateKey, &c.Status, &c.CategoryIDs, &c.ProductIDs, &c.Prompt, &c.UseDefaultPrompt,
&af, &c.ScheduledAt, &c.SentAt, &c.CreatedAt, &c.UpdatedAt,
)
if err != nil {
return Campaign{}, err
}
if c.CategoryIDs == nil {
c.CategoryIDs = []uuid.UUID{}
}
if c.ProductIDs == nil {
c.ProductIDs = []uuid.UUID{}
}
c.AudienceFilter = map[string]any{}
if len(af) > 0 {
_ = json.Unmarshal(af, &c.AudienceFilter)
}
if tpl, err := GetTemplate(c.TemplateKey); err == nil {
c.Season = tpl.Season
}
return c, nil
}
func applyVersionFields(c *Campaign, v Version) {
c.LatestVersion = &v
c.Subject = v.Subject
c.HTMLBody = v.HTMLBody
c.PlainBody = v.PlainBody
}
func (s *Service) attachLatestVersion(ctx context.Context, c *Campaign) error {
var v Version
err := s.Pool.QueryRow(ctx, `
SELECT id, version, subject, html_body, plain_body, generation_mode, generated_at
FROM email_campaign_versions
WHERE campaign_id=$1
ORDER BY version DESC LIMIT 1`, c.ID).Scan(
&v.ID, &v.Version, &v.Subject, &v.HTMLBody, &v.PlainBody, &v.GenerationMode, &v.GeneratedAt,
)
if errors.Is(err, pgx.ErrNoRows) {
return nil
}
if err != nil {
log.Printf("campaigns: attach version: %v", err)
return err
}
applyVersionFields(c, v)
return nil
}
// latestVersionsByCampaignIDsSQL loads one latest version per campaign (batch for List).
const latestVersionsByCampaignIDsSQL = `
SELECT DISTINCT ON (campaign_id)
campaign_id, id, version, subject, html_body, plain_body, generation_mode, generated_at
FROM email_campaign_versions
WHERE campaign_id = ANY($1)
ORDER BY campaign_id, version DESC`
// attachLatestVersions fills LatestVersion/subject/body fields for a page of campaigns in one query.
func (s *Service) attachLatestVersions(ctx context.Context, campaigns []Campaign) error {
if len(campaigns) == 0 {
return nil
}
ids := make([]uuid.UUID, len(campaigns))
for i := range campaigns {
ids[i] = campaigns[i].ID
}
rows, err := s.Pool.Query(ctx, latestVersionsByCampaignIDsSQL, ids)
if err != nil {
log.Printf("campaigns: attach versions batch: %v", err)
return err
}
defer rows.Close()
byID := make(map[uuid.UUID]Version, len(campaigns))
for rows.Next() {
var campaignID uuid.UUID
var v Version
if err := rows.Scan(
&campaignID, &v.ID, &v.Version, &v.Subject, &v.HTMLBody, &v.PlainBody, &v.GenerationMode, &v.GeneratedAt,
); err != nil {
return err
}
byID[campaignID] = v
}
if err := rows.Err(); err != nil {
return err
}
for i := range campaigns {
if v, ok := byID[campaigns[i].ID]; ok {
applyVersionFields(&campaigns[i], v)
}
}
return nil
}
func validateCampaignRefs(cats, prods []uuid.UUID) error {
if len(cats) > maxCampaignCategoryIDs {
return ErrTooManyCategoryIDs
}
if len(prods) > maxCampaignProductIDs {
return ErrTooManyProductIDs
}
return nil
}
+52
View File
@@ -0,0 +1,52 @@
package campaigns
import "errors"
var (
ErrNotFound = errors.New("campaign not found")
ErrProviderNotFound = errors.New("email provider not configured")
ErrProviderUnverified = errors.New("email provider not verified")
ErrInvalidEmail = errors.New("invalid email address")
ErrInvalidTemplate = errors.New("invalid template_key")
ErrInvalidStatus = errors.New("invalid status")
ErrMissingContent = errors.New("campaign has no generated content")
ErrAIRequiresUpgrade = errors.New("campaign AI generate requires a paid plan or AI credits")
ErrInsufficientCredits = errors.New("insufficient credits for campaign AI generate")
ErrAIUnavailable = errors.New("AI generation is not configured")
ErrRateLimited = errors.New("rate limit exceeded")
ErrUnsubscribed = errors.New("recipient is unsubscribed")
ErrMissingUnsubscribe = errors.New("generated HTML missing unsubscribe footer")
ErrPromptTooLong = errors.New("prompt exceeds maximum length")
ErrNameRequired = errors.New("name required")
ErrNoRecipients = errors.New("no recipients")
ErrConfirmRequired = errors.New("confirmation required to send campaign")
ErrTooManyProductIDs = errors.New("too many product_ids")
ErrTooManyCategoryIDs = errors.New("too many category_ids")
)
// ClientError reports whether err is a known client-facing campaign validation error.
func ClientError(err error) (msg string, ok bool) {
switch {
case err == nil:
return "", false
case errors.Is(err, ErrInvalidTemplate),
errors.Is(err, ErrInvalidStatus),
errors.Is(err, ErrInvalidEmail),
errors.Is(err, ErrMissingContent),
errors.Is(err, ErrMissingUnsubscribe),
errors.Is(err, ErrPromptTooLong),
errors.Is(err, ErrNameRequired),
errors.Is(err, ErrNoRecipients),
errors.Is(err, ErrAIUnavailable),
errors.Is(err, ErrConfirmRequired),
errors.Is(err, ErrTooManyProductIDs),
errors.Is(err, ErrTooManyCategoryIDs),
errors.Is(err, ErrUnsubscribed),
errors.Is(err, ErrRateLimited),
errors.Is(err, ErrProviderNotFound),
errors.Is(err, ErrProviderUnverified):
return err.Error(), true
default:
return "", false
}
}
@@ -0,0 +1,529 @@
package campaigns
import (
"context"
"errors"
"fmt"
"log"
"strings"
"time"
"github.com/descrybe/descrybe-v2/apps/api/internal/aiprompts"
"github.com/descrybe/descrybe-v2/apps/api/internal/company"
"github.com/descrybe/descrybe-v2/apps/api/internal/email"
"github.com/descrybe/descrybe-v2/apps/api/internal/processing"
"github.com/descrybe/descrybe-v2/apps/api/internal/security"
"github.com/google/uuid"
)
func (s *Service) Generate(ctx context.Context, companyID, id uuid.UUID, in GenerateInput) (Campaign, error) {
if !s.allowGenerate(companyID.String()) {
return Campaign{}, ErrRateLimited
}
c, err := s.Get(ctx, companyID, id)
if err != nil {
return Campaign{}, err
}
mode := strings.ToLower(strings.TrimSpace(in.Mode))
if mode == "" {
if in.UseAI != nil && *in.UseAI {
mode = "ai"
} else {
mode = "template"
}
}
if mode != "template" && mode != "ai" {
return Campaign{}, fmt.Errorf("mode must be template or ai")
}
tpl, err := GetTemplate(c.TemplateKey)
if err != nil {
return Campaign{}, err
}
brandName := s.companyName(ctx, companyID)
brand, _ := company.LoadBrand(ctx, s.Pool, companyID)
subject := renderSubject(tpl, brandName)
products := s.loadProductSnippets(ctx, companyID, c.ProductIDs, c.CategoryIDs)
logoAbs := company.AbsoluteLogoForEmbed(s.PublicAPIURL, s.TokenSigningSecret, companyID, brand.LogoURL)
html := templateHTML(subject, defaultIntro(tpl, brandName), buildProductHTML(products), s.WebOrigin, logoAbs)
plain := subject + "\n\n" + defaultIntro(tpl, brandName) + "\n\n" + productPlainList(products)
if mode == "ai" {
if s.Billing != nil {
if err := s.Billing.AssertFeatures(ctx, companyID, "capability.campaign_ai", "marketing.campaigns.generate_ai"); err != nil {
return Campaign{}, err
}
ent, err := s.Billing.EntitlementsForCompany(ctx, companyID)
if err != nil {
return Campaign{}, err
}
// Free tier: CanUseAI is false when no credits / free plan.
// Paid with CanUseAI but empty wallet must not run AI (no silent free generate).
if !ent.CanUseAI || ent.IsFreePlan {
return Campaign{}, ErrAIRequiresUpgrade
}
if ent.RemainingCredits < 1 {
return Campaign{}, ErrInsufficientCredits
}
}
var completer processing.Completer
if s.AI != nil {
cplt, _, _, rerr := s.AI.ResolveCompleter(ctx, companyID)
if rerr != nil {
return Campaign{}, ErrAIUnavailable
}
completer = cplt
} else {
completer = s.Completer
}
if completer == nil {
return Campaign{}, ErrAIUnavailable
}
if en, ok := completer.(processing.EnableChecker); ok && !en.Enabled() {
return Campaign{}, ErrAIUnavailable
}
sysTpl := ""
userTpl := ""
lang := company.LoadLanguage(ctx, s.Pool, companyID)
if s.Prompts != nil {
if resolved, perr := s.Prompts.Resolve(ctx, companyID, aiprompts.KeyCampaignEmail, lang); perr == nil {
sysTpl = resolved.SystemTemplate
userTpl = resolved.UserTemplate
}
}
if def, ok := aiprompts.DefaultFor(aiprompts.KeyCampaignEmail); ok {
if strings.TrimSpace(sysTpl) == "" {
sysTpl = def.SystemTemplate
}
if strings.TrimSpace(userTpl) == "" {
userTpl = def.UserTemplate
}
}
userPrompt := c.Prompt
if c.UseDefaultPrompt || strings.TrimSpace(userPrompt) == "" {
userPrompt = tpl.DefaultPrompt
}
userPrompt = SanitizePrompt(userPrompt)
userPrompt = security.TruncateRunes(userPrompt, 600)
products = limitProductSnippets(products, processing.MaxCampaignProducts)
vars := aiprompts.Vars{
"campaign_prompt": userPrompt,
"products": productPlainList(products),
"brand": brandName,
"brand_voice": processing.CompactBrandPrompt(brand.PromptBlock()),
"language": company.LanguageLabel(company.LoadLanguage(ctx, s.Pool, companyID)),
"template_key": c.TemplateKey,
}
system := strings.TrimSpace(aiprompts.Render(sysTpl, vars))
user := strings.TrimSpace(aiprompts.Render(userTpl, vars))
if user == "" {
user = userPrompt + "\n\nProducts:\n" + productPlainList(products) + "\nBrand: " + brandName
}
comp, obj, err := processing.CompleteJSON(ctx, completer, system, user, processing.CompleteOptions{
MaxTokens: processing.MaxTokensCampaign,
Temperature: processing.DefaultStructuredTemp,
})
if err != nil && obj == nil && comp.Text == "" {
log.Printf("campaigns: ai generate failed company=%s", companyID)
return Campaign{}, fmt.Errorf("ai generation failed")
}
parsed := parseAIContent(comp.Text, subject, html, plain)
if obj != nil {
if v, ok := obj["subject"].(string); ok && strings.TrimSpace(v) != "" {
parsed.Subject = strings.TrimSpace(v)
}
if v, ok := obj["html_body"].(string); ok && strings.TrimSpace(v) != "" {
parsed.HTML = v
} else if v, ok := obj["html"].(string); ok && strings.TrimSpace(v) != "" {
parsed.HTML = v
}
if v, ok := obj["plain_body"].(string); ok && strings.TrimSpace(v) != "" {
parsed.Plain = v
} else if v, ok := obj["text"].(string); ok && strings.TrimSpace(v) != "" {
parsed.Plain = v
}
}
subject, html, plain = parsed.Subject, parsed.HTML, parsed.Plain
if s.Billing != nil {
// Always debit base feature cost (even if provider reported 0 tokens).
if err := s.Billing.ConsumeCredits(ctx, companyID, comp.TotalTokens, "campaign_copy"); err != nil {
return Campaign{}, err
}
}
}
unsubURL := s.unsubscribePlaceholderURL(companyID)
html, plain = EnsureUnsubscribeFooter(html, plain, unsubURL)
html = SanitizeHTMLBody(html)
if !HasUnsubscribeFooter(html) {
html, plain = EnsureUnsubscribeFooter(html, plain, unsubURL)
html = SanitizeHTMLBody(html)
}
if !HasUnsubscribeFooter(html) {
return Campaign{}, ErrMissingUnsubscribe
}
subject = security.TruncateRunes(subject, MaxSubjectLen)
var nextVer int
err = s.Pool.QueryRow(ctx, `
SELECT COALESCE(MAX(version), 0) + 1 FROM email_campaign_versions
WHERE company_id=$1 AND campaign_id=$2`, companyID, id).Scan(&nextVer)
if err != nil {
return Campaign{}, err
}
_, err = s.Pool.Exec(ctx, `
INSERT INTO email_campaign_versions (
campaign_id, company_id, version, subject, html_body, plain_body, generation_mode
) VALUES ($1,$2,$3,$4,$5,$6,$7)`,
id, companyID, nextVer, subject, html, plain, mode,
)
if err != nil {
return Campaign{}, err
}
_, _ = s.Pool.Exec(ctx, `
UPDATE email_campaigns SET status='ready', updated_at=now() WHERE company_id=$1 AND id=$2`, companyID, id)
return s.Get(ctx, companyID, id)
}
func (s *Service) SendTest(ctx context.Context, companyID, id uuid.UUID, in SendTestInput) (Campaign, error) {
if !s.allowSend(companyID.String() + ":test") {
return Campaign{}, ErrRateLimited
}
to := strings.TrimSpace(in.To)
if to == "" {
to = strings.TrimSpace(in.Email)
}
addr, err := NormalizeEmail(to)
if err != nil {
return Campaign{}, ErrInvalidEmail
}
c, err := s.Get(ctx, companyID, id)
if err != nil {
return Campaign{}, err
}
if c.LatestVersion == nil || (c.Subject == "" && c.HTMLBody == "") {
return Campaign{}, ErrMissingContent
}
if s.Email == nil {
return Campaign{}, ErrProviderNotFound
}
cfg, err := s.Email.GetConfig(ctx, companyID)
if err != nil {
return Campaign{}, err
}
if !cfg.Configured {
return Campaign{}, ErrProviderNotFound
}
if !cfg.Verified {
return Campaign{}, ErrProviderUnverified
}
cid := id.String()
_, err = s.Email.Send(ctx, companyID, email.SendRequest{
To: []string{addr},
Subject: "[TEST] " + c.Subject,
Text: c.PlainBody,
HTML: c.HTMLBody,
CampaignID: &cid,
Mode: "test",
})
if err != nil {
return Campaign{}, mapEmailErr(err)
}
return s.Get(ctx, companyID, id)
}
func (s *Service) Schedule(ctx context.Context, companyID, id uuid.UUID, in ScheduleInput) (Campaign, error) {
if in.ScheduledAt.IsZero() || in.ScheduledAt.Before(time.Now().UTC().Add(-time.Minute)) {
return Campaign{}, fmt.Errorf("scheduled_at must be in the future")
}
if s.Email == nil {
return Campaign{}, ErrProviderNotFound
}
cfg, err := s.Email.GetConfig(ctx, companyID)
if err != nil {
return Campaign{}, err
}
if !cfg.Configured {
return Campaign{}, ErrProviderNotFound
}
if !cfg.Verified || !cfg.CanSendReal {
return Campaign{}, ErrProviderUnverified
}
c, err := s.Get(ctx, companyID, id)
if err != nil {
return Campaign{}, err
}
if c.LatestVersion == nil {
return Campaign{}, ErrMissingContent
}
_, err = s.Pool.Exec(ctx, `
UPDATE email_campaigns SET status='scheduled', scheduled_at=$3, updated_at=now()
WHERE company_id=$1 AND id=$2`, companyID, id, in.ScheduledAt.UTC())
if err != nil {
return Campaign{}, err
}
return s.Get(ctx, companyID, id)
}
func (s *Service) Send(ctx context.Context, companyID, id uuid.UUID, in SendInput) (Campaign, error) {
if !s.allowSend(companyID.String() + ":send") {
return Campaign{}, ErrRateLimited
}
if !in.Confirm {
return Campaign{}, ErrConfirmRequired
}
if !in.DryRun && s.Billing != nil {
if err := s.Billing.AssertFeatures(ctx, companyID, "capability.email_live_send", "marketing.campaigns.send"); err != nil {
return Campaign{}, err
}
}
c, err := s.Get(ctx, companyID, id)
if err != nil {
return Campaign{}, err
}
if c.LatestVersion == nil || c.HTMLBody == "" {
return Campaign{}, ErrMissingContent
}
if s.Email == nil {
return Campaign{}, ErrProviderNotFound
}
cfg, err := s.Email.GetConfig(ctx, companyID)
if err != nil {
return Campaign{}, err
}
if !cfg.Configured {
return Campaign{}, ErrProviderNotFound
}
if !in.DryRun && (!cfg.Verified || !cfg.CanSendReal) {
return Campaign{}, ErrProviderUnverified
}
recipients := in.Recipients
if len(recipients) == 0 {
res, err := s.ResolveAudienceMap(ctx, companyID, c.AudienceFilter, 100)
if err != nil {
return Campaign{}, err
}
for _, cust := range res.Customers {
recipients = append(recipients, cust.Email)
}
}
cleaned := make([]string, 0, len(recipients))
seen := map[string]struct{}{}
for _, raw := range recipients {
addr, err := NormalizeEmail(raw)
if err != nil {
continue
}
if _, ok := seen[addr]; ok {
continue
}
seen[addr] = struct{}{}
cleaned = append(cleaned, addr)
}
if len(cleaned) == 0 {
return Campaign{}, ErrNoRecipients
}
cid := id.String()
_, err = s.Email.Send(ctx, companyID, email.SendRequest{
To: cleaned,
Subject: c.Subject,
Text: c.PlainBody,
HTML: c.HTMLBody,
CampaignID: &cid,
Mode: "blast",
ConfirmUnderstood: email.ConfirmUnderstoodPhrase,
ForceDryRun: in.DryRun,
})
if err != nil {
return Campaign{}, mapEmailErr(err)
}
if !in.DryRun {
_, _ = s.Pool.Exec(ctx, `
UPDATE email_campaigns SET status='sent', sent_at=now(), updated_at=now()
WHERE company_id=$1 AND id=$2`, companyID, id)
}
return s.Get(ctx, companyID, id)
}
func mapEmailErr(err error) error {
switch {
case errors.Is(err, email.ErrNotConfigured):
return ErrProviderNotFound
case errors.Is(err, email.ErrNotVerified), errors.Is(err, email.ErrNotEnabled):
return ErrProviderUnverified
case errors.Is(err, email.ErrRateLimited):
return ErrRateLimited
case errors.Is(err, email.ErrMissingConfirm):
return ErrConfirmRequired
case errors.Is(err, email.ErrInvalidRecipient), errors.Is(err, email.ErrInvalidFrom):
return ErrInvalidEmail
default:
return err
}
}
func (s *Service) companyName(ctx context.Context, companyID uuid.UUID) string {
var name string
_ = s.Pool.QueryRow(ctx, `SELECT COALESCE(name, '') FROM companies WHERE id=$1`, companyID).Scan(&name)
name = strings.TrimSpace(name)
if name == "" {
return "our store"
}
return name
}
type productSnippet struct {
Name string
}
func (s *Service) loadProductSnippets(ctx context.Context, companyID uuid.UUID, productIDs, categoryIDs []uuid.UUID) []productSnippet {
out := make([]productSnippet, 0, 8)
if len(productIDs) > 0 {
rows, err := s.Pool.Query(ctx, `
SELECT COALESCE(NULLIF(processed_name, ''), NULLIF(name, ''), 'Product')
FROM processed_products
WHERE company_id=$1 AND id = ANY($2)
LIMIT 12`, companyID, productIDs)
if err == nil {
defer rows.Close()
for rows.Next() {
var name string
if rows.Scan(&name) == nil {
out = append(out, productSnippet{Name: name})
}
}
}
}
if len(out) == 0 && len(categoryIDs) > 0 {
rows, err := s.Pool.Query(ctx, `
SELECT COALESCE(NULLIF(p.processed_name, ''), NULLIF(p.name, ''), 'Product')
FROM processed_products p
JOIN categories c ON c.company_id = p.company_id
AND (c.name = p.category OR c.unique_id = p.category OR c.id::text = p.category)
WHERE p.company_id=$1 AND c.id = ANY($2::uuid[])
ORDER BY p.updated_at DESC NULLS LAST
LIMIT 12`, companyID, categoryIDs)
if err == nil {
defer rows.Close()
for rows.Next() {
var name string
if rows.Scan(&name) == nil {
out = append(out, productSnippet{Name: name})
}
}
}
}
if len(out) == 0 {
rows, err := s.Pool.Query(ctx, `
SELECT COALESCE(NULLIF(processed_name, ''), NULLIF(name, ''), 'Product')
FROM processed_products
WHERE company_id=$1
ORDER BY updated_at DESC NULLS LAST
LIMIT 6`, companyID)
if err == nil {
defer rows.Close()
for rows.Next() {
var name string
if rows.Scan(&name) == nil {
out = append(out, productSnippet{Name: name})
}
}
}
}
return out
}
func (s *Service) unsubscribePlaceholderURL(companyID uuid.UUID) string {
base := strings.TrimRight(s.WebOrigin, "/")
if base == "" {
base = strings.TrimRight(s.PublicAPIURL, "/")
}
if base == "" {
return "/unsubscribe"
}
return base + "/unsubscribe?company=" + companyID.String()
}
type aiParsed struct {
Subject string
HTML string
Plain string
}
func parseAIContent(text, fallbackSubject, fallbackHTML, fallbackPlain string) aiParsed {
text = strings.TrimSpace(text)
out := aiParsed{Subject: fallbackSubject, HTML: fallbackHTML, Plain: fallbackPlain}
obj, err := processing.ParseJSONObject(text)
if err == nil && obj != nil {
if v, ok := obj["subject"].(string); ok && strings.TrimSpace(v) != "" {
out.Subject = strings.TrimSpace(v)
}
if v, ok := obj["html_body"].(string); ok && strings.TrimSpace(v) != "" {
out.HTML = v
} else if v, ok := obj["html"].(string); ok && strings.TrimSpace(v) != "" {
out.HTML = v
}
if v, ok := obj["plain_body"].(string); ok && strings.TrimSpace(v) != "" {
out.Plain = v
} else if v, ok := obj["text"].(string); ok && strings.TrimSpace(v) != "" {
out.Plain = v
}
return out
}
if strings.Contains(text, "<") {
out.HTML = text
out.Plain = stripTags(text)
}
return out
}
func limitProductSnippets(products []productSnippet, max int) []productSnippet {
if max > 0 && len(products) > max {
products = products[:max]
}
out := make([]productSnippet, len(products))
copy(out, products)
for i := range out {
out[i].Name = security.TruncateRunes(out[i].Name, processing.MaxCampaignNameRunes)
}
return out
}
func defaultIntro(tpl Template, brand string) string {
switch TemplateKey(tpl.Key) {
case TemplateChristmas:
return fmt.Sprintf("Season's greetings from %s — here are a few holiday favorites we think you'll love.", brand)
case TemplateBlackFriday:
return fmt.Sprintf("Black Friday is here. %s picked standout products worth a look before they go.", brand)
case TemplateSpring:
return fmt.Sprintf("Spring refresh from %s — new energy for the season ahead.", brand)
default:
return fmt.Sprintf("A few highlights from %s, curated for you.", brand)
}
}
func buildProductHTML(products []productSnippet) string {
if len(products) == 0 {
return `<p><em>Your selected products will appear here.</em></p>`
}
var b strings.Builder
b.WriteString(`<ul style="padding-left:18px">`)
for _, p := range products {
b.WriteString("<li>" + escapeHTML(p.Name) + "</li>")
}
b.WriteString("</ul>")
return b.String()
}
func productPlainList(products []productSnippet) string {
if len(products) == 0 {
return "(no products selected)"
}
names := make([]string, 0, len(products))
for _, p := range products {
names = append(names, "- "+p.Name)
}
return strings.Join(names, "\n")
}
@@ -0,0 +1,49 @@
package campaigns
import (
"context"
"strings"
"testing"
"github.com/google/uuid"
)
func TestLatestVersionsByCampaignIDsSQL_batchesDistinctOn(t *testing.T) {
if !strings.Contains(latestVersionsByCampaignIDsSQL, "DISTINCT ON (campaign_id)") {
t.Fatal("expected DISTINCT ON so each campaign gets one latest version")
}
if !strings.Contains(latestVersionsByCampaignIDsSQL, "ANY($1)") {
t.Fatal("expected ANY($1) batch filter over campaign IDs")
}
if !strings.Contains(latestVersionsByCampaignIDsSQL, "ORDER BY campaign_id, version DESC") {
t.Fatal("expected ORDER BY campaign_id, version DESC for DISTINCT ON")
}
}
func TestApplyVersionFields(t *testing.T) {
c := Campaign{ID: uuid.New()}
v := Version{
ID: uuid.New(),
Version: 3,
Subject: "Hello",
HTMLBody: "<p>Hi</p>",
PlainBody: "Hi",
}
applyVersionFields(&c, v)
if c.Subject != "Hello" || c.HTMLBody != "<p>Hi</p>" || c.PlainBody != "Hi" {
t.Fatalf("subject/body not applied: %+v", c)
}
if c.LatestVersion == nil || c.LatestVersion.Version != 3 {
t.Fatalf("LatestVersion not applied: %+v", c.LatestVersion)
}
}
func TestAttachLatestVersionsEmpty(t *testing.T) {
s := &Service{}
if err := s.attachLatestVersions(context.TODO(), nil); err != nil {
t.Fatalf("empty page should no-op: %v", err)
}
if err := s.attachLatestVersions(context.TODO(), []Campaign{}); err != nil {
t.Fatalf("empty slice should no-op: %v", err)
}
}
+40
View File
@@ -0,0 +1,40 @@
package campaigns
import (
"testing"
"github.com/google/uuid"
)
func TestValidateCampaignRefs(t *testing.T) {
okCats := make([]uuid.UUID, maxCampaignCategoryIDs)
okProds := make([]uuid.UUID, maxCampaignProductIDs)
for i := range okCats {
okCats[i] = uuid.New()
}
for i := range okProds {
okProds[i] = uuid.New()
}
if err := validateCampaignRefs(okCats, okProds); err != nil {
t.Fatalf("expected ok, got %v", err)
}
tooManyCats := append(append([]uuid.UUID{}, okCats...), uuid.New())
if err := validateCampaignRefs(tooManyCats, nil); err != ErrTooManyCategoryIDs {
t.Fatalf("got %v want ErrTooManyCategoryIDs", err)
}
tooManyProds := append(append([]uuid.UUID{}, okProds...), uuid.New())
if err := validateCampaignRefs(nil, tooManyProds); err != ErrTooManyProductIDs {
t.Fatalf("got %v want ErrTooManyProductIDs", err)
}
}
func TestClientErrorTooManyRefs(t *testing.T) {
for _, err := range []error{ErrTooManyProductIDs, ErrTooManyCategoryIDs} {
msg, ok := ClientError(err)
if !ok || msg == "" {
t.Fatalf("ClientError(%v) ok=%v msg=%q", err, ok, msg)
}
}
}
+79
View File
@@ -0,0 +1,79 @@
package campaigns
import (
"net/http"
"sync"
"time"
"github.com/descrybe/descrybe-v2/apps/api/internal/aiprompts"
"github.com/descrybe/descrybe-v2/apps/api/internal/aiprovider"
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
"github.com/descrybe/descrybe-v2/apps/api/internal/email"
"github.com/descrybe/descrybe-v2/apps/api/internal/processing"
"github.com/jackc/pgx/v5/pgxpool"
)
// Service is the email campaigns API surface (CRUD + generate + schedule/send).
// Tenant sending goes through email.Service (verified provider, rate limits, unsub).
type Service struct {
Pool *pgxpool.Pool
Billing *billing.Service
Email *email.Service
Completer processing.Completer
AI *aiprovider.Service
Prompts *aiprompts.Service
WebOrigin string
PublicAPIURL string
// TokenSigningSecret signs public brand-logo URLs for email embeds.
TokenSigningSecret string
HTTP *http.Client
genMu sync.Mutex
genHit map[string][]time.Time
sendMu sync.Mutex
sendHit map[string][]time.Time
}
func NewService(pool *pgxpool.Pool, billingSvc *billing.Service, emailSvc *email.Service) *Service {
return &Service{
Pool: pool,
Billing: billingSvc,
Email: emailSvc,
HTTP: &http.Client{Timeout: 30 * time.Second},
genHit: make(map[string][]time.Time),
sendHit: make(map[string][]time.Time),
}
}
const (
generateRPM = 10
sendRPM = 20
)
func (s *Service) allowGenerate(companyID string) bool {
return allowWindow(&s.genMu, s.genHit, companyID, generateRPM, time.Minute)
}
func (s *Service) allowSend(companyID string) bool {
return allowWindow(&s.sendMu, s.sendHit, companyID, sendRPM, time.Minute)
}
func allowWindow(mu *sync.Mutex, hits map[string][]time.Time, key string, limit int, window time.Duration) bool {
now := time.Now()
cutoff := now.Add(-window)
mu.Lock()
defer mu.Unlock()
ts := hits[key]
kept := ts[:0]
for _, t := range ts {
if t.After(cutoff) {
kept = append(kept, t)
}
}
if len(kept) >= limit {
hits[key] = kept
return false
}
hits[key] = append(kept, now)
return true
}
+119
View File
@@ -0,0 +1,119 @@
package campaigns
import (
"fmt"
"strings"
)
// TemplateKey is a seasonal or custom campaign template identifier.
type TemplateKey string
const (
TemplateChristmas TemplateKey = "christmas"
TemplateBlackFriday TemplateKey = "black_friday"
TemplateSpring TemplateKey = "spring"
TemplateCustom TemplateKey = "custom"
)
type Template struct {
Key string `json:"key"`
Name string `json:"name"`
Season string `json:"season"`
DefaultSubject string `json:"default_subject"`
DefaultPrompt string `json:"default_prompt"`
Description string `json:"description"`
}
var builtInTemplates = []Template{
{
Key: string(TemplateChristmas),
Name: "Christmas",
Season: "christmas",
DefaultSubject: "Holiday picks from {{brand}}",
DefaultPrompt: "Warm Christmas email for these products. Festive, concise, clear CTA. JSON only.",
Description: "Festive seasonal campaign for holiday shoppers.",
},
{
Key: string(TemplateBlackFriday),
Name: "Black Friday",
Season: "black_friday",
DefaultSubject: "Black Friday deals from {{brand}}",
DefaultPrompt: "Urgent Black Friday email for these products. Limited-time value, no false claims, strong CTA. JSON only.",
Description: "Deal-focused Black Friday / Cyber Week campaign.",
},
{
Key: string(TemplateSpring),
Name: "Spring",
Season: "spring",
DefaultSubject: "Fresh for spring — {{brand}}",
DefaultPrompt: "Light spring email for these products. Renewal + practical benefits, clear CTA. JSON only.",
Description: "Seasonal spring refresh campaign.",
},
{
Key: string(TemplateCustom),
Name: "Custom",
Season: "custom",
DefaultSubject: "News from {{brand}}",
DefaultPrompt: "Clear marketing email for these products. Short subject, scannable body, CTA. JSON only.",
Description: "Blank slate with sensible defaults.",
},
}
func ListTemplates() []Template {
out := make([]Template, len(builtInTemplates))
copy(out, builtInTemplates)
return out
}
func GetTemplate(key string) (Template, error) {
key = strings.TrimSpace(strings.ToLower(key))
if key == "" {
key = string(TemplateCustom)
}
for _, t := range builtInTemplates {
if t.Key == key {
return t, nil
}
}
return Template{}, ErrInvalidTemplate
}
func ValidTemplateKey(key string) bool {
_, err := GetTemplate(key)
return err == nil
}
func renderSubject(tpl Template, brand string) string {
if brand == "" {
brand = "our store"
}
return strings.ReplaceAll(tpl.DefaultSubject, "{{brand}}", brand)
}
func templateHTML(subject, intro, productBlock, ctaURL, logoURL string) string {
if ctaURL == "" {
ctaURL = "#"
}
logoBlock := ""
if strings.TrimSpace(logoURL) != "" {
logoBlock = fmt.Sprintf(
`<p style="margin:0 0 16px"><img src="%s" alt="" width="120" style="max-width:160px;height:auto;border:0" /></p>`,
escapeAttr(logoURL),
)
}
return fmt.Sprintf(`<!DOCTYPE html><html><body style="font-family:Arial,sans-serif;color:#222;line-height:1.5">
%s<h1 style="font-size:22px;margin:0 0 12px">%s</h1>
<p>%s</p>
%s
<p style="margin:24px 0"><a href="%s" style="background:#2d1b4e;color:#fff;padding:10px 16px;text-decoration:none;border-radius:4px">Shop now</a></p>
</body></html>`, logoBlock, escapeHTML(subject), escapeHTML(intro), productBlock, escapeAttr(ctaURL))
}
func escapeHTML(s string) string {
r := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", `"`, "&quot;")
return r.Replace(s)
}
func escapeAttr(s string) string {
return escapeHTML(s)
}
@@ -0,0 +1,35 @@
package campaigns
import "testing"
func TestListTemplates(t *testing.T) {
tpls := ListTemplates()
if len(tpls) != 4 {
t.Fatalf("expected 4 templates, got %d", len(tpls))
}
for _, key := range []string{"christmas", "black_friday", "spring", "custom"} {
if !ValidTemplateKey(key) {
t.Fatalf("expected valid key %s", key)
}
}
}
func TestNormalizeEmail(t *testing.T) {
e, err := NormalizeEmail(" User@Example.COM ")
if err != nil || e != "user@example.com" {
t.Fatalf("got %q err=%v", e, err)
}
if _, err := NormalizeEmail("not-an-email"); err == nil {
t.Fatal("expected error")
}
}
func TestUnsubscribeFooter(t *testing.T) {
html, plain := EnsureUnsubscribeFooter("<p>Hi</p>", "Hi", "https://example.com/unsubscribe?token=abc")
if !HasUnsubscribeFooter(html) {
t.Fatalf("missing footer in %s", html)
}
if plain == "" {
t.Fatal("plain empty")
}
}
+126
View File
@@ -0,0 +1,126 @@
package campaigns
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"net/mail"
"regexp"
"strings"
"unicode"
"github.com/descrybe/descrybe-v2/apps/api/internal/security"
)
const (
MaxPromptLen = security.MaxCampaignPromptRunes
MaxSubjectLen = 200
MaxHTMLBodyLen = security.MaxEmailHTMLRunes
unsubscribeMark = "data-descrybe-unsubscribe"
)
var emailLoose = regexp.MustCompile(`(?i)^[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}$`)
// NormalizeEmail lowercases and trims; returns ErrInvalidEmail when invalid.
func NormalizeEmail(raw string) (string, error) {
raw = strings.TrimSpace(strings.ToLower(raw))
if raw == "" || len(raw) > 254 {
return "", ErrInvalidEmail
}
addr, err := mail.ParseAddress(raw)
if err != nil {
return "", ErrInvalidEmail
}
e := strings.TrimSpace(strings.ToLower(addr.Address))
if !emailLoose.MatchString(e) {
return "", ErrInvalidEmail
}
return e, nil
}
func EmailHash(email string) string {
sum := sha256.Sum256([]byte(strings.ToLower(strings.TrimSpace(email))))
return hex.EncodeToString(sum[:])
}
func NewToken() (string, error) {
b := make([]byte, 24)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
func ValidatePrompt(prompt string) error {
if security.CapPromptLength(prompt, MaxPromptLen) {
return ErrPromptTooLong
}
return nil
}
// SanitizePrompt bounds and soft-filters campaign prompts before AI / storage.
func SanitizePrompt(prompt string) string {
return security.SanitizePrompt(prompt, MaxPromptLen)
}
// SanitizeHTMLBody strips dangerous markup from generated/stored campaign HTML.
func SanitizeHTMLBody(html string) string {
return security.SanitizeEmailHTML(html)
}
func HasUnsubscribeFooter(html string) bool {
lower := strings.ToLower(html)
if strings.Contains(lower, unsubscribeMark) {
return true
}
if strings.Contains(lower, "unsubscribe") && (strings.Contains(lower, "href=") || strings.Contains(lower, "/unsubscribe")) {
return true
}
return false
}
func EnsureUnsubscribeFooter(html, plain, unsubscribeURL string) (string, string) {
if HasUnsubscribeFooter(html) {
if plain == "" {
plain = stripTags(html)
}
return html, plain
}
footerHTML := `<hr style="border:none;border-top:1px solid #ddd;margin:24px 0"/>` +
`<p style="font-size:12px;color:#666" ` + unsubscribeMark + `="1">` +
`You are receiving this because you opted in to marketing emails. ` +
`<a href="` + unsubscribeURL + `">Unsubscribe</a>.</p>`
footerPlain := "\n\n---\nUnsubscribe: " + unsubscribeURL + "\n"
if strings.TrimSpace(html) == "" {
html = "<div></div>"
}
html = html + footerHTML
if plain == "" {
plain = stripTags(html)
} else {
plain = plain + footerPlain
}
return html, plain
}
func stripTags(s string) string {
var b strings.Builder
inTag := false
for _, r := range s {
switch {
case r == '<':
inTag = true
case r == '>':
inTag = false
case !inTag:
if unicode.IsSpace(r) {
if b.Len() > 0 && b.String()[b.Len()-1] != ' ' {
b.WriteByte(' ')
}
} else {
b.WriteRune(r)
}
}
}
return strings.TrimSpace(b.String())
}