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, "ttl": record.TTL,
"proxied": record.Proxied, "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) resp, err := c.doRequest(ctx, "POST", fmt.Sprintf("/zones/%s/dns_records", c.zoneID), body)
if err != nil { if err != nil {
@ -84,6 +87,9 @@ func (c *Client) UpdateRecord(ctx context.Context, recordID string, record domai
"ttl": record.TTL, "ttl": record.TTL,
"proxied": record.Proxied, "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) resp, err := c.doRequest(ctx, "PUT", fmt.Sprintf("/zones/%s/dns_records/%s", c.zoneID, recordID), body)
if err != nil { 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). // 8. Add DNS records for DKIM/SPF (non-fatal).
// Resend returns record names relative to the registered domain; build FQDNs for Cloudflare. // rec.Name is relative to the zone apex (e.g., "resend._domainkey.mail.slug").
// Cloudflare's normalizeName handles FQDNs ending in the zone name correctly. // Cloudflare's normalizeName appends ".baseDomain" to build the FQDN.
if p.dns != nil && len(dnsRecords) > 0 { if p.dns != nil && len(dnsRecords) > 0 {
for _, rec := range dnsRecords { for _, rec := range dnsRecords {
fqdn := rec.Name + "." + host
dnsRec := domain.DNSRecord{ dnsRec := domain.DNSRecord{
Type: rec.Record, Type: rec.DNSType,
Name: fqdn, Name: rec.Name,
Content: rec.Value, Content: rec.Value,
TTL: 1, TTL: 1,
Priority: rec.Priority,
} }
if _, upsertErr := p.dns.UpsertRecord(ctx, dnsRec); upsertErr != nil { if _, upsertErr := p.dns.UpsertRecord(ctx, dnsRec); upsertErr != nil {
p.logger.Warn("failed to upsert notify DNS record", p.logger.Warn("failed to upsert notify DNS record",
"name", fqdn, "name", rec.Name,
"record", rec.Record, "type", rec.DNSType,
"project_id", projectID, "project_id", projectID,
"error", upsertErr, "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) p.logger.Info("resend domain created", "host", host, "domain_id", resendDomainID, "project_id", projectID)
// Step 8: Add DKIM/SPF DNS records (non-fatal). // 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 { if p.dns != nil && len(dnsRecords) > 0 {
for _, rec := range dnsRecords { for _, rec := range dnsRecords {
fqdn := rec.Name + "." + host
if _, upsertErr := p.dns.UpsertRecord(ctx, domain.DNSRecord{ if _, upsertErr := p.dns.UpsertRecord(ctx, domain.DNSRecord{
Type: rec.Record, Type: rec.DNSType,
Name: fqdn, Name: rec.Name,
Content: rec.Value, Content: rec.Value,
TTL: 1, TTL: 1,
Priority: rec.Priority,
}); upsertErr != nil { }); upsertErr != nil {
p.logger.Warn("failed to upsert notify DNS record", p.logger.Warn("failed to upsert notify DNS record",
"name", fqdn, "name", rec.Name,
"record", rec.Record, "type", rec.DNSType,
"project_id", projectID, "project_id", projectID,
"error", upsertErr, "error", upsertErr,
) )

View File

@ -28,12 +28,16 @@ type resendClient struct {
} }
// resendDNSRecord is a DNS record returned by Resend after domain creation. // 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 { type resendDNSRecord struct {
Record string `json:"record"` // DNS record type: "TXT", "MX", "CNAME" Record string `json:"record"` // Resend semantic label: "DKIM", "SPF"
Name string `json:"name"` // relative name (e.g., "resend._domainkey") DNSType string `json:"type"` // actual DNS type: "TXT", "MX", "CNAME"
Value string `json:"value"` // record content Name string `json:"name"` // subdomain relative to zone apex
Priority int `json:"priority,omitempty"` Value string `json:"value"` // record content
Priority int `json:"priority,omitempty"` // for MX records
} }
// resendCreateDomainResponse is the shape returned by POST /domains. // resendCreateDomainResponse is the shape returned by POST /domains.

View File

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