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,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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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("&", "&", "<", "<", ">", ">", `"`, """)
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
Reference in New Issue
Block a user