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