Skip to content
quarry Artifact [362bb621f0]
Login

Artifact [362bb621f0]

Artifact 362bb621f0943868b49597ad6818f46a181d22744eb4fac773a32145527fd41f:


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