rdev/internal/adapter/notify/resend_client.go
jordan ee1c214b7e
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
fix: correct Resend DNS record type, name, and MX priority
Three bugs in the notify provisioner DNS record upsert:

1. rec.Record ("DKIM"/"SPF") was used as the DNS record type — Cloudflare
   doesn't know those labels. Fix: use rec.DNSType ("TXT"/"MX") from the
   resendDNSRecord.type JSON field, which is the actual DNS record type.

2. rec.Name from Resend is already relative to the zone apex
   (e.g., "resend._domainkey.mail.project-name"), not relative to the
   registered domain. Code was doing rec.Name + "." + host which produced
   a doubled subdomain. Fix: pass rec.Name directly — Cloudflare's
   normalizeName appends ".baseDomain" to build the correct FQDN.

3. MX records have priority 10 in Resend's response but DNSRecord had no
   Priority field and Cloudflare CreateRecord/UpdateRecord didn't send it.
   Fix: add Priority int to domain.DNSRecord and include it in the body
   for both Create and Update when non-zero.

These bugs caused DKIM/SPF DNS records to never be created for any project.
Re-provision affected projects using POST /projects/{id}/notify/provision
after clearing NOTIFY_RESEND_DOMAIN_ID from the credential store.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 19:52:11 -07:00

155 lines
5.1 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" field is a Resend semantic label ("DKIM", "SPF").
// The "type" field is the actual DNS record type ("TXT", "MX", "CNAME").
// The "name" field is the subdomain relative to the zone apex (e.g., "resend._domainkey.mail.project-name"),
// NOT relative to the registered domain — append "." + baseDomain to get the FQDN.
type resendDNSRecord struct {
Record string `json:"record"` // Resend semantic label: "DKIM", "SPF"
DNSType string `json:"type"` // actual DNS type: "TXT", "MX", "CNAME"
Name string `json:"name"` // subdomain relative to zone apex
Value string `json:"value"` // record content
Priority int `json:"priority,omitempty"` // for MX records
}
// 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))
}