rdev/internal/adapter/notify/resend_client.go
jordan fa0d030def
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
feat: improve notify domain verification reliability and add status endpoints
- Add verifyWithRetry to provisioner: 60s initial DNS propagation delay,
  5 retries with 30s backoff before marking verification as failed
- Add GetNotifyDomainStatus: polls Resend API for domain verification status,
  returns "not_configured" when Resend not set up
- Add VerifyProjectNotify: synchronous re-verification for handler use
- Add getDomainStatus to resendAPI interface + resendClient implementation
- Add NotifyDomainStatus domain struct (host, resend_domain_id, status)
- Guard NOTIFY_RESEND_DOMAIN_ID storage against empty string writes
- New handler: GET /projects/{id}/notify/status (returns verification state)
- New handler: POST /projects/{id}/notify/verify (triggers re-verification)
- Add verify-notify-domain cookbook step to persona-community,
  slackpath-1, and slackpath-4 trees (polls status for up to 6 min)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 16:25:55 -07:00

151 lines
4.7 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
getDomainStatus(ctx context.Context, domainID string) (status string, err 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"`
}
// resendGetDomainResponse is the shape returned by GET /domains/{id}.
type resendGetDomainResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"` // "verified", "pending", "failed"
}
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
}
// getDomainStatus returns the verification status of a Resend domain.
// Returned status is one of: "verified", "pending", "failed".
func (r *resendClient) getDomainStatus(ctx context.Context, domainID string) (string, error) {
body, err := r.doRequest(ctx, http.MethodGet, "/domains/"+domainID, nil)
if err != nil {
return "", fmt.Errorf("get resend domain %s status: %w", domainID, err)
}
var resp resendGetDomainResponse
if err := json.Unmarshal(body, &resp); err != nil {
return "", fmt.Errorf("unmarshal resend domain status response: %w", err)
}
return resp.Status, 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))
}