rdev/internal/adapter/notify/provisioner.go
jordan 0f25bd8dbe
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
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 <noreply@anthropic.com>
2026-02-21 00:30:32 -07:00

156 lines
4.6 KiB
Go

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
}