feat: hook in notify service for per-project email delivery
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

- 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 <noreply@anthropic.com>
This commit is contained in:
jordan 2026-02-21 00:30:32 -07:00
parent bc77504b35
commit 0f25bd8dbe
12 changed files with 638 additions and 0 deletions

View File

@ -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) | | **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) | | **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) | | **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 | | **Structured logging** | `internal/logging/` - field constants, context propagation, redaction |
## Critical Rules ## Critical Rules

View File

@ -87,6 +87,12 @@ type InfraConfig struct {
GCSProjectID string // e.g., "threesix-prod" GCSProjectID string // e.g., "threesix-prod"
GCSCredentialsPath string // Path to service account JSON (empty = ADC) GCSCredentialsPath string // Path to service account JSON (empty = ADC)
GCSLocation string // Bucket location (default: "US") 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 { func loadConfig() Config {
@ -147,6 +153,8 @@ func loadInfraConfig(ctx context.Context, store port.CredentialStore, cfg Config
domain.CredKeyWoodpeckerAPIToken, domain.CredKeyWoodpeckerAPIToken,
domain.CredKeyWoodpeckerWebhookSecret, domain.CredKeyWoodpeckerWebhookSecret,
domain.CredKeyRegistryURL, domain.CredKeyRegistryURL,
domain.CredKeyNotifyURL,
domain.CredKeyNotifyAdminKey,
}) })
if err != nil { if err != nil {
logger.Warn("failed to load credentials from store, using env vars", "error", err) 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"), GCSProjectID: os.Getenv("GCS_PROJECT_ID"),
GCSCredentialsPath: os.Getenv("GCS_CREDENTIALS_PATH"), GCSCredentialsPath: os.Getenv("GCS_CREDENTIALS_PATH"),
GCSLocation: envutil.GetEnv("GCS_LOCATION", "US"), 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 // Log which credentials were loaded from store vs env

View File

@ -18,6 +18,7 @@ import (
"github.com/orchard9/rdev/internal/adapter/gitea" "github.com/orchard9/rdev/internal/adapter/gitea"
"github.com/orchard9/rdev/internal/adapter/kubernetes" "github.com/orchard9/rdev/internal/adapter/kubernetes"
"github.com/orchard9/rdev/internal/adapter/memory" "github.com/orchard9/rdev/internal/adapter/memory"
notifyadapter "github.com/orchard9/rdev/internal/adapter/notify"
"github.com/orchard9/rdev/internal/adapter/postgres" "github.com/orchard9/rdev/internal/adapter/postgres"
redisadapter "github.com/orchard9/rdev/internal/adapter/redis" redisadapter "github.com/orchard9/rdev/internal/adapter/redis"
sdlcadapter "github.com/orchard9/rdev/internal/adapter/sdlc" sdlcadapter "github.com/orchard9/rdev/internal/adapter/sdlc"
@ -240,6 +241,23 @@ func main() {
} }
defer closeProvisioner(storageProvisioner, "gcs", logger) 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) // Initialize registry client (for monitoring and image cleanup on project teardown)
var registryClient *zot.Client var registryClient *zot.Client
if infraCfg.RegistryURL != "" { if infraCfg.RegistryURL != "" {
@ -482,6 +500,9 @@ func main() {
if citadelClient != nil { if citadelClient != nil {
projectInfraService = projectInfraService.WithCitadelClient(citadelClient) projectInfraService = projectInfraService.WithCitadelClient(citadelClient)
} }
if notifyProvisioner != nil {
projectInfraService = projectInfraService.WithNotifyProvisioner(notifyProvisioner)
}
// Create domain service adapter for infrastructure handler // Create domain service adapter for infrastructure handler
domainServiceAdapter := handlers.NewDomainServiceAdapter(projectInfraService) domainServiceAdapter := handlers.NewDomainServiceAdapter(projectInfraService)

View File

@ -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))
}

View File

@ -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
}

View File

@ -38,6 +38,7 @@ const (
CredentialCategoryWorker = "worker" CredentialCategoryWorker = "worker"
CredentialCategoryStorage = "storage" CredentialCategoryStorage = "storage"
CredentialCategoryAI = "ai" CredentialCategoryAI = "ai"
CredentialCategoryNotify = "notify"
) )
// Known credential keys. // Known credential keys.
@ -65,4 +66,11 @@ const (
// AI Providers // AI Providers
CredKeyLaozhangAPIKey = "LAOZHANG_API_KEY" CredKeyLaozhangAPIKey = "LAOZHANG_API_KEY"
CredKeyGeminiAPIKey = "GEMINI_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"
) )

25
internal/domain/notify.go Normal file
View File

@ -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
}

View File

@ -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
}

View File

@ -35,6 +35,7 @@ type ProjectInfraService struct {
dbProvisioner port.DatabaseProvisioner dbProvisioner port.DatabaseProvisioner
cacheProvisioner port.CacheProvisioner cacheProvisioner port.CacheProvisioner
storageProvisioner port.StorageProvisioner storageProvisioner port.StorageProvisioner
notifyProvisioner port.NotifyProvisioner
registryProvider port.RegistryProvider registryProvider port.RegistryProvider
citadelClient port.CitadelClient citadelClient port.CitadelClient
@ -109,6 +110,12 @@ func (s *ProjectInfraService) WithStorageProvisioner(sp port.StorageProvisioner)
return s 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. // WithRegistryProvider sets the container registry provider for image cleanup.
func (s *ProjectInfraService) WithRegistryProvider(rp port.RegistryProvider) *ProjectInfraService { func (s *ProjectInfraService) WithRegistryProvider(rp port.RegistryProvider) *ProjectInfraService {
s.registryProvider = rp s.registryProvider = rp

View File

@ -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) // Provision storage (idempotent)
if s.storageProvisioner != nil { if s.storageProvisioner != nil {
existing, _ := s.storageProvisioner.GetProjectBucket(ctx, projectID) 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 // 5b. Delete Citadel log environment
s.deleteCitadelEnvironment(ctx, projectID) s.deleteCitadelEnvironment(ctx, projectID)

View File

@ -70,6 +70,7 @@ get_category() {
WOODPECKER_URL|WOODPECKER_API_TOKEN|WOODPECKER_WEBHOOK_SECRET) echo "woodpecker" ;; WOODPECKER_URL|WOODPECKER_API_TOKEN|WOODPECKER_WEBHOOK_SECRET) echo "woodpecker" ;;
REGISTRY_URL) echo "registry" ;; REGISTRY_URL) echo "registry" ;;
LAOZHANG_API_KEY|GEMINI_API_KEY) echo "ai" ;; LAOZHANG_API_KEY|GEMINI_API_KEY) echo "ai" ;;
NOTIFY_URL|NOTIFY_ADMIN_KEY) echo "notify" ;;
*) echo "other" ;; *) echo "other" ;;
esac esac
} }
@ -87,6 +88,8 @@ get_description() {
REGISTRY_URL) echo "Container registry URL" ;; REGISTRY_URL) echo "Container registry URL" ;;
LAOZHANG_API_KEY) echo "LaoZhang API key for text/image generation (also proxies Grok)" ;; 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" ;; 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" ;; *) echo "$1 credential" ;;
esac esac
} }

169
scripts/setup-notify.sh Executable file
View File

@ -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"