feat: add POST /projects/{id}/notify/provision to repair Resend domain
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

Repairs projects where notify account was created but Resend domain
provisioning (steps 7-9) failed — e.g., RESEND_API_KEY not yet
configured at project creation time.

- ProvisionNotifyDomain in port.NotifyProvisioner interface
- Provisioner.ProvisionNotifyDomain: creates Resend domain for existing
  notify host, upserts DKIM/SPF DNS records in Cloudflare, kicks off
  async verification via verifyWithRetry
- POST /projects/{id}/notify/provision handler:
  - reads NOTIFY_HOST from credential store (fails if not set)
  - rejects if NOTIFY_RESEND_DOMAIN_ID already present (use /verify)
  - stores returned domain ID as NOTIFY_RESEND_DOMAIN_ID credential

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jordan 2026-02-23 17:52:03 -07:00
parent b03c1f8028
commit c54664b751
3 changed files with 126 additions and 0 deletions

View File

@ -321,6 +321,54 @@ func (p *Provisioner) VerifyProjectNotify(ctx context.Context, projectID, resend
return nil
}
// ProvisionNotifyDomain creates the Resend domain for an existing notify host, adds DKIM/SPF DNS
// records via Cloudflare, and kicks off async verification. Use this to repair projects where
// Resend domain creation failed during initial provisioning (steps 7-9 of CreateProjectNotify).
// Returns the Resend domain ID which must be stored as NOTIFY_RESEND_DOMAIN_ID by the caller.
func (p *Provisioner) ProvisionNotifyDomain(ctx context.Context, projectID, host string) (string, error) {
if p.resend == nil {
return "", fmt.Errorf("notify: resend not configured")
}
if host == "" {
return "", fmt.Errorf("notify: host is required")
}
// Step 7: Create Resend domain for the existing notify host.
resendDomainID, dnsRecords, err := p.resend.createDomain(ctx, host, "us-east-1")
if err != nil {
return "", fmt.Errorf("notify: create resend domain for %s: %w", host, err)
}
p.logger.Info("resend domain created", "host", host, "domain_id", resendDomainID, "project_id", projectID)
// Step 8: Add DKIM/SPF DNS records (non-fatal).
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,
}); upsertErr != nil {
p.logger.Warn("failed to upsert notify DNS record",
"name", fqdn,
"record", rec.Record,
"project_id", projectID,
"error", upsertErr,
)
}
}
}
// Step 9: Fire-and-forget async verification with DNS propagation wait.
go func() {
verifyCtx := context.WithoutCancel(ctx)
p.verifyWithRetry(verifyCtx, resendDomainID, host, projectID)
}()
return resendDomainID, nil
}
// GetNotifyDomainStatus returns the Resend verification status for the project's email domain.
func (p *Provisioner) GetNotifyDomainStatus(ctx context.Context, host, resendDomainID string) (*domain.NotifyDomainStatus, error) {
if p.resend == nil || resendDomainID == "" {

View File

@ -37,6 +37,8 @@ func (h *NotifyHandler) Mount(r api.Router) {
Get("/status", h.GetStatus)
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).
Post("/verify", h.Verify)
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).
Post("/provision", h.Provision)
})
}
@ -120,6 +122,76 @@ func (h *NotifyHandler) Verify(w http.ResponseWriter, r *http.Request) {
})
}
// Provision creates the Resend domain and DNS records for a project whose notify account exists
// but whose Resend domain was never provisioned (e.g., RESEND_API_KEY was added after project creation).
// POST /projects/{projectID}/notify/provision
func (h *NotifyHandler) Provision(w http.ResponseWriter, r *http.Request) {
if h.notifyProvisioner == nil {
api.WriteError(w, r, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "notify not configured")
return
}
projectID := chi.URLParam(r, "projectID")
if projectID == "" {
api.WriteBadRequest(w, r, "project ID is required")
return
}
ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard)
defer cancel()
log := logging.FromContext(ctx).WithHandler("NotifyProvision")
host, existingDomainID := h.lookupNotifyCredentials(ctx, projectID)
if host == "" {
api.WriteBadRequest(w, r, "NOTIFY_HOST not found for project — notify account not provisioned")
return
}
if existingDomainID != "" {
// Already has a Resend domain ID; use /verify or /status instead
api.WriteBadRequest(w, r, "Resend domain already provisioned — use POST /notify/verify to re-verify")
return
}
resendDomainID, err := h.notifyProvisioner.ProvisionNotifyDomain(ctx, projectID, host)
if err != nil {
log.Error("failed to provision notify domain",
logging.FieldError, err,
logging.FieldProjectID, projectID,
)
api.WriteInternalError(w, r, "failed to provision Resend domain")
return
}
// Store the new domain ID so future status/verify calls work.
if h.credStore != nil {
if err := h.credStore.Set(ctx, domain.Credential{
Key: projectID + ":" + domain.CredKeyNotifyResendDomainID,
Value: resendDomainID,
Category: domain.CredentialCategoryNotify,
}); err != nil {
log.Error("provisioned Resend domain but failed to store domain ID",
logging.FieldError, err,
logging.FieldProjectID, projectID,
"resend_domain_id", resendDomainID,
)
// Return success anyway — the domain IS provisioned; the ID can be stored manually.
}
}
log.Info("notify domain provisioned",
logging.FieldProjectID, projectID,
"host", host,
"resend_domain_id", resendDomainID,
)
api.WriteSuccess(w, r, map[string]string{
"host": host,
"resend_domain_id": resendDomainID,
"status": "verifying",
})
}
// lookupNotifyCredentials fetches NOTIFY_HOST and NOTIFY_RESEND_DOMAIN_ID from the credential store.
// Returns empty strings if credStore is nil or the credentials are not found.
func (h *NotifyHandler) lookupNotifyCredentials(ctx context.Context, projectID string) (host, resendDomainID string) {

View File

@ -30,4 +30,10 @@ type NotifyProvisioner interface {
// GetNotifyDomainStatus returns the Resend verification status for the project's email domain.
GetNotifyDomainStatus(ctx context.Context, host, resendDomainID string) (*domain.NotifyDomainStatus, error)
// ProvisionNotifyDomain creates the Resend domain for an existing notify host,
// adds DKIM/SPF DNS records, and starts async verification.
// Use this to repair projects where steps 7-9 of CreateProjectNotify failed.
// Returns the Resend domain ID for storage in the credential store.
ProvisionNotifyDomain(ctx context.Context, projectID, host string) (resendDomainID string, err error)
}