fix: correct Resend DNS record type, name, and MX priority
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

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>
This commit is contained in:
jordan 2026-02-23 19:52:11 -07:00
parent c54664b751
commit ee1c214b7e
4 changed files with 40 additions and 27 deletions

View File

@ -55,6 +55,9 @@ func (c *Client) CreateRecord(ctx context.Context, record domain.DNSRecord) (*do
"ttl": record.TTL,
"proxied": record.Proxied,
}
if record.Priority > 0 {
body["priority"] = record.Priority
}
resp, err := c.doRequest(ctx, "POST", fmt.Sprintf("/zones/%s/dns_records", c.zoneID), body)
if err != nil {
@ -84,6 +87,9 @@ func (c *Client) UpdateRecord(ctx context.Context, recordID string, record domai
"ttl": record.TTL,
"proxied": record.Proxied,
}
if record.Priority > 0 {
body["priority"] = record.Priority
}
resp, err := c.doRequest(ctx, "PUT", fmt.Sprintf("/zones/%s/dns_records/%s", c.zoneID, recordID), body)
if err != nil {

View File

@ -129,21 +129,21 @@ func (p *Provisioner) CreateProjectNotify(ctx context.Context, projectID, slug s
}
// 8. Add DNS records for DKIM/SPF (non-fatal).
// Resend returns record names relative to the registered domain; build FQDNs for Cloudflare.
// Cloudflare's normalizeName handles FQDNs ending in the zone name correctly.
// rec.Name is relative to the zone apex (e.g., "resend._domainkey.mail.slug").
// Cloudflare's normalizeName appends ".baseDomain" to build the FQDN.
if p.dns != nil && len(dnsRecords) > 0 {
for _, rec := range dnsRecords {
fqdn := rec.Name + "." + host
dnsRec := domain.DNSRecord{
Type: rec.Record,
Name: fqdn,
Content: rec.Value,
TTL: 1,
Type: rec.DNSType,
Name: rec.Name,
Content: rec.Value,
TTL: 1,
Priority: rec.Priority,
}
if _, upsertErr := p.dns.UpsertRecord(ctx, dnsRec); upsertErr != nil {
p.logger.Warn("failed to upsert notify DNS record",
"name", fqdn,
"record", rec.Record,
"name", rec.Name,
"type", rec.DNSType,
"project_id", projectID,
"error", upsertErr,
)
@ -341,18 +341,20 @@ func (p *Provisioner) ProvisionNotifyDomain(ctx context.Context, projectID, host
p.logger.Info("resend domain created", "host", host, "domain_id", resendDomainID, "project_id", projectID)
// Step 8: Add DKIM/SPF DNS records (non-fatal).
// rec.Name is relative to the zone apex (e.g., "resend._domainkey.mail.slug").
// Cloudflare's normalizeName appends ".baseDomain" to build the FQDN.
if p.dns != nil && len(dnsRecords) > 0 {
for _, rec := range dnsRecords {
fqdn := rec.Name + "." + host
if _, upsertErr := p.dns.UpsertRecord(ctx, domain.DNSRecord{
Type: rec.Record,
Name: fqdn,
Content: rec.Value,
TTL: 1,
Type: rec.DNSType,
Name: rec.Name,
Content: rec.Value,
TTL: 1,
Priority: rec.Priority,
}); upsertErr != nil {
p.logger.Warn("failed to upsert notify DNS record",
"name", fqdn,
"record", rec.Record,
"name", rec.Name,
"type", rec.DNSType,
"project_id", projectID,
"error", upsertErr,
)

View File

@ -28,12 +28,16 @@ type resendClient struct {
}
// resendDNSRecord is a DNS record returned by Resend after domain creation.
// The "record" JSON field contains the DNS record type (e.g., "TXT", "MX").
// 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"` // 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"`
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.

View File

@ -3,12 +3,13 @@ package domain
// DNSRecord represents a DNS record in a zone.
type DNSRecord struct {
ID string // Provider-specific ID
Type string // A, AAAA, CNAME, TXT, etc.
Name string // Subdomain or @ for root
Content string // IP address or target
TTL int // TTL in seconds, 1 = auto
Proxied bool // Cloudflare proxy enabled
ID string // Provider-specific ID
Type string // A, AAAA, CNAME, TXT, MX, etc.
Name string // Subdomain or @ for root
Content string // IP address or target
TTL int // TTL in seconds, 1 = auto
Proxied bool // Cloudflare proxy enabled
Priority int // MX/SRV record priority (0 = not set)
}
// DNSRecordType constants for common record types.