// Package mail sends the few emails a forge must send itself — an
// invite to somebody who has no account yet — through Resend's HTTP
// API. One endpoint, one JSON body, no SDK: the dependency posture of
// the rest of quarry. Everything that has an in-app inbox still goes
// through the notify plane; this is for people the forge cannot reach
// any other way.
package mail
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// Resend is a client for api.resend.com. A nil *Resend sends nothing
// and says so, which lets the caller report "not emailed" honestly.
type Resend struct {
key string
from string
base string
http *http.Client
}
// New returns a client, or nil when there is no key — the forge runs
// without mail, and every surface that would have sent one says so.
func New(key, from string) *Resend {
if key == "" {
return nil
}
if from == "" {
from = "quarry <[email protected]>"
}
return &Resend{key: key, from: from, base: "https://api.resend.com",
http: &http.Client{Timeout: 15 * time.Second}}
}
// WithBase points the client elsewhere: a test's fake endpoint.
func (r *Resend) WithBase(base string) *Resend {
if r == nil {
return nil
}
c := *r
c.base = strings.TrimSuffix(base, "/")
return &c
}
// From is the sender the forge writes as.
func (r *Resend) From() string {
if r == nil {
return ""
}
return r.from
}
// Message is one email. Text is required; HTML is optional and, when
// present, is what a mail client shows.
type Message struct {
To string
Subject string
Text string
HTML string
}
// ErrNoMail is what a nil client answers: mail is not set up here.
var ErrNoMail = errors.New("mail: not configured (no RESEND_API_KEY)")
// Send delivers one message and returns Resend's id for it, or why it
// did not go. A non-2xx answer is an error with the body's first line,
// because "status 422" alone never told anybody the domain was not
// verified.
func (r *Resend) Send(ctx context.Context, m Message) (id string, err error) {
if r == nil {
return "", ErrNoMail
}
if m.To == "" || !strings.Contains(m.To, "@") {
return "", fmt.Errorf("mail: %q is not an address", m.To)
}
body, err := json.Marshal(map[string]any{
"from": r.from, "to": []string{m.To}, "subject": m.Subject,
"text": m.Text, "html": m.HTML,
})
if err != nil {
return "", err
}
req, err := http.NewRequestWithContext(ctx, "POST", r.base+"/emails", bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bearer "+r.key)
req.Header.Set("Content-Type", "application/json")
resp, err := r.http.Do(req)
if err != nil {
return "", fmt.Errorf("mail: %w", err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
var e struct {
Message string `json:"message"`
Name string `json:"name"`
}
_ = json.Unmarshal(raw, &e)
reason := e.Message
if reason == "" {
reason = strings.TrimSpace(string(raw))
if i := strings.IndexByte(reason, '\n'); i > 0 {
reason = reason[:i]
}
}
if len(reason) > 200 {
reason = reason[:200]
}
return "", fmt.Errorf("mail: resend answered %d: %s", resp.StatusCode, reason)
}
var ok struct {
ID string `json:"id"`
}
_ = json.Unmarshal(raw, &ok)
return ok.ID, nil
}