// 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 }