533 lines
16 KiB
Go
533 lines
16 KiB
Go
package campaigns
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/descrybe/descrybe-v2/apps/api/internal/aiaudit"
|
|
"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
|
|
}
|
|
}
|
|
// Tag the campaign role so admin AI inspector rows are not filed as processing.
|
|
ctx := aiaudit.WithCall(ctx, aiaudit.CallContext{CompanyID: companyID, Role: aiaudit.RoleCampaign})
|
|
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")
|
|
}
|