diff --git a/internal/adapter/notify/provisioner.go b/internal/adapter/notify/provisioner.go index 2e5fed2..61f2037 100644 --- a/internal/adapter/notify/provisioner.go +++ b/internal/adapter/notify/provisioner.go @@ -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 == "" { diff --git a/internal/handlers/notify.go b/internal/handlers/notify.go index e570148..416cf55 100644 --- a/internal/handlers/notify.go +++ b/internal/handlers/notify.go @@ -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) { diff --git a/internal/port/notify_provisioner.go b/internal/port/notify_provisioner.go index 8b63284..73c8e07 100644 --- a/internal/port/notify_provisioner.go +++ b/internal/port/notify_provisioner.go @@ -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) }