From ddcfe52b5cc594515803a560a093335d139f9dc4 Mon Sep 17 00:00:00 2001 From: jordan Date: Wed, 25 Feb 2026 17:04:11 -0700 Subject: [PATCH] feat: implement shared notify host model for platform email delivery 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 --- cmd/rdev-api/config.go | 18 +- cmd/rdev-api/main.go | 12 +- internal/adapter/notify/provisioner.go | 233 ++++++------------ .../adapter/notify/provisioner_reprovision.go | 4 +- internal/adapter/notify/provisioner_test.go | 222 ++++++----------- internal/domain/credential.go | 8 +- internal/domain/notify.go | 31 ++- internal/handlers/notify.go | 20 +- internal/port/notify_provisioner.go | 27 +- internal/service/component.go | 13 + internal/service/component_deploy.go | 11 + internal/service/project_infra_crud.go | 39 +-- 12 files changed, 282 insertions(+), 356 deletions(-) diff --git a/cmd/rdev-api/config.go b/cmd/rdev-api/config.go index e38d277..a016075 100644 --- a/cmd/rdev-api/config.go +++ b/cmd/rdev-api/config.go @@ -89,9 +89,11 @@ type InfraConfig struct { GCSLocation string // Bucket location (default: "US") // Notify provisioner (for per-project email delivery) - 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 + 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) @@ -199,9 +203,11 @@ func loadInfraConfig(ctx context.Context, store port.CredentialStore, cfg Config 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")), - ResendAPIKey: getOrFallback(domain.CredKeyResendAPIKey, os.Getenv("RESEND_API_KEY")), + 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 diff --git a/cmd/rdev-api/main.go b/cmd/rdev-api/main.go index 514c156..a100d04 100644 --- a/cmd/rdev-api/main.go +++ b/cmd/rdev-api/main.go @@ -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) diff --git a/internal/adapter/notify/provisioner.go b/internal/adapter/notify/provisioner.go index 0df698a..3b2f81c 100644 --- a/internal/adapter/notify/provisioner.go +++ b/internal/adapter/notify/provisioner.go @@ -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,131 +53,52 @@ 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(), + ProjectID: projectID, + AccountID: acct.ID, + APIKey: key.Key, + 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,59 +175,60 @@ func (p *Provisioner) DeleteProjectNotify(ctx context.Context, projectID, slug, } } - // 2. Delete notify host - if err := p.client.deleteHost(ctx, host); err != nil { - p.logger.Warn("failed to delete notify host", - "host", host, - "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", - "domain_id", resendDomainID, + // 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", perProjectHost, "project_id", projectID, "error", err, ) } + + if p.resend != nil && resendDomainID != "" { + if err := p.resend.deleteDomain(ctx, resendDomainID); err != nil { + p.logger.Warn("failed to delete resend domain", + "domain_id", resendDomainID, + "project_id", projectID, + "error", err, + ) + } + } + + // 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." + perProjectHost + if err := p.dns.DeleteRecordByName(ctx, "TXT", dkimName); err != nil { + p.logger.Warn("failed to delete DKIM DNS record", + "name", dkimName, + "project_id", projectID, + "error", err, + ) + } + 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, + "project_id", projectID, + "error", err, + ) + } + if err := p.dns.DeleteRecordByName(ctx, "TXT", spfSendName); err != nil { + p.logger.Warn("failed to delete SPF TXT DNS record", + "name", spfSendName, + "project_id", projectID, + "error", err, + ) + } + } } - // 4. 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 - if err := p.dns.DeleteRecordByName(ctx, "TXT", dkimName); err != nil { - p.logger.Warn("failed to delete DKIM DNS record", - "name", dkimName, - "project_id", projectID, - "error", err, - ) - } - spfSendName := "send." + host - if err := p.dns.DeleteRecordByName(ctx, "MX", spfSendName); err != nil { - p.logger.Warn("failed to delete SPF MX DNS record", - "name", spfSendName, - "project_id", projectID, - "error", err, - ) - } - if err := p.dns.DeleteRecordByName(ctx, "TXT", spfSendName); err != nil { - p.logger.Warn("failed to delete SPF TXT DNS record", - "name", spfSendName, - "project_id", projectID, - "error", err, - ) - } - } - - 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 { diff --git a/internal/adapter/notify/provisioner_reprovision.go b/internal/adapter/notify/provisioner_reprovision.go index 06731e3..71e9a45 100644 --- a/internal/adapter/notify/provisioner_reprovision.go +++ b/internal/adapter/notify/provisioner_reprovision.go @@ -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, diff --git a/internal/adapter/notify/provisioner_test.go b/internal/adapter/notify/provisioner_test.go index c2636c9..e6da5c4 100644 --- a/internal/adapter/notify/provisioner_test.go +++ b/internal/adapter/notify/provisioner_test.go @@ -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{ diff --git a/internal/domain/credential.go b/internal/domain/credential.go index 4412876..b276dcf 100644 --- a/internal/domain/credential.go +++ b/internal/domain/credential.go @@ -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" diff --git a/internal/domain/notify.go b/internal/domain/notify.go index c5870d8..98a8e17 100644 --- a/internal/domain/notify.go +++ b/internal/domain/notify.go @@ -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. diff --git a/internal/handlers/notify.go b/internal/handlers/notify.go index 628af7c..6d8a8bc 100644 --- a/internal/handlers/notify.go +++ b/internal/handlers/notify.go @@ -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,8 +240,15 @@ 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") - return + // 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") diff --git a/internal/port/notify_provisioner.go b/internal/port/notify_provisioner.go index a4b12cd..7c6ebba 100644 --- a/internal/port/notify_provisioner.go +++ b/internal/port/notify_provisioner.go @@ -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) } diff --git a/internal/service/component.go b/internal/service/component.go index 3c493ca..9b22395 100644 --- a/internal/service/component.go +++ b/internal/service/component.go @@ -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. diff --git a/internal/service/component_deploy.go b/internal/service/component_deploy.go index 305ebb3..d3a3232 100644 --- a/internal/service/component_deploy.go +++ b/internal/service/component_deploy.go @@ -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, diff --git a/internal/service/project_infra_crud.go b/internal/service/project_infra_crud.go index 09c204f..894cf28 100644 --- a/internal/service/project_infra_crud.go +++ b/internal/service/project_infra_crud.go @@ -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) } }