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 `
Your selected products will appear here.
` } var b strings.Builder b.WriteString(`