package notify import ( "context" "fmt" "github.com/orchard9/rdev/internal/domain" ) // ReprovisionNotifyHost migrates a project's notify setup from oldHost to newHost. // The project's notify account and send key are preserved; only the sending host, // Resend domain, and DNS records are replaced. // // Steps: // 1. Create new notify host newHost // 2. Add Resend provider to newHost // 3. Register noreply@newHost as from-address on newHost // 4. Find the project's existing notify account // 5. Revoke account access to oldHost (non-fatal) // 6. Grant account access to newHost (non-fatal) // 7. Create Resend domain for newHost // 8. Add DKIM/SPF DNS records for newHost (non-fatal) // 9. Delete old Resend domain (non-fatal) // // 10. Delete old DNS records for oldHost (non-fatal) // 11. Delete old notify host (non-fatal) // 12. Fire-and-forget async domain verification func (p *Provisioner) ReprovisionNotifyHost(ctx context.Context, projectID, oldHost, oldResendDomainID, newHost string) (*domain.NotifyCredentials, error) { newFrom := "noreply@" + newHost // 1. Create new notify host. if err := p.client.createHost(ctx, newHost, "failover"); err != nil { return nil, fmt.Errorf("notify: create host %s for project %s: %w", newHost, projectID, err) } // 2. Add Resend provider to new host. if p.resend != nil { if err := p.client.createProvider(ctx, newHost, "resend", map[string]string{"api_key": p.resendAPIKey}, 1, 3, 1000); err != nil { p.bestEffortDeleteHost(ctx, newHost, projectID) return nil, fmt.Errorf("notify: create provider on host %s for project %s: %w", newHost, projectID, err) } } // 3. Register from-address on new host. slug := newHost if err := p.client.createFromAddress(ctx, newHost, newFrom, slug); err != nil { p.bestEffortDeleteHost(ctx, newHost, projectID) return nil, fmt.Errorf("notify: create from-address %s for project %s: %w", newFrom, projectID, err) } // 4. Find existing account. acct, err := p.findAccountByProject(ctx, projectID) if err != nil { p.bestEffortDeleteHost(ctx, newHost, projectID) return nil, fmt.Errorf("notify: find account for project %s: %w", projectID, err) } if acct == nil { p.bestEffortDeleteHost(ctx, newHost, projectID) return nil, fmt.Errorf("notify: no account found for project %s", projectID) } // 5. Revoke old host access (non-fatal). if oldHost != "" { if err := p.client.revokeHostAccess(ctx, oldHost, acct.ID); err != nil { p.logger.Warn("failed to revoke old notify host access", "old_host", oldHost, "account_id", acct.ID, "project_id", projectID, "error", err) } } // 6. Grant new host access (non-fatal). if err := p.client.grantHostAccess(ctx, newHost, acct.ID); err != nil { p.logger.Warn("failed to grant new notify host access", "new_host", newHost, "account_id", acct.ID, "project_id", projectID, "error", err) } // 7. Create Resend domain for new host. var newResendDomainID string var dnsRecords []resendDNSRecord if p.resend != nil { var resendErr error newResendDomainID, dnsRecords, resendErr = p.resend.createDomain(ctx, newHost, "us-east-1") if resendErr != nil { p.bestEffortDeleteHost(ctx, newHost, projectID) return nil, fmt.Errorf("notify: create resend domain for %s: %w", newHost, resendErr) } p.logger.Info("resend domain created for new host", "host", newHost, "domain_id", newResendDomainID, "project_id", projectID) } // 8. Add DKIM/SPF DNS records for new host (non-fatal). if p.dns != nil && len(dnsRecords) > 0 { for _, rec := range dnsRecords { if _, upsertErr := p.dns.UpsertRecord(ctx, domain.DNSRecord{ 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 for new host", "name", rec.Name, "type", rec.DNSType, "project_id", projectID, "error", upsertErr) } } } // 9. Delete old Resend domain (non-fatal). if p.resend != nil && oldResendDomainID != "" { if err := p.resend.deleteDomain(ctx, oldResendDomainID); err != nil { p.logger.Warn("failed to delete old resend domain", "domain_id", oldResendDomainID, "project_id", projectID, "error", err) } } // 10. Delete old DNS records for oldHost (non-fatal). if p.dns != nil && oldHost != "" { dkimName := "resend._domainkey." + oldHost if err := p.dns.DeleteRecordByName(ctx, "TXT", dkimName); err != nil { p.logger.Warn("failed to delete old DKIM DNS record", "name", dkimName, "project_id", projectID, "error", err) } spfSendName := "send." + oldHost if err := p.dns.DeleteRecordByName(ctx, "MX", spfSendName); err != nil { p.logger.Warn("failed to delete old SPF MX DNS record", "name", spfSendName, "project_id", projectID, "error", err) } if err := p.dns.DeleteRecordByName(ctx, "TXT", spfSendName); err != nil { p.logger.Warn("failed to delete old SPF TXT DNS record", "name", spfSendName, "project_id", projectID, "error", err) } } // 11. Delete old notify host (non-fatal). if oldHost != "" { if err := p.client.deleteHost(ctx, oldHost); err != nil { p.logger.Warn("failed to delete old notify host", "host", oldHost, "project_id", projectID, "error", err) } } // 12. Fire-and-forget async domain verification. if p.resend != nil && newResendDomainID != "" { go func() { verifyCtx := context.WithoutCancel(ctx) p.verifyWithRetry(verifyCtx, newResendDomainID, newHost, projectID) }() } p.logger.Info("notify host reprovisioned", "project_id", projectID, "old_host", oldHost, "new_host", newHost, "resend_domain_id", newResendDomainID, ) return &domain.NotifyCredentials{ ProjectID: projectID, Host: newHost, From: newFrom, ResendDomainID: newResendDomainID, }, nil }