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

97
internal/email/client.go Normal file
View File

@@ -0,0 +1,97 @@
// Package email sends transactional mail through Resend (https://resend.com).
// Veola uses it for opt-in deal alerts and the weekly digest. Credentials are
// resolved at call time (settings table overriding config.toml), mirroring how
// the ntfy and Apify clients are built per-send.
package email
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
const apiURL = "https://api.resend.com/emails"
// Client is a thin wrapper around the Resend send endpoint.
type Client struct {
APIKey string
From string
HTTP *http.Client
}
// New returns a Resend client. apiKey and from are typically resolved from
// settings (falling back to config.toml) by the caller.
func New(apiKey, from string) *Client {
return &Client{
APIKey: strings.TrimSpace(apiKey),
From: strings.TrimSpace(from),
HTTP: &http.Client{Timeout: 15 * time.Second},
}
}
// Configured reports whether the client has enough to attempt a send.
func (c *Client) Configured() bool {
return c.APIKey != "" && c.From != ""
}
// Message is a single outbound email. HTML is the rendered body; Text is an
// optional plain-text alternative.
type Message struct {
To string
Subject string
HTML string
Text string
}
type sendRequest struct {
From string `json:"from"`
To []string `json:"to"`
Subject string `json:"subject"`
HTML string `json:"html,omitempty"`
Text string `json:"text,omitempty"`
}
// Send delivers one message. Returns an error if the client is unconfigured or
// Resend rejects the request.
func (c *Client) Send(ctx context.Context, m Message) error {
if c.APIKey == "" {
return fmt.Errorf("resend api key not configured")
}
if c.From == "" {
return fmt.Errorf("resend from address not configured")
}
if strings.TrimSpace(m.To) == "" {
return fmt.Errorf("recipient address required")
}
body, err := json.Marshal(sendRequest{
From: c.From,
To: []string{m.To},
Subject: m.Subject,
HTML: m.HTML,
Text: m.Text,
})
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.APIKey)
resp, err := c.HTTP.Do(req)
if err != nil {
return fmt.Errorf("resend POST: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return fmt.Errorf("resend returned %d: %s", resp.StatusCode, string(b))
}
return nil
}