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) } // dealMailer resolves the item owner and Resend client for deal-alert email. // It returns (nil, nil) when email should be skipped (Resend unconfigured, no // owner, owner opted out, or no address) so the caller can resolve once per // poll instead of once per result. Best-effort: errors are logged and swallowed. func (s *Scheduler) dealMailer(ctx context.Context, it models.Item) (*email.Client, *models.User) { client := s.emailClient(ctx) if !client.Configured() || it.UserID == 0 { return nil, nil } owner, err := s.store.GetUserByID(ctx, it.UserID) if err != nil { slog.Error("load item owner failed", "item_id", it.ID, "err", err) return nil, nil } if owner == nil || !owner.EmailDealAlerts || owner.Email == "" { return nil, nil } return client, owner } // sendDealEmail mails a single deal alert to the resolved owner. The client and // owner come from dealMailer (resolved once per poll), so this does no DB or // settings I/O per result. Items are private, so a deal only ever notifies the // person watching it. func (s *Scheduler) sendDealEmail(ctx context.Context, client *email.Client, owner *models.User, it models.Item, r apify.UnifiedResult) { 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) if err := client.Send(ctx, email.Message{ To: owner.Email, Subject: subject, HTML: body, Text: text, }); err != nil { slog.Error("deal email send failed", "to", owner.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 } // Each digest is built from only the recipient's own items and stats, since // items are private. A user watching nothing is skipped rather than mailed // an empty table. sent := 0 for _, u := range recipients { items, err := s.store.ListActiveItemsForUser(ctx, u.ID) if err != nil { slog.Error("digest item load failed", "user_id", u.ID, "err", err) continue } if len(items) == 0 { continue } stats, err := s.store.GetDashboardStatsForUser(ctx, u.ID) if err != nil { slog.Error("digest stats load failed", "user_id", u.ID, "err", err) continue } subject := fmt.Sprintf("Veola weekly digest — %d items watched", len(items)) if err := client.Send(ctx, email.Message{ To: u.Email, Subject: subject, HTML: digestHTML(items, stats), Text: digestText(items, stats), }); err != nil { slog.Error("digest email send failed", "to", u.Email, "err", err) continue } sent++ } slog.Info("weekly digest sent", "recipients", sent) } func dealEmailHTML(itemName, title, store, price, target, url string) string { var b strings.Builder 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, ``, html.EscapeString(url)) } b.WriteString(`%d active items. Estimated money saved vs 30-day average: $%.2f.
`, stats.ActiveItems, stats.MoneySaved) } b.WriteString(`| Item | Best price | Where |
|---|---|---|
| %s | %s | %s |