From 0f25bd8dbe73790d241d37e474b5cd8da2518756 Mon Sep 17 00:00:00 2001 From: jordan Date: Sat, 21 Feb 2026 00:30:32 -0700 Subject: [PATCH] feat: hook in notify service for per-project email delivery - Add NotifyProvisioner (port + adapter) using real notify admin API - Create notify account + send key + host grant per project - Inject NOTIFY_API_KEY/HOST/FROM into component deployments - Store NOTIFY_URL, NOTIFY_ADMIN_KEY, RESEND_API_KEY in credential store - Add setup-notify.sh for one-time host/provider/domain setup - Add NOTIFY_ADMIN_KEY constant to domain/credential.go - Wire provisioner in main.go with connection test guard - Add .claude/guides/services/notify.md and CLAUDE.md entry Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 1 + cmd/rdev-api/config.go | 14 ++ cmd/rdev-api/main.go | 21 +++ internal/adapter/notify/admin_client.go | 165 +++++++++++++++++++++++ internal/adapter/notify/provisioner.go | 155 ++++++++++++++++++++++ internal/domain/credential.go | 8 ++ internal/domain/notify.go | 25 ++++ internal/port/notify_provisioner.go | 23 ++++ internal/service/project_infra.go | 7 + internal/service/project_infra_crud.go | 47 +++++++ scripts/load-credentials.sh | 3 + scripts/setup-notify.sh | 169 ++++++++++++++++++++++++ 12 files changed, 638 insertions(+) create mode 100644 internal/adapter/notify/admin_client.go create mode 100644 internal/adapter/notify/provisioner.go create mode 100644 internal/domain/notify.go create mode 100644 internal/port/notify_provisioner.go create mode 100755 scripts/setup-notify.sh diff --git a/CLAUDE.md b/CLAUDE.md index e6e08c5..94e4931 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,6 +67,7 @@ Both use `lib/pq` driver. The `type: postgres` component API provisions **Cockro | **Traefik v3 ingress & middleware** | [ops/traefik-v3.md](.claude/guides/ops/traefik-v3.md) | | **Zot container registry** | [ops/zot-registry.md](.claude/guides/ops/zot-registry.md) | | **cert-manager / TLS certificates** | [ops/cert-manager.md](.claude/guides/ops/cert-manager.md) | +| **Notify / email delivery** | [services/notify.md](.claude/guides/services/notify.md) | | **Structured logging** | `internal/logging/` - field constants, context propagation, redaction | ## Critical Rules diff --git a/cmd/rdev-api/config.go b/cmd/rdev-api/config.go index c6f371b..ec86221 100644 --- a/cmd/rdev-api/config.go +++ b/cmd/rdev-api/config.go @@ -87,6 +87,12 @@ type InfraConfig struct { GCSProjectID string // e.g., "threesix-prod" GCSCredentialsPath string // Path to service account JSON (empty = ADC) GCSLocation string // Bucket location (default: "US") + + // Notify provisioner (for project email delivery) + NotifyURL string // e.g., "https://notify.orchard9.ai" + NotifyAdminKey string // notify_admin_... admin API key + NotifyHost string // shared host (e.g., "threesix.ai") + NotifyFrom string // from-address (e.g., "noreply@threesix.ai") } func loadConfig() Config { @@ -147,6 +153,8 @@ func loadInfraConfig(ctx context.Context, store port.CredentialStore, cfg Config domain.CredKeyWoodpeckerAPIToken, domain.CredKeyWoodpeckerWebhookSecret, domain.CredKeyRegistryURL, + domain.CredKeyNotifyURL, + domain.CredKeyNotifyAdminKey, }) if err != nil { logger.Warn("failed to load credentials from store, using env vars", "error", err) @@ -189,6 +197,12 @@ func loadInfraConfig(ctx context.Context, store port.CredentialStore, cfg Config GCSProjectID: os.Getenv("GCS_PROJECT_ID"), GCSCredentialsPath: os.Getenv("GCS_CREDENTIALS_PATH"), GCSLocation: envutil.GetEnv("GCS_LOCATION", "US"), + + // Notify provisioner (credential store with env fallback) + NotifyURL: getOrFallback(domain.CredKeyNotifyURL, os.Getenv("NOTIFY_URL")), + NotifyAdminKey: getOrFallback(domain.CredKeyNotifyAdminKey, os.Getenv("NOTIFY_ADMIN_KEY")), + NotifyHost: envutil.GetEnv("NOTIFY_HOST", "threesix.ai"), + NotifyFrom: envutil.GetEnv("NOTIFY_FROM", "noreply@threesix.ai"), } // Log which credentials were loaded from store vs env diff --git a/cmd/rdev-api/main.go b/cmd/rdev-api/main.go index 6f4a37c..0d16eba 100644 --- a/cmd/rdev-api/main.go +++ b/cmd/rdev-api/main.go @@ -18,6 +18,7 @@ import ( "github.com/orchard9/rdev/internal/adapter/gitea" "github.com/orchard9/rdev/internal/adapter/kubernetes" "github.com/orchard9/rdev/internal/adapter/memory" + notifyadapter "github.com/orchard9/rdev/internal/adapter/notify" "github.com/orchard9/rdev/internal/adapter/postgres" redisadapter "github.com/orchard9/rdev/internal/adapter/redis" sdlcadapter "github.com/orchard9/rdev/internal/adapter/sdlc" @@ -240,6 +241,23 @@ func main() { } defer closeProvisioner(storageProvisioner, "gcs", logger) + // Initialize notify provisioner (optional - for project email delivery) + var notifyProvisioner port.NotifyProvisioner + if infraCfg.NotifyURL != "" && infraCfg.NotifyAdminKey != "" { + np := notifyadapter.NewProvisioner(notifyadapter.Config{ + BaseURL: infraCfg.NotifyURL, + AdminKey: infraCfg.NotifyAdminKey, + Host: infraCfg.NotifyHost, + From: infraCfg.NotifyFrom, + }, logger) + if err := np.TestConnection(context.Background()); err != nil { + logger.Warn("notify provisioner connection test failed, disabling", "error", err) + } else { + notifyProvisioner = np + logger.Info("notify provisioner initialized", "url", infraCfg.NotifyURL, "host", infraCfg.NotifyHost) + } + } + // Initialize registry client (for monitoring and image cleanup on project teardown) var registryClient *zot.Client if infraCfg.RegistryURL != "" { @@ -482,6 +500,9 @@ func main() { if citadelClient != nil { projectInfraService = projectInfraService.WithCitadelClient(citadelClient) } + if notifyProvisioner != nil { + projectInfraService = projectInfraService.WithNotifyProvisioner(notifyProvisioner) + } // Create domain service adapter for infrastructure handler domainServiceAdapter := handlers.NewDomainServiceAdapter(projectInfraService) diff --git a/internal/adapter/notify/admin_client.go b/internal/adapter/notify/admin_client.go new file mode 100644 index 0000000..87b5183 --- /dev/null +++ b/internal/adapter/notify/admin_client.go @@ -0,0 +1,165 @@ +// Package notify provides a notify service admin client for rdev. +// It manages accounts and send keys on behalf of projects. +package notify + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +// adminClient calls the notify admin API to manage accounts and keys. +type adminClient struct { + baseURL string + adminKey string + httpClient *http.Client +} + +func newAdminClient(baseURL, adminKey string) *adminClient { + return &adminClient{ + baseURL: baseURL, + adminKey: adminKey, + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +// accountResponse is the shape returned by POST /admin/accounts. +type accountResponse struct { + ID string `json:"id"` + Name string `json:"name"` + CreatedAt time.Time `json:"created_at"` +} + +// apiKeyResponse is the shape returned by POST /admin/api-keys (full key only on creation). +type apiKeyResponse struct { + ID int `json:"id"` + Key string `json:"key"` // plaintext — only present on creation + KeyPrefix string `json:"key_prefix"` // e.g. "notify_send" + AccountID string `json:"account_id"` + KeyType string `json:"key_type"` + Name string `json:"name"` + CreatedAt time.Time `json:"created_at"` +} + +// listAccountsResponse is the shape returned by GET /admin/accounts. +type listAccountsResponse struct { + Items []accountResponse `json:"items"` +} + +// createAccount creates a new notify account with the given name. +func (c *adminClient) createAccount(ctx context.Context, name string) (*accountResponse, error) { + payload := map[string]string{"name": name} + respBody, err := c.doRequest(ctx, http.MethodPost, "/admin/accounts", payload) + if err != nil { + return nil, fmt.Errorf("create account: %w", err) + } + + var acct accountResponse + if err := json.Unmarshal(respBody, &acct); err != nil { + return nil, fmt.Errorf("unmarshal account response: %w", err) + } + return &acct, nil +} + +// createSendKey creates a send API key for the given account. +// The plaintext key is only present in the response at creation time. +func (c *adminClient) createSendKey(ctx context.Context, accountID, name string) (*apiKeyResponse, error) { + payload := map[string]string{ + "account_id": accountID, + "key_type": "send", + "name": name, + } + respBody, err := c.doRequest(ctx, http.MethodPost, "/admin/api-keys", payload) + if err != nil { + return nil, fmt.Errorf("create send key: %w", err) + } + + var key apiKeyResponse + if err := json.Unmarshal(respBody, &key); err != nil { + return nil, fmt.Errorf("unmarshal key response: %w", err) + } + return &key, nil +} + +// grantHostAccess grants the given account access to send from the specified host slug. +func (c *adminClient) grantHostAccess(ctx context.Context, hostSlug, accountID string) error { + payload := map[string]string{"account_id": accountID} + _, err := c.doRequest(ctx, http.MethodPost, "/admin/hosts/"+hostSlug+"/accounts", payload) + if err != nil { + return fmt.Errorf("grant host access: %w", err) + } + return nil +} + +// deleteAccount removes the notify account and all its keys. +func (c *adminClient) deleteAccount(ctx context.Context, accountID string) error { + _, err := c.doRequest(ctx, http.MethodDelete, "/admin/accounts/"+accountID, nil) + if err != nil { + return fmt.Errorf("delete account: %w", err) + } + return nil +} + +// listAccounts returns all accounts in the notify service. +func (c *adminClient) listAccounts(ctx context.Context) ([]accountResponse, error) { + respBody, err := c.doRequest(ctx, http.MethodGet, "/admin/accounts", nil) + if err != nil { + return nil, fmt.Errorf("list accounts: %w", err) + } + + var resp listAccountsResponse + if err := json.Unmarshal(respBody, &resp); err != nil { + return nil, fmt.Errorf("unmarshal accounts list: %w", err) + } + return resp.Items, nil +} + +// doRequest executes an HTTP request against the notify admin API. +func (c *adminClient) doRequest(ctx context.Context, method, path string, bodyData any) ([]byte, error) { + var reqBody io.Reader + if bodyData != nil { + jsonBody, err := json.Marshal(bodyData) + if err != nil { + return nil, fmt.Errorf("marshal request: %w", err) + } + reqBody = bytes.NewReader(jsonBody) + } + + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reqBody) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+c.adminKey) + if bodyData != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("http do: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + // 204 No Content — success with no body (e.g., grant host access, delete) + if resp.StatusCode == http.StatusNoContent { + return nil, nil + } + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response body: %w", err) + } + + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return respBody, nil + } + + return nil, fmt.Errorf("notify admin API error (HTTP %d): %s", resp.StatusCode, string(respBody)) +} diff --git a/internal/adapter/notify/provisioner.go b/internal/adapter/notify/provisioner.go new file mode 100644 index 0000000..8e59e1e --- /dev/null +++ b/internal/adapter/notify/provisioner.go @@ -0,0 +1,155 @@ +package notify + +import ( + "context" + "fmt" + "log/slog" + "time" + + "github.com/orchard9/rdev/internal/domain" +) + +// Provisioner implements port.NotifyProvisioner using the notify admin API. +// Each project gets an isolated notify account and send key scoped to the +// shared sending host (e.g., "threesix.ai"). +type Provisioner struct { + client *adminClient + host string // shared sending host slug (e.g., "threesix.ai") + from string // from-address (e.g., "noreply@threesix.ai") + logger *slog.Logger +} + +// Config holds configuration for the notify provisioner. +type Config struct { + BaseURL string // Required: notify service URL (e.g., "https://notify.orchard9.ai") + AdminKey string // Required: admin API key (notify_admin_...) + Host string // Shared host slug for all projects (e.g., "threesix.ai") + From string // Default from-address (e.g., "noreply@threesix.ai") +} + +// NewProvisioner creates a new notify provisioner. +func NewProvisioner(cfg Config, logger *slog.Logger) *Provisioner { + host := cfg.Host + if host == "" { + host = "threesix.ai" + } + from := cfg.From + if from == "" { + from = "noreply@threesix.ai" + } + return &Provisioner{ + client: newAdminClient(cfg.BaseURL, cfg.AdminKey), + host: host, + from: from, + logger: logger, + } +} + +// CreateProjectNotify provisions a notify account and send key for the project. +// Steps: +// 1. Create account named "project-{projectID}" +// 2. Create send API key via POST /admin/api-keys +// 3. Grant account access to the shared host +func (p *Provisioner) CreateProjectNotify(ctx context.Context, projectID string) (*domain.NotifyCredentials, error) { + accountName := "project-" + projectID + + // 1. Create account + acct, err := p.client.createAccount(ctx, accountName) + if err != nil { + return nil, fmt.Errorf("notify: create account for project %s: %w", projectID, err) + } + + // 2. Create send key (plaintext key only returned here) + key, err := p.client.createSendKey(ctx, acct.ID, accountName+"-send") + if err != nil { + // Best-effort cleanup + if delErr := p.client.deleteAccount(ctx, acct.ID); delErr != nil { + p.logger.Warn("failed to clean up notify account after key creation failure", + "account_id", acct.ID, + "project_id", projectID, + "error", delErr, + ) + } + return nil, fmt.Errorf("notify: create send key for project %s: %w", projectID, err) + } + + // 3. Grant host access + if err := p.client.grantHostAccess(ctx, p.host, acct.ID); err != nil { + p.logger.Warn("failed to grant notify host access", + "host", p.host, + "account_id", acct.ID, + "project_id", projectID, + "error", err, + ) + } + + return &domain.NotifyCredentials{ + ProjectID: projectID, + AccountID: acct.ID, + APIKey: key.Key, + Host: p.host, + From: p.from, + CreatedAt: time.Now(), + }, nil +} + +// DeleteProjectNotify removes the notify account for the project. +func (p *Provisioner) DeleteProjectNotify(ctx context.Context, projectID string) error { + acct, err := p.findAccountByProject(ctx, projectID) + if err != nil { + return fmt.Errorf("notify: find account for project %s: %w", projectID, err) + } + if acct == nil { + return nil // Already deleted or never provisioned + } + + if err := p.client.deleteAccount(ctx, acct.ID); err != nil { + return fmt.Errorf("notify: delete account %s for project %s: %w", acct.ID, projectID, err) + } + return nil +} + +// GetProjectNotify returns notify credentials for the project, or nil if not provisioned. +// Note: APIKey cannot be retrieved after creation — returns empty string. +func (p *Provisioner) GetProjectNotify(ctx context.Context, projectID string) (*domain.NotifyCredentials, error) { + acct, err := p.findAccountByProject(ctx, projectID) + if err != nil { + return nil, fmt.Errorf("notify: find account for project %s: %w", projectID, err) + } + if acct == nil { + return nil, nil + } + + return &domain.NotifyCredentials{ + ProjectID: projectID, + AccountID: acct.ID, + Host: p.host, + From: p.from, + CreatedAt: acct.CreatedAt, + }, nil +} + +// TestConnection verifies the notify admin API is reachable. +func (p *Provisioner) TestConnection(ctx context.Context) error { + _, err := p.client.listAccounts(ctx) + if err != nil { + return fmt.Errorf("notify admin API unreachable: %w", err) + } + return nil +} + +// findAccountByProject looks up the account named "project-{projectID}". +func (p *Provisioner) findAccountByProject(ctx context.Context, projectID string) (*accountResponse, error) { + accounts, err := p.client.listAccounts(ctx) + if err != nil { + return nil, err + } + + targetName := "project-" + projectID + for i := range accounts { + if accounts[i].Name == targetName { + return &accounts[i], nil + } + } + return nil, nil +} diff --git a/internal/domain/credential.go b/internal/domain/credential.go index 287e360..0b0bf1f 100644 --- a/internal/domain/credential.go +++ b/internal/domain/credential.go @@ -38,6 +38,7 @@ const ( CredentialCategoryWorker = "worker" CredentialCategoryStorage = "storage" CredentialCategoryAI = "ai" + CredentialCategoryNotify = "notify" ) // Known credential keys. @@ -65,4 +66,11 @@ const ( // AI Providers CredKeyLaozhangAPIKey = "LAOZHANG_API_KEY" CredKeyGeminiAPIKey = "GEMINI_API_KEY" + + // Notify service (email delivery) + CredKeyNotifyURL = "NOTIFY_URL" + CredKeyNotifyAdminKey = "NOTIFY_ADMIN_KEY" + CredKeyNotifyAPIKey = "NOTIFY_API_KEY" + CredKeyNotifyHost = "NOTIFY_HOST" + CredKeyNotifyFrom = "NOTIFY_FROM" ) diff --git a/internal/domain/notify.go b/internal/domain/notify.go new file mode 100644 index 0000000..770717c --- /dev/null +++ b/internal/domain/notify.go @@ -0,0 +1,25 @@ +// Package domain contains core business entities. +package domain + +import "time" + +// NotifyCredentials holds per-project email delivery credentials. +type NotifyCredentials struct { + // ProjectID is the rdev project this credential set belongs to. + ProjectID string + + // AccountID is the notify service account UUID (used for deletion). + AccountID string + + // APIKey is the notify send key (notify_send_...) for sending emails. + APIKey string + + // Host is the shared sending host (e.g., "threesix.ai"). + Host string + + // From is the from-address for outgoing email (e.g., "noreply@threesix.ai"). + From string + + // CreatedAt is when the credentials were provisioned. + CreatedAt time.Time +} diff --git a/internal/port/notify_provisioner.go b/internal/port/notify_provisioner.go new file mode 100644 index 0000000..4973f76 --- /dev/null +++ b/internal/port/notify_provisioner.go @@ -0,0 +1,23 @@ +package port + +import ( + "context" + + "github.com/orchard9/rdev/internal/domain" +) + +// NotifyProvisioner manages per-project email delivery accounts on the notify service. +type NotifyProvisioner interface { + // CreateProjectNotify creates a notify account and send key for a project. + // Grants the account access to the shared host and returns credentials. + CreateProjectNotify(ctx context.Context, projectID string) (*domain.NotifyCredentials, error) + + // DeleteProjectNotify removes the notify account for a project. + DeleteProjectNotify(ctx context.Context, projectID string) error + + // GetProjectNotify returns notify credentials for a project, or nil if not provisioned. + GetProjectNotify(ctx context.Context, projectID string) (*domain.NotifyCredentials, error) + + // TestConnection verifies the admin API key and notify service are reachable. + TestConnection(ctx context.Context) error +} diff --git a/internal/service/project_infra.go b/internal/service/project_infra.go index f5b5a76..4b287ed 100644 --- a/internal/service/project_infra.go +++ b/internal/service/project_infra.go @@ -35,6 +35,7 @@ type ProjectInfraService struct { dbProvisioner port.DatabaseProvisioner cacheProvisioner port.CacheProvisioner storageProvisioner port.StorageProvisioner + notifyProvisioner port.NotifyProvisioner registryProvider port.RegistryProvider citadelClient port.CitadelClient @@ -109,6 +110,12 @@ func (s *ProjectInfraService) WithStorageProvisioner(sp port.StorageProvisioner) return s } +// WithNotifyProvisioner sets the notify provisioner for project email delivery. +func (s *ProjectInfraService) WithNotifyProvisioner(np port.NotifyProvisioner) *ProjectInfraService { + s.notifyProvisioner = np + return s +} + // WithRegistryProvider sets the container registry provider for image cleanup. func (s *ProjectInfraService) WithRegistryProvider(rp port.RegistryProvider) *ProjectInfraService { s.registryProvider = rp diff --git a/internal/service/project_infra_crud.go b/internal/service/project_infra_crud.go index 12b8d49..f0f5d59 100644 --- a/internal/service/project_infra_crud.go +++ b/internal/service/project_infra_crud.go @@ -467,6 +467,46 @@ func (s *ProjectInfraService) provisionResources(ctx context.Context, result *Cr } } + // Provision notify email delivery (idempotent) + if s.notifyProvisioner != nil { + existing, _ := s.notifyProvisioner.GetProjectNotify(ctx, projectID) + if existing != nil { + log.Info("notify already provisioned, skipping", logging.FieldProjectID, projectID) + } else { + notifyCreds, err := s.notifyProvisioner.CreateProjectNotify(ctx, projectID) + if err != nil { + log.Error("failed to provision notify", logging.FieldProjectID, projectID, logging.FieldError, err) + result.NextSteps = append(result.NextSteps, "Notify provisioning failed - contact admin") + } else if s.credentialStore != nil { + var storeErr error + if err := s.storeCredential(ctx, projectID, domain.CredentialCategoryNotify, domain.CredKeyNotifyAPIKey, notifyCreds.APIKey); err != nil { + storeErr = err + log.Error("failed to store NOTIFY_API_KEY", logging.FieldProjectID, projectID, logging.FieldError, err) + } + if err := s.storeCredential(ctx, projectID, domain.CredentialCategoryNotify, domain.CredKeyNotifyHost, notifyCreds.Host); err != nil { + storeErr = err + log.Error("failed to store NOTIFY_HOST", logging.FieldProjectID, projectID, logging.FieldError, err) + } + if err := s.storeCredential(ctx, projectID, domain.CredentialCategoryNotify, domain.CredKeyNotifyFrom, notifyCreds.From); err != nil { + storeErr = err + log.Error("failed to store NOTIFY_FROM", logging.FieldProjectID, projectID, logging.FieldError, err) + } + + if storeErr != nil { + log.Warn("rolling back notify due to credential storage failure", logging.FieldProjectID, projectID) + if rollbackErr := s.notifyProvisioner.DeleteProjectNotify(ctx, projectID); rollbackErr != nil { + log.Error("failed to rollback notify account", logging.FieldProjectID, projectID, logging.FieldError, rollbackErr) + result.NextSteps = append(result.NextSteps, "Notify created but credentials not stored - manual cleanup required") + } else { + result.NextSteps = append(result.NextSteps, "Notify provisioning rolled back due to credential storage failure") + } + } else { + log.Info("notify provisioned", logging.FieldProjectID, projectID, "host", notifyCreds.Host) + } + } + } + } + // Provision storage (idempotent) if s.storageProvisioner != nil { existing, _ := s.storageProvisioner.GetProjectBucket(ctx, projectID) @@ -844,6 +884,13 @@ func (s *ProjectInfraService) DeleteProject(ctx context.Context, projectID strin } } + // 5. Delete provisioned notify account + if s.notifyProvisioner != nil { + if err := s.notifyProvisioner.DeleteProjectNotify(ctx, projectID); err != nil { + log.Warn("failed to delete project notify account", logging.FieldError, err) + } + } + // 5b. Delete Citadel log environment s.deleteCitadelEnvironment(ctx, projectID) diff --git a/scripts/load-credentials.sh b/scripts/load-credentials.sh index 07166e3..9eb96f6 100755 --- a/scripts/load-credentials.sh +++ b/scripts/load-credentials.sh @@ -70,6 +70,7 @@ get_category() { WOODPECKER_URL|WOODPECKER_API_TOKEN|WOODPECKER_WEBHOOK_SECRET) echo "woodpecker" ;; REGISTRY_URL) echo "registry" ;; LAOZHANG_API_KEY|GEMINI_API_KEY) echo "ai" ;; + NOTIFY_URL|NOTIFY_ADMIN_KEY) echo "notify" ;; *) echo "other" ;; esac } @@ -87,6 +88,8 @@ get_description() { REGISTRY_URL) echo "Container registry URL" ;; LAOZHANG_API_KEY) echo "LaoZhang API key for text/image generation (also proxies Grok)" ;; GEMINI_API_KEY) echo "Google Gemini API key for text/image generation" ;; + NOTIFY_URL) echo "Notify service base URL for email delivery" ;; + NOTIFY_ADMIN_KEY) echo "Notify admin API key for provisioning per-project accounts" ;; *) echo "$1 credential" ;; esac } diff --git a/scripts/setup-notify.sh b/scripts/setup-notify.sh new file mode 100755 index 0000000..90e1af4 --- /dev/null +++ b/scripts/setup-notify.sh @@ -0,0 +1,169 @@ +#!/usr/bin/env bash +# setup-notify.sh - One-time host and provider setup for the notify service. +# +# Creates the threesix.ai host, adds Resend as provider, registers noreply@threesix.ai, +# and adds Resend DNS records to Cloudflare for domain verification. +# +# Idempotent: safe to run multiple times. +# +# Usage: +# ./scripts/setup-notify.sh +# NOTIFY_URL=... NOTIFY_ADMIN_KEY=... RESEND_API_KEY=... ./scripts/setup-notify.sh + +set -euo pipefail + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +log_info() { echo -e "${GREEN}[INFO]${NC} $1"; } +log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } +log_error() { echo -e "${RED}[ERROR]${NC} $1"; } +log_step() { echo -e "${BLUE}[STEP]${NC} $1"; } + +# ─── Load secrets ──────────────────────────────────────────────────────────── + +SECRETS_FILE="${SECRETS_FILE:-.secrets}" +if [[ -f "$SECRETS_FILE" ]]; then + while IFS='=' read -r key val || [[ -n "$key" ]]; do + [[ -z "$key" || "$key" == \#* ]] && continue + export "$key"="${val}" + done < "$SECRETS_FILE" +fi + +NOTIFY_URL="${NOTIFY_URL:-}" +NOTIFY_ADMIN_KEY="${NOTIFY_ADMIN_KEY:-}" +RESEND_API_KEY="${RESEND_API_KEY:-}" +CF_TOKEN="${CLOUDFLARE_API_TOKEN:-}" +CF_ZONE="${CLOUDFLARE_ZONE_ID:-}" + +if [[ -z "$NOTIFY_URL" ]]; then log_error "NOTIFY_URL required"; exit 1; fi +if [[ -z "$NOTIFY_ADMIN_KEY" ]]; then log_error "NOTIFY_ADMIN_KEY required"; exit 1; fi +if [[ -z "$RESEND_API_KEY" ]]; then log_error "RESEND_API_KEY required"; exit 1; fi + +HOST=threesix.ai +FROM=noreply@threesix.ai + +log_info "Notify URL: $NOTIFY_URL" +log_info "Host: $HOST" +log_info "From: $FROM" + +# ─── Helpers ───────────────────────────────────────────────────────────────── + +notify() { + local method="$1" path="$2" body="${3:-}" + local args=(-s -X "$method" "$NOTIFY_URL$path" + -H "Authorization: Bearer $NOTIFY_ADMIN_KEY" + -H "Content-Type: application/json") + [[ -n "$body" ]] && args+=(-d "$body") + curl "${args[@]}" +} + +resend_api() { + local method="$1" path="$2" body="${3:-}" + local args=(-s -X "$method" "https://api.resend.com$path" + -H "Authorization: Bearer $RESEND_API_KEY" + -H "Content-Type: application/json") + [[ -n "$body" ]] && args+=(-d "$body") + curl "${args[@]}" +} + +cf_dns() { + local method="$1" path="$2" body="${3:-}" + local args=(-s -X "$method" "https://api.cloudflare.com/client/v4/zones/$CF_ZONE$path" + -H "Authorization: Bearer $CF_TOKEN" + -H "Content-Type: application/json") + [[ -n "$body" ]] && args+=(-d "$body") + curl "${args[@]}" +} + +# ─── Step 1: Create host ────────────────────────────────────────────────────── + +log_step "1. Setting up notify host: $HOST" +existing=$(notify GET "/admin/hosts" | python3 -c "import sys,json; items=json.load(sys.stdin).get('items',[]); print(next((x['host'] for x in items if x['host']=='$HOST'),''))" 2>/dev/null || true) +if [[ "$existing" == "$HOST" ]]; then + log_info " Host already exists — skipping" +else + notify POST "/admin/hosts" "{\"host\":\"$HOST\",\"strategy\":\"failover\"}" | python3 -m json.tool + log_info " Host created" +fi + +# ─── Step 2: Add Resend provider ───────────────────────────────────────────── + +log_step "2. Adding Resend provider" +providers=$(notify GET "/admin/hosts/$HOST/providers" | python3 -c "import sys,json; items=json.load(sys.stdin); print(next((str(x['id']) for x in items if x['provider']=='resend'),''))" 2>/dev/null || true) +if [[ -n "$providers" ]]; then + log_info " Resend provider already configured (id: $providers) — skipping" +else + notify POST "/admin/hosts/$HOST/providers" \ + "{\"provider\":\"resend\",\"config\":{\"api_key\":\"$RESEND_API_KEY\"},\"priority\":1,\"retry_attempts\":3,\"retry_backoff_ms\":1000}" | python3 -m json.tool + log_info " Resend provider added" +fi + +# ─── Step 3: Register from-address ─────────────────────────────────────────── + +log_step "3. Registering from-address: $FROM" +addrs=$(notify GET "/admin/hosts/$HOST/from-addresses" | python3 -c "import sys,json; items=json.load(sys.stdin).get('items',[]); print(next((x['email'] for x in items if x['email']=='$FROM'),''))" 2>/dev/null || true) +if [[ "$addrs" == "$FROM" ]]; then + log_info " From-address already registered — skipping" +else + notify POST "/admin/hosts/$HOST/from-addresses" \ + "{\"email\":\"$FROM\",\"display_name\":\"threesix.ai\"}" | python3 -m json.tool + log_info " From-address registered" +fi + +# ─── Step 4: Resend domain + Cloudflare DNS ─────────────────────────────────── + +log_step "4. Setting up Resend domain for $HOST" +existing_domain=$(resend_api GET "/domains" | python3 -c "import sys,json; data=json.load(sys.stdin); print(next((x['id'] for x in data.get('data',[]) if x['name']=='$HOST'),''))" 2>/dev/null || true) + +if [[ -n "$existing_domain" ]]; then + log_info " Resend domain already exists (id: $existing_domain)" + DOMAIN_ID="$existing_domain" + DOMAIN_RECORDS=$(resend_api GET "/domains/$DOMAIN_ID" | python3 -c "import sys,json; print(json.dumps(json.load(sys.stdin).get('records',[])))") +else + log_info " Creating Resend domain..." + domain_resp=$(resend_api POST "/domains" "{\"name\":\"$HOST\",\"region\":\"us-east-1\"}") + DOMAIN_ID=$(echo "$domain_resp" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])") + DOMAIN_RECORDS=$(echo "$domain_resp" | python3 -c "import sys,json; print(json.dumps(json.load(sys.stdin).get('records',[])))") + log_info " Domain created (id: $DOMAIN_ID)" +fi + +# Add DNS records if Cloudflare is configured +if [[ -n "$CF_TOKEN" && -n "$CF_ZONE" ]]; then + log_step "5. Adding Resend DNS records to Cloudflare" + echo "$DOMAIN_RECORDS" | python3 -c " +import sys, json +records = json.load(sys.stdin) +for r in records: + print(r['type'], r['name'], r.get('value',''), r.get('priority','')) +" | while read -r rtype rname rvalue rpriority; do + # Check if record already exists + existing_rec=$(cf_dns GET "/dns_records?type=$rtype&name=$rname.$HOST" | python3 -c "import sys,json; result=json.load(sys.stdin).get('result',[]); print(result[0]['id'] if result else '')" 2>/dev/null || true) + if [[ -n "$existing_rec" ]]; then + log_info " $rtype $rname already exists — skipping" + else + if [[ "$rtype" == "MX" ]]; then + cf_dns POST "/dns_records" "{\"type\":\"MX\",\"name\":\"$rname\",\"content\":\"$rvalue\",\"priority\":$rpriority,\"ttl\":1}" > /dev/null + else + cf_dns POST "/dns_records" "{\"type\":\"$rtype\",\"name\":\"$rname\",\"content\":\"$rvalue\",\"ttl\":1,\"proxied\":false}" > /dev/null + fi + log_info " Added $rtype $rname" + fi + done + + # Trigger verification + log_step "6. Triggering Resend domain verification" + resend_api POST "/domains/$DOMAIN_ID/verify" > /dev/null + log_info " Verification triggered (DNS propagation takes ~60s)" +else + log_warn " CLOUDFLARE_API_TOKEN or CLOUDFLARE_ZONE_ID not set — add DNS records manually:" + echo "$DOMAIN_RECORDS" | python3 -m json.tool +fi + +echo "" +log_info "Setup complete." +log_info "Check Resend domain status: curl -s https://api.resend.com/domains/$DOMAIN_ID -H 'Authorization: Bearer \$RESEND_API_KEY' | python3 -m json.tool"