package scheduler import ( "context" "fmt" "html" "log/slog" "strings" "veola/internal/apify" "veola/internal/db" "veola/internal/email" "veola/internal/models" ) // emailClient builds a Resend client with credentials resolved from settings, // falling back to config.toml. Returns a client even when unconfigured; callers // check Configured() to skip silently. func (s *Scheduler) emailClient(ctx context.Context) *email.Client { apiKey := s.cfg.Resend.APIKey if v, _ := s.store.GetSetting(ctx, "resend_api_key"); v != "" { apiKey = v } from := s.cfg.Resend.From if v, _ := s.store.GetSetting(ctx, "resend_from"); v != "" { from = v } return email.New(apiKey, from) } // sendDealEmails mails a deal alert to every user who opted into deal-alert // email. Entirely best-effort: an unconfigured Resend account or a per-user // send failure is logged and swallowed. func (s *Scheduler) sendDealEmails(ctx context.Context, it models.Item, r apify.UnifiedResult) { client := s.emailClient(ctx) if !client.Configured() { return } recipients, err := s.store.UsersWithDealAlerts(ctx) if err != nil { slog.Error("load deal-alert recipients failed", "err", err) return } if len(recipients) == 0 { return } subject := fmt.Sprintf("Veola deal: %s", it.Name) price := fmt.Sprintf("%s%.2f", currencyPrefix(r.Currency), r.Price) target := "" if it.TargetPrice != nil { target = fmt.Sprintf("%s%.2f", currencyPrefix(r.Currency), *it.TargetPrice) } body := dealEmailHTML(it.Name, r.Title, r.Store, price, target, r.URL) text := dealEmailText(it.Name, r.Title, r.Store, price, target, r.URL) for _, u := range recipients { if err := client.Send(ctx, email.Message{ To: u.Email, Subject: subject, HTML: body, Text: text, }); err != nil { slog.Error("deal email send failed", "to", u.Email, "err", err) } } } // RunWeeklyDigest builds and mails the weekly digest to every opted-in user. // Public so it can be triggered manually as well as on the cron schedule. func (s *Scheduler) RunWeeklyDigest(ctx context.Context) { client := s.emailClient(ctx) if !client.Configured() { return } recipients, err := s.store.UsersWithWeeklyDigest(ctx) if err != nil { slog.Error("load digest recipients failed", "err", err) return } if len(recipients) == 0 { return } items, err := s.store.ListActiveItems(ctx) if err != nil { slog.Error("digest item load failed", "err", err) return } stats, err := s.store.GetDashboardStats(ctx) if err != nil { slog.Error("digest stats load failed", "err", err) return } subject := fmt.Sprintf("Veola weekly digest — %d items watched", len(items)) body := digestHTML(items, stats) text := digestText(items, stats) for _, u := range recipients { if err := client.Send(ctx, email.Message{ To: u.Email, Subject: subject, HTML: body, Text: text, }); err != nil { slog.Error("digest email send failed", "to", u.Email, "err", err) } } slog.Info("weekly digest sent", "recipients", len(recipients), "items", len(items)) } func dealEmailHTML(itemName, title, store, price, target, url string) string { var b strings.Builder fmt.Fprintf(&b, `
`) fmt.Fprintf(&b, `

Veola deal: %s

`, html.EscapeString(itemName)) fmt.Fprintf(&b, `

%s at %s

`, html.EscapeString(price), html.EscapeString(store)) if target != "" { fmt.Fprintf(&b, `

Your target: %s

`, html.EscapeString(target)) } if title != "" { fmt.Fprintf(&b, `

%s

`, html.EscapeString(title)) } if url != "" { fmt.Fprintf(&b, `

View listing

`, html.EscapeString(url)) } b.WriteString(`
`) return b.String() } func dealEmailText(itemName, title, store, price, target, url string) string { var b strings.Builder fmt.Fprintf(&b, "Veola deal: %s\n\n%s at %s\n", itemName, price, store) if target != "" { fmt.Fprintf(&b, "Your target: %s\n", target) } if title != "" { fmt.Fprintf(&b, "%s\n", title) } if url != "" { fmt.Fprintf(&b, "%s\n", url) } return b.String() } func digestHTML(items []models.Item, stats *db.DashboardStats) string { var b strings.Builder b.WriteString(`
`) b.WriteString(`

Veola weekly digest

`) if stats != nil { fmt.Fprintf(&b, `

%d active items. Estimated money saved so far: $%.2f.

`, stats.ActiveItems, stats.MoneySaved) } b.WriteString(``) b.WriteString(``) for _, it := range items { best := "—" if it.BestPrice != nil { best = fmt.Sprintf("%s%.2f", currencyPrefix(it.BestPriceCurrency), *it.BestPrice) } fmt.Fprintf(&b, ``, html.EscapeString(it.Name), html.EscapeString(best), html.EscapeString(it.BestPriceStore)) } b.WriteString(`
ItemBest priceWhere
%s%s%s
`) return b.String() } func digestText(items []models.Item, stats *db.DashboardStats) string { var b strings.Builder b.WriteString("Veola weekly digest\n\n") if stats != nil { fmt.Fprintf(&b, "%d active items. Estimated money saved so far: $%.2f.\n\n", stats.ActiveItems, stats.MoneySaved) } for _, it := range items { best := "no price yet" if it.BestPrice != nil { best = fmt.Sprintf("%s%.2f", currencyPrefix(it.BestPriceCurrency), *it.BestPrice) } fmt.Fprintf(&b, "- %s: %s %s\n", it.Name, best, it.BestPriceStore) } return b.String() }