Add Authentik SSO, budget visibility, Resend email, 12h item default

Opens Veola up to more users (Parodia's Authentik) and makes Apify spend
visible to everyone.

- Auth: Traefik forward-auth (trust X-Authentik-* headers only from
  trusted_proxies CIDRs, keyed by email, role synced from admin_group),
  keeping local password login as break-glass. New [auth] config,
  CaptureDirectIP + ForwardAuth middleware, deploy/authentik-forward-auth.md.
- Budget: count every Apify run (apify_api_usage table) and show
  calls + estimated cost to all users on the dashboard, with an optional
  monthly-budget bar. New [budget] config + settings.
- Email: Resend client for opt-in deal alerts and a weekly digest
  (Mon 09:00). Per-user email + toggles on Settings. New [resend] config.
- Defaults: new items default to a 12-hour poll interval to cut spend.

users table gains email/auth_source/email-pref columns (migrated in place).
go build/vet/test green; boots and migrates cleanly.
This commit is contained in:
prosolis
2026-06-20 11:12:11 -07:00
parent b6fadf6504
commit 7c95e4fd4e
23 changed files with 1883 additions and 261 deletions

171
internal/scheduler/email.go Normal file
View File

@@ -0,0 +1,171 @@
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, `<div style="font-family:sans-serif">`)
fmt.Fprintf(&b, `<h2>Veola deal: %s</h2>`, html.EscapeString(itemName))
fmt.Fprintf(&b, `<p style="font-size:20px"><strong>%s</strong> at %s</p>`, html.EscapeString(price), html.EscapeString(store))
if target != "" {
fmt.Fprintf(&b, `<p>Your target: %s</p>`, html.EscapeString(target))
}
if title != "" {
fmt.Fprintf(&b, `<p>%s</p>`, html.EscapeString(title))
}
if url != "" {
fmt.Fprintf(&b, `<p><a href="%s">View listing</a></p>`, html.EscapeString(url))
}
b.WriteString(`</div>`)
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(`<div style="font-family:sans-serif">`)
b.WriteString(`<h2>Veola weekly digest</h2>`)
if stats != nil {
fmt.Fprintf(&b, `<p>%d active items. Estimated money saved so far: $%.2f.</p>`,
stats.ActiveItems, stats.MoneySaved)
}
b.WriteString(`<table style="border-collapse:collapse" cellpadding="6">`)
b.WriteString(`<tr><th align="left">Item</th><th align="left">Best price</th><th align="left">Where</th></tr>`)
for _, it := range items {
best := "—"
if it.BestPrice != nil {
best = fmt.Sprintf("%s%.2f", currencyPrefix(it.BestPriceCurrency), *it.BestPrice)
}
fmt.Fprintf(&b, `<tr><td>%s</td><td>%s</td><td>%s</td></tr>`,
html.EscapeString(it.Name), html.EscapeString(best), html.EscapeString(it.BestPriceStore))
}
b.WriteString(`</table></div>`)
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()
}

View File

