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.
98 lines
2.5 KiB
Go
98 lines
2.5 KiB
Go
// 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
|
|
}
|