All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- 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>
151 lines
4.7 KiB
Go
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))
|
|
}
|