@@ -2,6 +2,7 @@ package scheduler
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"strconv"
@@ -64,6 +65,15 @@ func (s *Scheduler) Start(ctx context.Context) error {
for _, it := range items {
s.register(it)
}
// Weekly digest: Monday 09:00 server-local. Best-effort inside
// RunWeeklyDigest (no-op when Resend is unconfigured or nobody opted in).
if _, err := s.cron.AddFunc("0 9 * * 1", func() {
ctx, cancel := context.WithTimeout(s.rootCtx, 5*time.Minute)
defer cancel()
s.RunWeeklyDigest(ctx)
}); err != nil {
slog.Error("weekly digest schedule failed", "err", err)
}
s.cron.Start()
slog.Info("scheduler started", "items", len(items))
return nil
@@ -142,7 +152,6 @@ func (s *Scheduler) RunPoll(ctx context.Context, it models.Item) {
s.recordError(ctx, it.ID, "no marketplaces configured for this item")
return
}
apifyClient := s.apifyClient(ctx)
var results []apify.UnifiedResult
var errs []string
successes := 0
@@ -176,7 +185,7 @@ func (s *Scheduler) RunPoll(ctx context.Context, it models.Item) {
pcQueries = []string{""}
}
for _, q := range pcQueries {
pcRaw, err := apifyClient.Run(ctx, pcID, apify.PriceComparisonInput{
pcRaw, err := s.runApifyActor(ctx, pcID, apify.PriceComparisonInput{
Query: q, URL: it.URL,
ProxyConfiguration: s.proxyConfig(),
})
@@ -238,6 +247,9 @@ func (s *Scheduler) RunPoll(ctx context.Context, it models.Item) {
alerted = true
alertsSent++
}
// Email opted-in users in addition to ntfy. Best-effort: a mail
// failure must not block result storage or the next alert.
s.sendDealEmails(ctx, it, r)
}
price := r.Price
_, err = s.store.InsertResult(ctx, &models.Result{
@@ -307,6 +319,37 @@ func (s *Scheduler) apifyClient(ctx context.Context) *apify.Client {
return apify.New(key)
}
// runApifyActor runs an Apify actor and records the call against the daily
// usage counter that feeds the budget view. Every Apify-billed run in Veola
// goes through here so the count stays honest.
func (s *Scheduler) runApifyActor(ctx context.Context, actorID string, input any) ([]json.RawMessage, error) {
if err := s.store.IncrementApifyUsage(ctx); err != nil {
slog.Error("apify usage increment failed", "err", err)
}
return s.apifyClient(ctx).Run(ctx, actorID, input)
}
// ApifyUsage reports Apify run counts and the cost model used by the budget
// view. costPerCall and monthlyBudget come from settings, falling back to
// config.toml.
func (s *Scheduler) ApifyUsage(ctx context.Context) (today, month int, costPerCall, monthlyBudget float64) {
today, _ = s.store.ApifyUsageToday(ctx)
month, _ = s.store.ApifyUsageMonth(ctx)
costPerCall = s.cfg.Budget.CostPerApifyCall
if v, _ := s.store.GetSetting(ctx, "apify_cost_per_call"); v != "" {
if f, err := strconv.ParseFloat(strings.TrimSpace(v), 64); err == nil {
costPerCall = f
}
}
monthlyBudget = s.cfg.Budget.MonthlyBudgetUSD
if v, _ := s.store.GetSetting(ctx, "monthly_budget_usd"); v != "" {
if f, err := strconv.ParseFloat(strings.TrimSpace(v), 64); err == nil {
monthlyBudget = f
}
}
return today, month, costPerCall, monthlyBudget
}
// ebayClient returns the shared eBay client with credentials refreshed from
// settings (falling back to config.toml). The client caches its OAuth token
// in memory, so the same instance is reused across polls; credentials are
@@ -382,7 +425,7 @@ func (s *Scheduler) ExecutePlan(ctx context.Context, p actorPlan) ([]apify.Unifi
if p.actorID == "" {
return nil, fmt.Errorf("no actor configured for %s", p.marketplace)
}
raw, err := s.apifyClient(ctx).Run(ctx, p.actorID, p.input)
raw, err := s.runApifyActor(ctx, p.actorID, p.input)
if err != nil {
return nil, err
}
@@ -677,7 +720,7 @@ func (s *Scheduler) seedSoldHistoryFor(ctx context.Context, it models.Item, quer
if actorID == "" {
return
}
raw, err := s.apifyClient(ctx).Run(ctx, actorID, apify.SoldListingInput{
raw, err := s.runApifyActor(ctx, actorID, apify.SoldListingInput{
Query: query, Marketplace: marketplace, MaxResults: 50, DaysBack: 30,
ProxyConfiguration: s.proxyConfig(),
})