feat: implement shared notify host model for platform email delivery
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed

Replace per-project notify host provisioning (7-9 API calls + DNS + async
Resend verification) with a shared platform host for all *.threesix.ai projects.

Under the new model:
- CreateProjectNotify: 3 calls only (account + send key + host grant)
- No per-project Resend domain, DNS records, or async verification
- All *.threesix.ai projects share `threesix.ai` as the platform host
- Custom domains still get a dedicated host via ReprovisionNotifyHost

Changes:
- domain/notify.go: slim NotifyCredentials (no Host/From/ResendDomainID);
  add NotifyHostCredentials for reprovision return path
- port/notify_provisioner.go: update interface signatures and docs
- adapter/notify/provisioner.go: rewrite CreateProjectNotify (3 steps);
  rewrite DeleteProjectNotify (account-only vs full cleanup)
- adapter/notify/provisioner_reprovision.go: return *NotifyHostCredentials
- adapter/notify/provisioner_test.go: update tests for new model
- service/project_infra_crud.go: store only NOTIFY_API_KEY on provision
- domain/credential.go: add CredKeyNotifySharedHost/CredKeyNotifySharedFrom
- cmd/rdev-api/config.go: add NotifySharedHost/NotifySharedFrom to InfraConfig
- service/component.go: add notifySharedHost/notifySharedFrom + WithNotifyDefaults
- service/component_deploy.go: inject shared host defaults when no custom host stored
- handlers/notify.go: handle shared-host projects in Reprovision guard;
  add WithSharedNotifyHost builder
- cmd/rdev-api/main.go: wire SharedHost to provisioner, component service,
  and notify handler

Bootstrap: NOTIFY_SHARED_HOST=threesix.ai and NOTIFY_SHARED_FROM=noreply@threesix.ai
stored in credential store (host id=1 already provisioned with Resend provider).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jordan 2026-02-25 17:04:11 -07:00
parent 17240f4efd
commit ddcfe52b5c
12 changed files with 282 additions and 356 deletions

View File

