// Package handlers provides HTTP handlers for the rdev API. package handlers import ( "context" "log/slog" "net/http" "github.com/go-chi/chi/v5" "github.com/orchard9/rdev/internal/auth" "github.com/orchard9/rdev/internal/domain" "github.com/orchard9/rdev/internal/logging" "github.com/orchard9/rdev/internal/port" "github.com/orchard9/rdev/pkg/api" ) // NotifyHandler handles notify domain status and verification endpoints. type NotifyHandler struct { notifyProvisioner port.NotifyProvisioner // may be nil if not configured credStore port.CredentialStore // may be nil sharedNotifyHost string // platform shared host (e.g., "mail.threesix.ai"); empty if not configured logger *slog.Logger } // NewNotifyHandler creates a new notify handler. func NewNotifyHandler(notifyProvisioner port.NotifyProvisioner, credStore port.CredentialStore, logger *slog.Logger) *NotifyHandler { return &NotifyHandler{ notifyProvisioner: notifyProvisioner, credStore: credStore, logger: logger, } } // WithSharedNotifyHost sets the platform shared sending host. Used by the Reprovision // handler to distinguish "shared-host project" (has API key, no per-project host) from // "not provisioned at all" when currentHost is empty. func (h *NotifyHandler) WithSharedNotifyHost(host string) *NotifyHandler { h.sharedNotifyHost = host return h } // Mount registers the notify routes. func (h *NotifyHandler) Mount(r api.Router) { r.Route("/projects/{projectID}/notify", func(r chi.Router) { r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)). 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) r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)). Post("/reprovision", h.Reprovision) }) } // NotifyDomainStatusResponse is the response for GET /projects/{projectID}/notify/status. type NotifyDomainStatusResponse struct { Host string `json:"host"` ResendDomainID string `json:"resend_domain_id"` Status string `json:"status"` } // GetStatus returns the Resend domain verification status for the project. // GET /projects/{projectID}/notify/status func (h *NotifyHandler) GetStatus(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(), TimeoutLookup) defer cancel() log := logging.FromContext(ctx).WithHandler("NotifyGetStatus") host, resendDomainID := h.lookupNotifyCredentials(ctx, projectID) status, err := h.notifyProvisioner.GetNotifyDomainStatus(ctx, host, resendDomainID) if err != nil { log.Error("failed to get notify domain status", logging.FieldError, err, logging.FieldProjectID, projectID, ) api.WriteInternalError(w, r, "failed to get notify domain status") return } api.WriteSuccess(w, r, NotifyDomainStatusResponse{ Host: status.Host, ResendDomainID: status.ResendDomainID, Status: status.Status, }) } // Verify triggers domain re-verification for the project's Resend email domain. // POST /projects/{projectID}/notify/verify func (h *NotifyHandler) Verify(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("NotifyVerify") _, resendDomainID := h.lookupNotifyCredentials(ctx, projectID) if err := h.notifyProvisioner.VerifyProjectNotify(ctx, projectID, resendDomainID); err != nil { log.Error("failed to trigger notify domain verification", logging.FieldError, err, logging.FieldProjectID, projectID, ) api.WriteInternalError(w, r, "failed to trigger domain verification") return } api.WriteSuccess(w, r, map[string]string{ "message": "verification triggered", }) } // 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", }) } // notifyReprovisionRequest is the request body for POST /projects/{projectID}/notify/reprovision. type notifyReprovisionRequest struct { Host string `json:"host"` // new sending host, e.g. "mail.myapp.threesix.ai" } // Reprovision migrates a project's notify setup to a new sending host. // Use after adding a custom named domain to update email to send from that domain. // POST /projects/{projectID}/notify/reprovision func (h *NotifyHandler) Reprovision(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 } var req notifyReprovisionRequest if err := api.DecodeJSON(r, &req); err != nil { api.WriteBadRequest(w, r, "invalid request body") return } if req.Host == "" { api.WriteBadRequest(w, r, "host is required") return } ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard) defer cancel() log := logging.FromContext(ctx).WithHandler("NotifyReprovision") currentHost, currentResendDomainID := h.lookupNotifyCredentials(ctx, projectID) if currentHost == "" { // No per-project host stored. This is either a shared-host project (using platform default) // or a project with no notify provisioned at all. Check the provisioner to distinguish. existing, err := h.notifyProvisioner.GetProjectNotify(ctx, projectID) if err != nil || existing == nil { api.WriteBadRequest(w, r, "notify not provisioned for this project") return } // Shared-host project: allow migration. ReprovisionNotifyHost with oldHost="" skips // revocation and deletion of the shared platform host (which must not be deleted). } if currentHost == req.Host { api.WriteBadRequest(w, r, "new host is the same as the current host") return } creds, err := h.notifyProvisioner.ReprovisionNotifyHost(ctx, projectID, currentHost, currentResendDomainID, req.Host) if err != nil { log.Error("failed to reprovision notify host", logging.FieldError, err, logging.FieldProjectID, projectID, "old_host", currentHost, "new_host", req.Host, ) api.WriteInternalError(w, r, "failed to reprovision notify host") return } // Store updated credentials. if h.credStore != nil { for _, kv := range []struct{ key, val string }{ {domain.CredKeyNotifyHost, creds.Host}, {domain.CredKeyNotifyFrom, creds.From}, {domain.CredKeyNotifyResendDomainID, creds.ResendDomainID}, } { if err := h.credStore.Set(ctx, domain.Credential{ Key: projectID + ":" + kv.key, Value: kv.val, Category: domain.CredentialCategoryNotify, }); err != nil { log.Error("reprovisioned notify host but failed to store credential", logging.FieldError, err, logging.FieldProjectID, projectID, "credential_key", kv.key, ) } } } log.Info("notify host reprovisioned", logging.FieldProjectID, projectID, "old_host", currentHost, "new_host", creds.Host, "resend_domain_id", creds.ResendDomainID, ) api.WriteSuccess(w, r, map[string]string{ "host": creds.Host, "from": creds.From, "resend_domain_id": creds.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) { if h.credStore == nil { return "", "" } // Credentials are stored with project-scoped keys: "projectID:CRED_KEY" creds, err := h.credStore.GetMultiple(ctx, []string{ projectID + ":" + domain.CredKeyNotifyHost, projectID + ":" + domain.CredKeyNotifyResendDomainID, }) if err != nil { logging.FromContext(ctx).WithHandler("NotifyHandler").Warn( "failed to fetch notify credentials from store", logging.FieldError, err, logging.FieldProjectID, projectID, ) return "", "" } host = creds[projectID+":"+domain.CredKeyNotifyHost] resendDomainID = creds[projectID+":"+domain.CredKeyNotifyResendDomainID] return host, resendDomainID }