The Settings page no longer renders the Resend From Address input or any of the read-only "Set in config.toml" credential status rows (Apify key, eBay id/secret, ntfy auth, Resend key). Those rows looked like editable inputs but were dead text, and duplicated what the Test buttons verify more authoritatively. - resend_from is now sourced only from config.toml ([resend] from); it is removed from settingsKeys, the test-Resend handler, the scheduler email client, and the schema seed. - Removed the credStatus templ component, the CredentialStatus field on SettingsData, and the credentialStatus() handler helper.
182 lines
6.2 KiB
Go
182 lines
6.2 KiB
Go
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
|
|
}
|
|
return email.New(apiKey, s.cfg.Resend.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, `<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 vs 30-day average: $%.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 vs 30-day average: $%.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()
|
|
}
|