@ -92,6 +92,8 @@ type InfraConfig struct {
NotifyURL string // e.g., "https://notify.orchard9.ai"
NotifyAdminKey string // notify_admin_... admin API key
ResendAPIKey string // re_... Resend API key for per-project domain provisioning
NotifySharedHost string // pre-provisioned platform sending host (e.g., "mail.threesix.ai")
NotifySharedFrom string // from-address for the shared host (e.g., "noreply@mail.threesix.ai")
}
func loadConfig() Config {
@ -155,6 +157,8 @@ func loadInfraConfig(ctx context.Context, store port.CredentialStore, cfg Config
domain.CredKeyNotifyURL,
domain.CredKeyNotifyAdminKey,
domain.CredKeyResendAPIKey,
domain.CredKeyNotifySharedHost,
domain.CredKeyNotifySharedFrom,
})
if err != nil {
logger.Warn("failed to load credentials from store, using env vars", "error", err)
@ -202,6 +206,8 @@ func loadInfraConfig(ctx context.Context, store port.CredentialStore, cfg Config
NotifyURL: getOrFallback(domain.CredKeyNotifyURL, os.Getenv("NOTIFY_URL")),
NotifyAdminKey: getOrFallback(domain.CredKeyNotifyAdminKey, os.Getenv("NOTIFY_ADMIN_KEY")),
ResendAPIKey: getOrFallback(domain.CredKeyResendAPIKey, os.Getenv("RESEND_API_KEY")),
NotifySharedHost: getOrFallback(domain.CredKeyNotifySharedHost, os.Getenv("NOTIFY_SHARED_HOST")),
NotifySharedFrom: getOrFallback(domain.CredKeyNotifySharedFrom, os.Getenv("NOTIFY_SHARED_FROM")),
}
// Log which credentials were loaded from store vs env

View File

@ -266,12 +266,16 @@ func main() {
AdminKey: infraCfg.NotifyAdminKey,
ResendAPIKey: infraCfg.ResendAPIKey,
BaseDomain: infraCfg.DefaultDomain,
SharedHost: infraCfg.NotifySharedHost,
}, dnsClient, 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)
logger.Info("notify provisioner initialized",
"url", infraCfg.NotifyURL,
"shared_host", infraCfg.NotifySharedHost,
)
}
}
@ -562,7 +566,8 @@ func main() {
WithDatabaseProvisioner(dbProvisioner).
WithCacheProvisioner(cacheProvisioner).
WithStorageProvisioner(storageProvisioner).
WithCredentialStore(credentialStore)
WithCredentialStore(credentialStore).
WithNotifyDefaults(infraCfg.NotifySharedHost, infraCfg.NotifySharedFrom)
componentsHandler = handlers.NewComponentsHandler(componentService).
SetOperationService(operationService)
logger.Info("component service initialized",
@ -637,7 +642,8 @@ func main() {
verifyHandler := handlers.NewVerifyHandler(verifyService, streamPub)
// Initialize notify handler (domain status and re-verification)
notifyHandler := handlers.NewNotifyHandler(notifyProvisioner, credentialStore, logger)
notifyHandler := handlers.NewNotifyHandler(notifyProvisioner, credentialStore, logger).
WithSharedNotifyHost(infraCfg.NotifySharedHost)
// Initialize cache handler (Redis reprovision after ACL reset)
cacheHandler := handlers.NewCacheHandler(cacheProvisioner, credentialStore, deployerAdapter, logger)

View File

@ -11,14 +11,16 @@ import (
)
// Provisioner implements port.NotifyProvisioner using the notify admin API.
// Each project gets an isolated sending host (mail.{slug}.{baseDomain}),
// a Resend domain with DKIM/SPF DNS records, and a dedicated send key.
// Under the shared-host model, default projects share a pre-provisioned platform
// sending host; only an account, send key, and host grant are created per-project.
// Custom domains still receive a dedicated host via ReprovisionNotifyHost.
type Provisioner struct {
client notifyAdminAPI
resend resendAPI // nil when ResendAPIKey not configured
resendAPIKey string // passed to createProvider; kept separate from resend for interface compatibility
dns port.DNSProvider // nil when Cloudflare not configured
baseDomain string // e.g., "threesix.ai"
sharedHost string // pre-provisioned platform sending host (e.g., "mail.threesix.ai")
logger *slog.Logger
}
@ -28,6 +30,7 @@ type Config struct {
AdminKey string // Required: admin API key (notify_admin_...)
ResendAPIKey string // Optional: Resend API key for per-project domain provisioning
BaseDomain string // Base domain for per-project hosts (default: "threesix.ai")
SharedHost string // Pre-provisioned platform sending host (e.g., "mail.threesix.ai"). Required.
}
// NewProvisioner creates a new notify provisioner.
@ -40,6 +43,7 @@ func NewProvisioner(cfg Config, dns port.DNSProvider, logger *slog.Logger) *Prov
client: newAdminClient(cfg.BaseURL, cfg.AdminKey),
dns: dns,
baseDomain: baseDomain,
sharedHost: cfg.SharedHost,
logger: logger,
}
if cfg.ResendAPIKey != "" {
@ -49,130 +53,51 @@ func NewProvisioner(cfg Config, dns port.DNSProvider, logger *slog.Logger) *Prov
return p
}
// CreateProjectNotify provisions a per-project notify host, Resend domain, DNS records,
// and notify account with send key.
// CreateProjectNotify provisions a notify account with send key and grants access
// to the shared platform sending host.
//
// Steps:
// 1. Create notify host mail.{slug}.{baseDomain}
// 2. Add Resend provider to the host (skipped if ResendAPIKey not configured)
// 3. Register from-address noreply@mail.{slug}.{baseDomain}
// 4. Create notify account "project-{projectID}"
// 5. Create send key for the account
// 6. Grant the account access to the host (non-fatal)
// 7. Create Resend domain (non-fatal — skipped if ResendAPIKey not configured)
// 8. Add DNS records via Cloudflare (non-fatal — skipped if DNS not configured)
// 9. Fire-and-forget async domain verification
// 1. Create notify account "project-{projectID}"
// 2. Create send key for the account
// 3. Grant the account access to p.sharedHost (non-fatal)
func (p *Provisioner) CreateProjectNotify(ctx context.Context, projectID, slug string) (*domain.NotifyCredentials, error) {
host := "mail." + slug + "." + p.baseDomain
from := "noreply@" + host
if p.sharedHost == "" {
return nil, fmt.Errorf("notify: shared host not configured")
}
accountName := "project-" + projectID
// 1. Create notify host
if err := p.client.createHost(ctx, host, "failover"); err != nil {
return nil, fmt.Errorf("notify: create host %s for project %s: %w", host, projectID, err)
}
// 2. Add Resend provider to the host (only when Resend is configured)
if p.resend != nil {
if err := p.client.createProvider(ctx, host, "resend", map[string]string{"api_key": p.resendAPIKey}, 1, 3, 1000); err != nil {
p.bestEffortDeleteHost(ctx, host, projectID)
return nil, fmt.Errorf("notify: create provider on host %s for project %s: %w", host, projectID, err)
}
}
// 3. Register from-address
if err := p.client.createFromAddress(ctx, host, from, slug); err != nil {
p.bestEffortDeleteHost(ctx, host, projectID)
return nil, fmt.Errorf("notify: create from-address %s for project %s: %w", from, projectID, err)
}
// 4. Create account
// 1. Create account
acct, err := p.client.createAccount(ctx, accountName)
if err != nil {
p.bestEffortDeleteHost(ctx, host, projectID)
return nil, fmt.Errorf("notify: create account for project %s: %w", projectID, err)
}
// 5. Create send key
// 2. Create send key
key, err := p.client.createSendKey(ctx, acct.ID, accountName+"-send")
if err != nil {
p.bestEffortDeleteAccount(ctx, acct.ID, projectID)
p.bestEffortDeleteHost(ctx, host, projectID)
return nil, fmt.Errorf("notify: create send key for project %s: %w", projectID, err)
}
// 6. Grant host access (non-fatal — log warn and continue)
if err := p.client.grantHostAccess(ctx, host, acct.ID); err != nil {
// 3. Grant shared host access (non-fatal — log warn and continue)
if err := p.client.grantHostAccess(ctx, p.sharedHost, acct.ID); err != nil {
p.logger.Warn("failed to grant notify host access",
"host", host,
"host", p.sharedHost,
"account_id", acct.ID,
"project_id", projectID,
"error", err,
)
}
// 7. Create Resend domain (non-fatal — project still usable, email won't send until fixed)
var resendDomainID string
var dnsRecords []resendDNSRecord
if p.resend != nil {
var resendErr error
resendDomainID, dnsRecords, resendErr = p.resend.createDomain(ctx, host, "us-east-1")
if resendErr != nil {
p.logger.Warn("failed to create resend domain — email delivery will not work until resolved",
"host", host,
"project_id", projectID,
"error", resendErr,
)
} else {
p.logger.Info("resend domain created", "host", host, "domain_id", resendDomainID)
}
}
// 8. Add DNS records for DKIM/SPF (non-fatal).
// rec.Name is relative to the zone apex (e.g., "resend._domainkey.mail.slug").
// Cloudflare's normalizeName appends ".baseDomain" to build the FQDN.
if p.dns != nil && len(dnsRecords) > 0 {
for _, rec := range dnsRecords {
dnsRec := domain.DNSRecord{
Type: rec.DNSType,
Name: rec.Name,
Content: rec.Value,
TTL: 1,
Priority: rec.Priority,
}
if _, upsertErr := p.dns.UpsertRecord(ctx, dnsRec); upsertErr != nil {
p.logger.Warn("failed to upsert notify DNS record",
"name", rec.Name,
"type", rec.DNSType,
"project_id", projectID,
"error", upsertErr,
)
}
}
}
// 9. Fire-and-forget async domain verification.
// Waits 60 seconds for DNS propagation, then retries verification up to 5 times with 30s backoff.
if p.resend != nil && resendDomainID != "" {
go func() {
verifyCtx := context.WithoutCancel(ctx)
p.verifyWithRetry(verifyCtx, resendDomainID, host, projectID)
}()
}
p.logger.Info("notify provisioned",
"project_id", projectID,
"host", host,
"resend_domain_id", resendDomainID,
"shared_host", p.sharedHost,
)
return &domain.NotifyCredentials{
ProjectID: projectID,
AccountID: acct.ID,
APIKey: key.Key,
Host: host,
From: from,
ResendDomainID: resendDomainID,
CreatedAt: time.Now(),
}, nil
}
@ -228,11 +153,11 @@ func (p *Provisioner) verifyWithRetry(ctx context.Context, resendDomainID, host,
)
}
// DeleteProjectNotify removes all notify resources for a project.
// Failures are logged as warnings — cleanup continues regardless.
func (p *Provisioner) DeleteProjectNotify(ctx context.Context, projectID, slug, resendDomainID string) error {
host := "mail." + slug + "." + p.baseDomain
// DeleteProjectNotify removes notify resources for a project.
// The notify account (and all cascaded keys and host grants) is always deleted.
// If perProjectHost is non-empty, the custom sending host, Resend domain, and DNS
// records are also deleted. Failures are logged as warnings — cleanup continues.
func (p *Provisioner) DeleteProjectNotify(ctx context.Context, projectID, perProjectHost, resendDomainID string) error {
// 1. Delete notify account (cascades keys + host grants)
acct, err := p.findAccountByProject(ctx, projectID)
if err != nil {
@ -250,16 +175,16 @@ func (p *Provisioner) DeleteProjectNotify(ctx context.Context, projectID, slug,
}
}
// 2. Delete notify host
if err := p.client.deleteHost(ctx, host); err != nil {
// 2. If a per-project custom host was provisioned, clean it up.
if perProjectHost != "" {
if err := p.client.deleteHost(ctx, perProjectHost); err != nil {
p.logger.Warn("failed to delete notify host",
"host", host,
"host", perProjectHost,
"project_id", projectID,
"error", err,
)
}
// 3. Delete Resend domain
if p.resend != nil && resendDomainID != "" {
if err := p.resend.deleteDomain(ctx, resendDomainID); err != nil {
p.logger.Warn("failed to delete resend domain",
@ -270,14 +195,14 @@ func (p *Provisioner) DeleteProjectNotify(ctx context.Context, projectID, slug,
}
}
// 4. Delete Cloudflare DNS records for DKIM/SPF.
// Delete Cloudflare DNS records for DKIM/SPF.
// Names follow Resend's standard format:
// DKIM: resend._domainkey.{host}
// SPF MX: send.{host}
// SPF TXT: send.{host}
// If Resend changes their record naming, manual cleanup may be needed.
if p.dns != nil {
dkimName := "resend._domainkey." + host
dkimName := "resend._domainkey." + perProjectHost
if err := p.dns.DeleteRecordByName(ctx, "TXT", dkimName); err != nil {
p.logger.Warn("failed to delete DKIM DNS record",
"name", dkimName,
@ -285,7 +210,7 @@ func (p *Provisioner) DeleteProjectNotify(ctx context.Context, projectID, slug,
"error", err,
)
}
spfSendName := "send." + host
spfSendName := "send." + perProjectHost
if err := p.dns.DeleteRecordByName(ctx, "MX", spfSendName); err != nil {
p.logger.Warn("failed to delete SPF MX DNS record",
"name", spfSendName,
@ -301,8 +226,9 @@ func (p *Provisioner) DeleteProjectNotify(ctx context.Context, projectID, slug,
)
}
}
}
p.logger.Info("notify resources deleted", "project_id", projectID, "host", host)
p.logger.Info("notify resources deleted", "project_id", projectID)
return nil
}
@ -392,9 +318,8 @@ func (p *Provisioner) GetNotifyDomainStatus(ctx context.Context, host, resendDom
}
// GetProjectNotify returns notify credentials for the project, or nil if not provisioned.
// Note: Only AccountID and CreatedAt are populated — APIKey, Host, and From are not
// recoverable after provisioning. Use this method solely to check whether provisioning
// has already occurred (non-nil return = already provisioned).
// Only AccountID and CreatedAt are recoverable after provisioning. Use this method
// solely to check whether provisioning has already occurred (non-nil = already provisioned).
func (p *Provisioner) GetProjectNotify(ctx context.Context, projectID string) (*domain.NotifyCredentials, error) {
acct, err := p.findAccountByProject(ctx, projectID)
if err != nil {

View File

@ -25,7 +25,7 @@ import (
// 10. Delete old DNS records for oldHost (non-fatal)
// 11. Delete old notify host (non-fatal)
// 12. Fire-and-forget async domain verification
func (p *Provisioner) ReprovisionNotifyHost(ctx context.Context, projectID, oldHost, oldResendDomainID, newHost string) (*domain.NotifyCredentials, error) {
func (p *Provisioner) ReprovisionNotifyHost(ctx context.Context, projectID, oldHost, oldResendDomainID, newHost string) (*domain.NotifyHostCredentials, error) {
newFrom := "noreply@" + newHost
// 1. Create new notify host.
@ -151,7 +151,7 @@ func (p *Provisioner) ReprovisionNotifyHost(ctx context.Context, projectID, oldH
"resend_domain_id", newResendDomainID,
)
return &domain.NotifyCredentials{
return &domain.NotifyHostCredentials{
ProjectID: projectID,
Host: newHost,
From: newFrom,

View File

@ -192,7 +192,8 @@ func testLogger() *slog.Logger {
}
// newProvisionerWithDeps creates a Provisioner with injected dependencies for testing.
func newProvisionerWithDeps(client notifyAdminAPI, resend resendAPI, resendAPIKey string, dns port.DNSProvider, baseDomain string, logger *slog.Logger) *Provisioner {
// sharedHost is used as-is; pass a non-empty value for tests that need a working provisioner.
func newProvisionerWithDeps(client notifyAdminAPI, resend resendAPI, resendAPIKey string, dns port.DNSProvider, baseDomain, sharedHost string, logger *slog.Logger) *Provisioner {
if baseDomain == "" {
baseDomain = "threesix.ai"
}
@ -202,6 +203,7 @@ func newProvisionerWithDeps(client notifyAdminAPI, resend resendAPI, resendAPIKe
resendAPIKey: resendAPIKey,
dns: dns,
baseDomain: baseDomain,
sharedHost: sharedHost,
logger: logger,
}
}
@ -224,101 +226,60 @@ func newTestProvisioner(admin *mockAdminClient, resend *mockResendClient, dns *m
if dns != nil {
d = dns
}
return newProvisionerWithDeps(admin, r, "re_test_key", d, "test.example", testLogger())
return newProvisionerWithDeps(admin, r, "re_test_key", d, "test.example", "mail.test.example", testLogger())
}
// --- tests ---
func TestCreateProjectNotify_Success(t *testing.T) {
admin := &mockAdminClient{}
resend := &mockResendClient{
dnsRecords: []resendDNSRecord{
{Record: "TXT", Name: "resend._domainkey", Value: "v=DKIM1; p=..."},
{Record: "MX", Name: "send", Value: "feedback-smtp.us-east-1.amazonses.com", Priority: 10},
},
}
dns := &mockDNS{}
p := newTestProvisioner(admin, resend, dns)
p := newTestProvisioner(admin, nil, nil)
creds, err := p.CreateProjectNotify(context.Background(), "proj-123", "happy-fox")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if creds.Host != "mail.happy-fox.test.example" {
t.Errorf("expected host mail.happy-fox.test.example, got %s", creds.Host)
}
if creds.From != "noreply@mail.happy-fox.test.example" {
t.Errorf("expected from noreply@mail.happy-fox.test.example, got %s", creds.From)
}
if creds.APIKey != "notify_send_test_key" {
t.Errorf("expected send key, got %s", creds.APIKey)
}
if creds.ResendDomainID != "resend-domain-id-123" {
t.Errorf("expected resend domain id, got %s", creds.ResendDomainID)
t.Errorf("expected send key notify_send_test_key, got %s", creds.APIKey)
}
if creds.ProjectID != "proj-123" {
t.Errorf("expected project id proj-123, got %s", creds.ProjectID)
}
if creds.AccountID == "" {
t.Error("expected non-empty AccountID")
}
// Verify all steps executed
if admin.createHostCalls != 1 {
t.Errorf("expected 1 createHost call, got %d", admin.createHostCalls)
}
if admin.createProviderCalls != 1 {
t.Errorf("expected 1 createProvider call, got %d", admin.createProviderCalls)
}
if admin.createFromAddressCalls != 1 {
t.Errorf("expected 1 createFromAddress call, got %d", admin.createFromAddressCalls)
}
// Shared-host model: only account + send key + grant access are called.
if admin.createAccountCalls != 1 {
t.Errorf("expected 1 createAccount call, got %d", admin.createAccountCalls)
}
if admin.createSendKeyCalls != 1 {
t.Errorf("expected 1 createSendKey call, got %d", admin.createSendKeyCalls)
}
if resend.createDomainCalls != 1 {
t.Errorf("expected 1 createDomain call, got %d", resend.createDomainCalls)
if admin.grantHostAccessCalls != 1 {
t.Errorf("expected 1 grantHostAccess call, got %d", admin.grantHostAccessCalls)
}
// Verify 2 DNS records were upserted
if len(dns.upsertCalls) != 2 {
t.Errorf("expected 2 DNS upserts, got %d", len(dns.upsertCalls))
// No per-project host, provider, from-address, Resend domain, or DNS records.
if admin.createHostCalls != 0 {
t.Errorf("expected 0 createHost calls, got %d", admin.createHostCalls)
}
if admin.createProviderCalls != 0 {
t.Errorf("expected 0 createProvider calls, got %d", admin.createProviderCalls)
}
if admin.createFromAddressCalls != 0 {
t.Errorf("expected 0 createFromAddress calls, got %d", admin.createFromAddressCalls)
}
}
func TestCreateProjectNotify_RollsBackOnProviderFailure(t *testing.T) {
admin := &mockAdminClient{
createProviderErr: errors.New("provider setup failed"),
}
p := newTestProvisioner(admin, &mockResendClient{}, nil)
func TestCreateProjectNotify_NoSharedHost_ReturnsError(t *testing.T) {
admin := &mockAdminClient{}
p := newProvisionerWithDeps(admin, nil, "", nil, "test.example", "", testLogger())
_, err := p.CreateProjectNotify(context.Background(), "proj-123", "happy-fox")
if err == nil {
t.Fatal("expected error, got nil")
}
if admin.deleteHostCalls != 1 {
t.Errorf("expected host rollback, got %d deleteHost calls", admin.deleteHostCalls)
}
}
func TestCreateProjectNotify_RollsBackOnFromAddressFailure(t *testing.T) {
admin := &mockAdminClient{
createFromAddressErr: errors.New("from address failed"),
}
p := newTestProvisioner(admin, &mockResendClient{}, nil)
_, err := p.CreateProjectNotify(context.Background(), "proj-123", "happy-fox")
if err == nil {
t.Fatal("expected error, got nil")
}
if admin.deleteHostCalls != 1 {
t.Errorf("expected host rollback, got %d deleteHost calls", admin.deleteHostCalls)
}
if admin.deleteAccountCalls != 0 {
t.Errorf("account not yet created, should not delete account")
t.Fatal("expected error when sharedHost is not configured, got nil")
}
}
@ -326,15 +287,23 @@ func TestCreateProjectNotify_RollsBackOnAccountFailure(t *testing.T) {
admin := &mockAdminClient{
createAccountErr: errors.New("account creation failed"),
}
p := newTestProvisioner(admin, &mockResendClient{}, nil)
p := newTestProvisioner(admin, nil, nil)
_, err := p.CreateProjectNotify(context.Background(), "proj-123", "happy-fox")
if err == nil {
t.Fatal("expected error, got nil")
}
if admin.deleteHostCalls != 1 {
t.Errorf("expected host rollback, got %d deleteHost calls", admin.deleteHostCalls)
// No send key or grant attempted when account creation fails.
if admin.createSendKeyCalls != 0 {
t.Errorf("expected 0 createSendKey calls, got %d", admin.createSendKeyCalls)
}
if admin.grantHostAccessCalls != 0 {
t.Errorf("expected 0 grantHostAccess calls, got %d", admin.grantHostAccessCalls)
}
// No host was ever created in the shared-host model.
if admin.createHostCalls != 0 {
t.Errorf("expected 0 createHost calls, got %d", admin.createHostCalls)
}
}
@ -342,69 +311,20 @@ func TestCreateProjectNotify_RollsBackOnSendKeyFailure(t *testing.T) {
admin := &mockAdminClient{
createSendKeyErr: errors.New("send key creation failed"),
}
p := newTestProvisioner(admin, &mockResendClient{}, nil)
p := newTestProvisioner(admin, nil, nil)
_, err := p.CreateProjectNotify(context.Background(), "proj-123", "happy-fox")
if err == nil {
t.Fatal("expected error, got nil")
}
// Account was created, so it must be rolled back.
if admin.deleteAccountCalls != 1 {
t.Errorf("expected account rollback, got %d deleteAccount calls", admin.deleteAccountCalls)
t.Errorf("expected 1 deleteAccount call, got %d", admin.deleteAccountCalls)
}
if admin.deleteHostCalls != 1 {
t.Errorf("expected host rollback, got %d deleteHost calls", admin.deleteHostCalls)
}
}
func TestCreateProjectNotify_ResendFailureIsNonFatal(t *testing.T) {
admin := &mockAdminClient{}
resend := &mockResendClient{
createDomainErr: errors.New("resend API down"),
}
p := newTestProvisioner(admin, resend, nil)
creds, err := p.CreateProjectNotify(context.Background(), "proj-123", "happy-fox")
if err != nil {
t.Fatalf("resend failure should be non-fatal, got error: %v", err)
}
if creds.ResendDomainID != "" {
t.Errorf("expected empty resend domain id on failure, got %s", creds.ResendDomainID)
}
}
func TestCreateProjectNotify_WithoutResend_SkipsProviderAndDomain(t *testing.T) {
admin := &mockAdminClient{}
p := newProvisionerWithDeps(admin, nil, "", nil, "test.example", testLogger())
creds, err := p.CreateProjectNotify(context.Background(), "proj-123", "happy-fox")
if err != nil {
t.Fatalf("expected no error without resend, got %v", err)
}
if admin.createProviderCalls != 0 {
t.Errorf("createProvider should not be called without ResendAPIKey, got %d calls", admin.createProviderCalls)
}
if creds.ResendDomainID != "" {
t.Errorf("expected no resend domain id without ResendAPIKey, got %s", creds.ResendDomainID)
}
}
func TestCreateProjectNotify_DNSFailureIsNonFatal(t *testing.T) {
admin := &mockAdminClient{}
resend := &mockResendClient{
dnsRecords: []resendDNSRecord{{Record: "TXT", Name: "resend._domainkey", Value: "v=DKIM1"}},
}
dns := &mockDNS{upsertErr: errors.New("cloudflare down")}
p := newTestProvisioner(admin, resend, dns)
creds, err := p.CreateProjectNotify(context.Background(), "proj-123", "happy-fox")
if err != nil {
t.Fatalf("DNS failure should be non-fatal, got error: %v", err)
}
// Project still usable; DNS will need manual fix
if creds.Host != "mail.happy-fox.test.example" {
t.Errorf("creds should still be returned on DNS failure, got host %s", creds.Host)
// No host was created, so no host rollback.
if admin.deleteHostCalls != 0 {
t.Errorf("expected 0 deleteHost calls, got %d", admin.deleteHostCalls)
}
}
@ -418,7 +338,8 @@ func TestDeleteProjectNotify_Success(t *testing.T) {
dns := &mockDNS{}
p := newTestProvisioner(admin, resend, dns)
err := p.DeleteProjectNotify(context.Background(), "proj-123", "happy-fox", "resend-domain-id-123")
// perProjectHost is non-empty: delete account + host + Resend domain + DNS records.
err := p.DeleteProjectNotify(context.Background(), "proj-123", "mail.custom.example", "resend-domain-id-123")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
@ -447,26 +368,60 @@ func TestDeleteProjectNotify_NoResendDomainID_SkipsDomainDeletion(t *testing.T)
resend := &mockResendClient{}
p := newTestProvisioner(admin, resend, nil)
err := p.DeleteProjectNotify(context.Background(), "proj-123", "happy-fox", "")
// perProjectHost set but no resendDomainID: host deleted, Resend domain skipped.
err := p.DeleteProjectNotify(context.Background(), "proj-123", "mail.custom.example", "")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if resend.deleteDomainCalls != 0 {
t.Errorf("should skip domain deletion when resendDomainID is empty")
}
if admin.deleteHostCalls != 1 {
t.Errorf("expected 1 deleteHost call when perProjectHost is set, got %d", admin.deleteHostCalls)
}
}
func TestDeleteProjectNotify_NoPerProjectHost_OnlyDeletesAccount(t *testing.T) {
admin := &mockAdminClient{
accounts: []accountResponse{
{ID: "acct-001", Name: "project-proj-123"},
},
}
resend := &mockResendClient{}
dns := &mockDNS{}
p := newTestProvisioner(admin, resend, dns)
// Empty perProjectHost: only the account is deleted (shared host, no custom resources).
err := p.DeleteProjectNotify(context.Background(), "proj-123", "", "")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if admin.deleteAccountCalls != 1 {
t.Errorf("expected 1 deleteAccount call, got %d", admin.deleteAccountCalls)
}
if admin.deleteHostCalls != 0 {
t.Errorf("expected 0 deleteHost calls when no perProjectHost, got %d", admin.deleteHostCalls)
}
if resend.deleteDomainCalls != 0 {
t.Errorf("expected 0 deleteDomain calls when no perProjectHost, got %d", resend.deleteDomainCalls)
}
if len(dns.deleteByNameCalls) != 0 {
t.Errorf("expected 0 DNS deleteByName calls when no perProjectHost, got %d", len(dns.deleteByNameCalls))
}
}
func TestDeleteProjectNotify_AccountNotFound_ContinuesCleanup(t *testing.T) {
// Account doesn't exist (never provisioned or already deleted)
// Account doesn't exist (never provisioned or already deleted).
admin := &mockAdminClient{accounts: []accountResponse{}}
resend := &mockResendClient{}
p := newTestProvisioner(admin, resend, nil)
err := p.DeleteProjectNotify(context.Background(), "proj-123", "happy-fox", "resend-domain-id-123")
// Even without an account, host and Resend domain cleanup must proceed.
err := p.DeleteProjectNotify(context.Background(), "proj-123", "mail.custom.example", "resend-domain-id-123")
if err != nil {
t.Fatalf("expected no error when account not found, got %v", err)
}
// Should still attempt host and Resend domain deletion
if admin.deleteHostCalls != 1 {
t.Errorf("expected 1 deleteHost call, got %d", admin.deleteHostCalls)
}
@ -508,19 +463,6 @@ func TestGetProjectNotify_AlreadyProvisioned(t *testing.T) {
}
}
func TestCreateProjectNotify_HostUsesBaseDomain(t *testing.T) {
admin := &mockAdminClient{}
p := newProvisionerWithDeps(admin, nil, "", nil, "staging.example.com", testLogger())
creds, err := p.CreateProjectNotify(context.Background(), "proj-123", "some-slug")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if creds.Host != "mail.some-slug.staging.example.com" {
t.Errorf("expected host to use baseDomain, got %s", creds.Host)
}
}
func TestReprovisionNotifyHost_Success(t *testing.T) {
admin := &mockAdminClient{
accounts: []accountResponse{

View File

@ -72,9 +72,11 @@ const (
CredKeyNotifyURL = "NOTIFY_URL"
CredKeyNotifyAdminKey = "NOTIFY_ADMIN_KEY"
CredKeyNotifyAPIKey = "NOTIFY_API_KEY"
CredKeyNotifyHost = "NOTIFY_HOST"
CredKeyNotifyFrom = "NOTIFY_FROM"
CredKeyNotifyResendDomainID = "NOTIFY_RESEND_DOMAIN_ID"
CredKeyNotifyHost = "NOTIFY_HOST" // per-project: custom domain only
CredKeyNotifyFrom = "NOTIFY_FROM" // per-project: custom domain only
CredKeyNotifyResendDomainID = "NOTIFY_RESEND_DOMAIN_ID" // per-project: custom domain only
CredKeyNotifySharedHost = "NOTIFY_SHARED_HOST" // global: pre-provisioned platform sending host
CredKeyNotifySharedFrom = "NOTIFY_SHARED_FROM" // global: from-address for the shared host
// Resend (email provider for per-project domain provisioning)
CredKeyResendAPIKey = "RESEND_API_KEY"

View File

@ -4,6 +4,10 @@ package domain
import "time"
// NotifyCredentials holds per-project email delivery credentials.
// Under the shared-host model, all default projects send through a pre-provisioned
// platform host (e.g., "mail.threesix.ai"). Only the account, send key, and host
// grant are created per-project; no dedicated host, Resend domain, or DNS records
// are allocated unless a custom domain is configured via ReprovisionNotifyHost.
type NotifyCredentials struct {
// ProjectID is the rdev project this credential set belongs to.
ProjectID string
@ -14,22 +18,29 @@ type NotifyCredentials struct {
// APIKey is the notify send key (notify_send_...) for sending emails.
APIKey string
// Host is the per-project sending host (e.g., "mail.{slug}.threesix.ai").
Host string
// From is the from-address for outgoing email (e.g., "noreply@mail.{slug}.threesix.ai").
From string
// ResendDomainID is the Resend domain UUID (used for deletion).
ResendDomainID string
// CreatedAt is when the credentials were provisioned.
CreatedAt time.Time
}
// NotifyHostCredentials holds the per-project host credentials returned by ReprovisionNotifyHost.
// Used only for custom-domain migrations; default projects use the shared platform host.
type NotifyHostCredentials struct {
// ProjectID is the rdev project this credential set belongs to.
ProjectID string
// Host is the custom sending host (e.g., "mail.myapp.threesix.ai").
Host string
// From is the from-address registered on the host (e.g., "noreply@mail.myapp.threesix.ai").
From string
// ResendDomainID is the Resend domain UUID for the new host.
ResendDomainID string
}
// NotifyDomainStatus holds the Resend verification status for a project's email domain.
type NotifyDomainStatus struct {
// Host is the per-project sending host (e.g., "mail.slug.threesix.ai").
// Host is the per-project sending host (only set for custom domains).
Host string
// ResendDomainID is the Resend domain UUID.

View File

@ -18,6 +18,7 @@ import (
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
}
@ -30,6 +31,14 @@ func NewNotifyHandler(notifyProvisioner port.NotifyProvisioner, credStore port.C
}
}
// 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) {
@ -231,9 +240,16 @@ func (h *NotifyHandler) Reprovision(w http.ResponseWriter, r *http.Request) {
currentHost, currentResendDomainID := h.lookupNotifyCredentials(ctx, projectID)
if currentHost == "" {
api.WriteBadRequest(w, r, "notify not provisioned for this project — run POST /notify/provision first")
// 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

View File

@ -7,18 +7,27 @@ import (
)
// NotifyProvisioner manages per-project email delivery on the notify service.
// Each project gets its own isolated sending host (mail.{slug}.threesix.ai),
// Resend domain with DKIM/SPF, and a dedicated notify account with send key.
// Under the shared-host model, default projects do not receive a dedicated sending
// host. Instead, a single pre-provisioned platform host (e.g., "mail.threesix.ai")
// is shared by all default projects. Per-project provisioning creates only an
// account, a send key, and a host grant (3 API calls). Custom domains still receive
// a dedicated host via ReprovisionNotifyHost.
type NotifyProvisioner interface {
// CreateProjectNotify provisions a notify host, Resend domain, DNS records,
// and account with send key for the project.
// CreateProjectNotify creates a notify account, a send key, and grants the
// account access to the shared platform sending host. No per-project host,
// Resend domain, or DNS records are created for default projects.
CreateProjectNotify(ctx context.Context, projectID, slug string) (*domain.NotifyCredentials, error)
// DeleteProjectNotify removes all notify resources for a project:
// the notify account, the per-project host, the Resend domain, and DNS records.
DeleteProjectNotify(ctx context.Context, projectID, slug, resendDomainID string) error
// DeleteProjectNotify removes notify resources for a project.
// The notify account (and all its keys) is always deleted.
// If perProjectHost is non-empty, the custom sending host, Resend domain
// (when resendDomainID is non-empty), and DNS records are also deleted.
// For default projects where perProjectHost is empty, only the account is deleted.
DeleteProjectNotify(ctx context.Context, projectID, perProjectHost, resendDomainID string) error
// GetProjectNotify returns notify credentials for a project, or nil if not provisioned.
// Only AccountID and CreatedAt are recoverable; use this solely to check whether
// provisioning has already occurred (non-nil return = already provisioned).
GetProjectNotify(ctx context.Context, projectID string) (*domain.NotifyCredentials, error)
// TestConnection verifies the admin API key and notify service are reachable.
@ -40,6 +49,6 @@ type NotifyProvisioner interface {
// ReprovisionNotifyHost migrates a project's notify setup to a new sending host.
// Tears down oldHost's notify host entry, Resend domain, and DNS records, then
// creates new ones for newHost. The project's account and send key are preserved.
// Returns partial credentials (Host, From, ResendDomainID) for storage in the credential store.
ReprovisionNotifyHost(ctx context.Context, projectID, oldHost, oldResendDomainID, newHost string) (*domain.NotifyCredentials, error)
// Returns host credentials (Host, From, ResendDomainID) for storage in the credential store.
ReprovisionNotifyHost(ctx context.Context, projectID, oldHost, oldResendDomainID, newHost string) (*domain.NotifyHostCredentials, error)
}

View File

@ -47,6 +47,10 @@ type ComponentService struct {
cacheProvisioner port.CacheProvisioner
storageProvisioner port.StorageProvisioner
credentialStore port.CredentialStore
// Notify shared-host defaults (injected into deployments when no per-project custom host is stored)
notifySharedHost string
notifySharedFrom string
}
// ComponentServiceConfig configures the component service.
@ -100,6 +104,15 @@ func (s *ComponentService) WithCredentialStore(cs port.CredentialStore) *Compone
return s
}
// WithNotifyDefaults sets the platform shared notify host and from-address.
// These are injected into component deployments as NOTIFY_HOST and NOTIFY_FROM
// when the project has not provisioned a custom sending domain.
func (s *ComponentService) WithNotifyDefaults(host, from string) *ComponentService {
s.notifySharedHost = host
s.notifySharedFrom = from
return s
}
// AddComponent adds a new component to a project's monorepo.
// For code components (service, worker, app-*, cli), this scaffolds template files.
// For infrastructure components (postgres, redis), this provisions the resource.

View File

@ -241,6 +241,17 @@ func (s *ComponentService) fetchProjectCredentials(ctx context.Context, projectI
}
}
// For projects using the platform shared notify host, inject shared NOTIFY_HOST and NOTIFY_FROM
// defaults so deployed components get the correct env vars. Projects with a custom sending
// domain already have NOTIFY_HOST stored in the per-project credential store; those take
// precedence via the project-scoped fetch above.
if secrets[domain.CredKeyNotifyHost] == "" && s.notifySharedHost != "" {
secrets[domain.CredKeyNotifyHost] = s.notifySharedHost
if secrets[domain.CredKeyNotifyFrom] == "" {
secrets[domain.CredKeyNotifyFrom] = s.notifySharedFrom
}
}
if len(secrets) > 0 {
log.Debug("fetched credentials for deployment",
logging.FieldProjectID, projectID,

View File

@ -478,36 +478,20 @@ func (s *ProjectInfraService) provisionResources(ctx context.Context, result *Cr
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
// Under the shared-host model, CreateProjectNotify only returns APIKey.
// NOTIFY_HOST, NOTIFY_FROM, and NOTIFY_RESEND_DOMAIN_ID are not set here;
// they come from the platform's shared host configuration.
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 notifyCreds.ResendDomainID != "" {
if err := s.storeCredential(ctx, projectID, domain.CredentialCategoryNotify, domain.CredKeyNotifyResendDomainID, notifyCreds.ResendDomainID); err != nil {
storeErr = err
log.Error("failed to store NOTIFY_RESEND_DOMAIN_ID", 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, result.Slug, notifyCreds.ResendDomainID); rollbackErr != nil {
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)
log.Info("notify provisioned", logging.FieldProjectID, projectID, "account_id", notifyCreds.AccountID)
}
}
}
@ -890,15 +874,16 @@ func (s *ProjectInfraService) DeleteProject(ctx context.Context, projectID strin
}
}
// 5. Delete provisioned notify account (look up slug + resendDomainID from credential store)
// 5. Delete provisioned notify account.
// Under the shared-host model, perProjectHost is only set for custom-domain projects
// (stored as NOTIFY_HOST). Default projects pass empty string — only the account is deleted.
if s.notifyProvisioner != nil {
notifySlug := status.Slug
var resendDomainID string
var perProjectHost, resendDomainID string
if s.credentialStore != nil {
cred, _ := s.credentialStore.Get(ctx, projectID+":"+domain.CredKeyNotifyResendDomainID)
resendDomainID = cred
perProjectHost, _ = s.credentialStore.Get(ctx, projectID+":"+domain.CredKeyNotifyHost)
resendDomainID, _ = s.credentialStore.Get(ctx, projectID+":"+domain.CredKeyNotifyResendDomainID)
}
if err := s.notifyProvisioner.DeleteProjectNotify(ctx, projectID, notifySlug, resendDomainID); err != nil {
if err := s.notifyProvisioner.DeleteProjectNotify(ctx, projectID, perProjectHost, resendDomainID); err != nil {
log.Warn("failed to delete project notify account", logging.FieldError, err)
}
}