rdev/internal/adapter/notify/resend_client.go
jordan 4f01015132
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
feat: implement project access enforcement and management API
- Fix no-op RequireProjectAccess middleware to enforce project_ids
- Apply project access middleware to all project-scoped routes
- Filter GET /projects by allowed project IDs for restricted keys
- Add GET /me endpoint with key identity, scopes, and project access info
- Add PATCH /keys/{id} for partial key updates (name, scopes, project_ids, allowed_ips, expires_in)
- Add GET/POST/DELETE /projects/{id}/access for project-centric access management
- Auto-grant creating key access when using POST /project/create-and-build
- Accept grant_to_key_ids in create-and-build to grant multiple keys on project creation
- Move newProvisionerWithDeps test helper from production code to test file

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 15:38:37 -07:00

129 lines
3.8 KiB
Go

package notify
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
const resendBaseURL = "https://api.resend.com"
// resendAPI is the interface Provisioner uses to call the Resend API.
// Extracted for testability.
type resendAPI interface {
createDomain(ctx context.Context, name, region string) (domainID string, records []resendDNSRecord, err error)
verifyDomain(ctx context.Context, domainID string) error
deleteDomain(ctx context.Context, domainID string) error
}
// resendClient calls the Resend API for domain management.
type resendClient struct {
apiKey string
httpClient *http.Client
}
// resendDNSRecord is a DNS record returned by Resend after domain creation.
// The "record" JSON field contains the DNS record type (e.g., "TXT", "MX").
type resendDNSRecord struct {
Record string `json:"record"` // DNS record type: "TXT", "MX", "CNAME"
Name string `json:"name"` // relative name (e.g., "resend._domainkey")
Value string `json:"value"` // record content
Priority int `json:"priority,omitempty"`
}
// resendCreateDomainResponse is the shape returned by POST /domains.
type resendCreateDomainResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Records []resendDNSRecord `json:"records"`
}
func newResendClient(apiKey string) *resendClient {
return &resendClient{
apiKey: apiKey,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
}
}
// createDomain creates a new Resend domain and returns the domain ID and DNS records to set.
func (r *resendClient) createDomain(ctx context.Context, name, region string) (domainID string, records []resendDNSRecord, err error) {
payload := map[string]string{"name": name, "region": region}
respBody, err := r.doRequest(ctx, http.MethodPost, "/domains", payload)
if err != nil {
return "", nil, fmt.Errorf("create resend domain %s: %w", name, err)
}
var resp resendCreateDomainResponse
if err := json.Unmarshal(respBody, &resp); err != nil {
return "", nil, fmt.Errorf("unmarshal resend domain response: %w", err)
}
return resp.ID, resp.Records, nil
}
// verifyDomain triggers domain verification on Resend.
func (r *resendClient) verifyDomain(ctx context.Context, domainID string) error {
_, err := r.doRequest(ctx, http.MethodPost, "/domains/"+domainID+"/verify", nil)
if err != nil {
return fmt.Errorf("verify resend domain %s: %w", domainID, err)
}
return nil
}
// deleteDomain removes a Resend domain by ID.
func (r *resendClient) deleteDomain(ctx context.Context, domainID string) error {
_, err := r.doRequest(ctx, http.MethodDelete, "/domains/"+domainID, nil)
if err != nil {
return fmt.Errorf("delete resend domain %s: %w", domainID, err)
}
return nil
}
// doRequest executes an HTTP request against the Resend API.
func (r *resendClient) doRequest(ctx context.Context, method, path string, bodyData any) ([]byte, error) {
var reqBody io.Reader
if bodyData != nil {
jsonBody, err := json.Marshal(bodyData)
if err != nil {
return nil, fmt.Errorf("marshal request: %w", err)
}
reqBody = bytes.NewReader(jsonBody)
}
req, err := http.NewRequestWithContext(ctx, method, resendBaseURL+path, reqBody)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+r.apiKey)
if bodyData != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := r.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("http do: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == http.StatusNoContent {
return nil, nil
}
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response body: %w", err)
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return respBody, nil
}
return nil, fmt.Errorf("resend API error (HTTP %d): %s", resp.StatusCode, string(respBody))
}