feat: add POST /projects/{id}/notify/provision to repair Resend domain
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
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:
parent
b03c1f8028
commit
c54664b751
@ -321,6 +321,54 @@ func (p *Provisioner) VerifyProjectNotify(ctx context.Context, projectID, resend
|
|||||||
return nil
|
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.
|
// 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) {
|
func (p *Provisioner) GetNotifyDomainStatus(ctx context.Context, host, resendDomainID string) (*domain.NotifyDomainStatus, error) {
|
||||||
if p.resend == nil || resendDomainID == "" {
|
if p.resend == nil || resendDomainID == "" {
|
||||||
|
|||||||
@ -37,6 +37,8 @@ func (h *NotifyHandler) Mount(r api.Router) {
|
|||||||
Get("/status", h.GetStatus)
|
Get("/status", h.GetStatus)
|
||||||
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).
|
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).
|
||||||
Post("/verify", h.Verify)
|
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.
|
// 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.
|
// 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) {
|
func (h *NotifyHandler) lookupNotifyCredentials(ctx context.Context, projectID string) (host, resendDomainID string) {
|
||||||
|
|||||||
@ -30,4 +30,10 @@ type NotifyProvisioner interface {
|
|||||||
|
|
||||||
// GetNotifyDomainStatus returns the Resend verification status for the project's email domain.
|
// GetNotifyDomainStatus returns the Resend verification status for the project's email domain.
|
||||||
GetNotifyDomainStatus(ctx context.Context, host, resendDomainID string) (*domain.NotifyDomainStatus, error)
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user