diff --git a/.trivyignore b/.trivyignore index 28b99833..40fc74a1 100644 --- a/.trivyignore +++ b/.trivyignore @@ -11,3 +11,13 @@ # fetched at startup over the public CA chain; the affected code # path (CRL parsing) is not reachable in our usage. GHSA-82j2-j2ch-gfr8 + +# esbuild < 0.28.1 — missing binary integrity check in the Deno module +# install path enables RCE (GHSA-gv7w-rqvm-qjhr). Pulled in transitively +# by vite / vitest / astro / tsx across admin/, docs/, site/, web/. The +# affected path is the deno.land/x/esbuild installer; we install esbuild +# only via pnpm/npm on Node, where the platform binary packages are +# integrity-pinned in the lockfile, so that path is never used. esbuild +# is a build-time dev dependency and ships in no runtime artifact. +# Re-evaluate when vite/astro bump esbuild to >= 0.28.1. +GHSA-gv7w-rqvm-qjhr diff --git a/AGENTS.md b/AGENTS.md index 0868dead..41bd220f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -118,6 +118,42 @@ Everything in the dashboard must use our own theme, not browser/library defaults - Row interactions: list rows behave like the campaigns list — clicking anywhere on a row opens that item's detail (drawer or page); right-side action buttons (3-dots / "More") open a relevant detail/tab (e.g. the mailbox 3-dots opens the Settings tab of `InboxDetails`). Inner interactive controls (checkbox, dropdown trigger, action buttons) must `e.stopPropagation()` so they don't also fire the row's open handler. - Prefer realtime over polling: subscribe to the socket and `queryClient.invalidateQueries(...)` on the relevant event instead of `refetchInterval` where an event exists (see `useRealtimeEvents` / `RealtimeManager`). +## Realtime Collaboration And Presence + +The dashboard is collaborative: org members see each other's activity live. Keep these patterns intact when extending features. + +### The audit spine + +Every `h.auditOrg` / `AuditService.LogAction` call publishes an org-scoped `AUDIT_CREATED` realtime event carrying `action`, `entity_type`, and `entity_id`. The web client maps `entity_type` to react-query invalidations (the `spine` map in `web/src/hooks/useRealtimeEvents.ts`), so every audited mutation refreshes every teammate's lists without a bespoke emit site. + +Consequences: + +- keeping audit coverage complete IS keeping the dashboard live. A new mutating handler gets org-wide realtime for free by calling `auditOrg` with a proper entity type +- a new audit entity type needs a matching entry in the frontend spine map +- dedicated realtime events only exist for non-audited consumer/scheduler flows: `EMAIL_SENT` (campaign send success), `EMAIL_REPLIED` (human replies only, via `WireRealtime` in both backend and consumer mains), `EMAIL_DELETED`, inbox arrivals, tracking opens/clicks, account health transitions + +### Org-scoped events + +The Elixir subscriber routes on the event BODY: `user_id` -> `user:`, `org_id`/`organization_id` -> `org:`, plus `campaign_id`/`email_account_id`/`operation_id` entity topics. To make an event visible to the whole team, set the `OrgID` field on the specific event struct (do NOT add OrgID to `BaseEvent`; several event structs declare their own `org_id` JSON key and embedding would conflict). + +`OrgChannel.can_see_event?` gates org-broadcast events by member permission after normalizing the event type (upcased, separators collapsed): inbox -> `access_unibox`, campaign/task/send/open/click/reply -> `view_campaigns`, contact -> `view_contacts`, account/warmup -> `manage_emails`, member/invitation -> `manage_team`, settings -> `manage_settings`, billing -> `manage_billing`. `AUDIT_CREATED` is deliberately default-allowed (payload is non-sensitive ids; the spine needs all members to receive it). New org-scoped event families must be added to this table. + +### Presence + +`RealtimeWeb.Presence` (Phoenix.Presence) tracks JWT members on the org channel; API-key (developer) sockets receive events but are never tracked. Clients push `presence:update` with `{page, resource, action}` where action is `viewing | editing | replying | idle` (rate-limited by the existing `ws_event` limiter, strings sanitized and capped). + +Web conventions: + +- `PresenceProvider` (mounted inside `RealtimeManager`) syncs `presence_state`/`presence_diff` into the zustand `presenceSlice` and pushes route changes automatically +- detail panes/editors claim a record with `usePresenceResource(resource, action)`; resource strings are `thread:`, `automation:`, `campaign:`, `contact:` — follow this naming for new surfaces +- show other viewers with `` (amber for editing/replying, emerald for viewing); the header avatar stack is `PresenceAvatars` +- `useRealtimeEvents` early-returns on `PRESENCE*` / `RATE_LIMITED` events; never let presence diffs reach the default invalidation branch +- `OrgChannel` has a no-op `handle_info(%Phoenix.Socket.Broadcast{}, ...)` clause because its manual PubSub subscription duplicates presence broadcasts to the channel process; removing it crashes the channel on the first presence diff + +### Developer WebSocket + +API keys with the `REALTIME_SUBSCRIBE` permission (bit 11) can connect to the same socket. Connection spam is bounded by per-user concurrent-connection caps (plan-based, default 10), per-IP (50), a global cap, join rate limits, and per-key IP restrictions. Documented in `docs/content/docs/api/realtime.mdx` — keep that page in sync with channel/limit changes. + ## System Shape - `cmd/backend`: API and business orchestration @@ -617,6 +653,16 @@ Relevant code: - `tracking/src/handlers.rs` - `internal/repository/pg_tracking_dedupe.go` - `internal/app/consumer/event_tracking.go` + +### Tracking endpoint anti-abuse + +The tracking service additionally defends itself before any event reaches Kafka (`tracking/src/abuse.rs`): + +- per-source rate limiting: fixed 60s window per hashed IP, `TRACKING_RATE_LIMIT_PER_MIN` (default `300`), bounded cache. Over-budget pixels are still served (no broken images) but not counted; over-budget click redirects get `429` +- prefetch/scanner filtering: `Sec-Purpose`/`Purpose`-style prefetch headers and a UA marker list (crawlers, CLI clients, chat-app link previews, email security gateways) are served but never counted. Gmail's image proxy is deliberately NOT filtered — it is the only open signal Gmail exposes +- URL caps on click redirects: 4096 bytes raw / 2048 decoded +- click links are server-side tickets, not signed URLs: `WrapLinksForTracking` mints a `tracked_links` row per link (migration 000041, batch CopyFrom; on write failure the email ships with original untracked links, never dead tickets) and the email carries only `https:///c/`. The tracking service resolves tickets via `GET /api/v1/internal/tracked-links/:id` (INTERNAL_API_TOKEN, same pattern as the worker DEK proxy) — destinations never travel inside the URL, so there is no open-redirect surface and NO signing secret anywhere. Do not reintroduce `?url=`-style redirects +- ticket-spray protection in `tracking/src/links.rs`: positive cache (24h), negative cache (60s), per-source miss budget (12 unknown-ticket lookups/min — real clickers never miss, probers get cut off before backend traffic), and a circuit breaker (5 consecutive backend failures opens for 15s; misses fail closed with 503/404, never an unverified redirect) - `internal/app/advanced/service.go` - `internal/repository/pg_advanced_outreach.go` - `internal/repository/pg_subscription.go` diff --git a/Makefile b/Makefile index 9d9dbc11..b2f8e549 100644 --- a/Makefile +++ b/Makefile @@ -440,6 +440,8 @@ tracking: KAFKA_BOOTSTRAP_SERVERS=localhost:9092 \ KAFKA_TRACKING_TOPIC=tracking-events \ SCHEMA_REGISTRY_URL=http://localhost:8081 \ + BACKEND_INTERNAL_URL=http://localhost:8080 \ + INTERNAL_API_TOKEN=local-dev-internal-token \ cargo run # Websocket fanout service (Elixir/Phoenix) on :4000. MIX_ENV=dev skips @@ -448,6 +450,7 @@ tracking: realtime: cd realtime && \ export MIX_ENV=dev \ + JWT_SECRET=local-dev-auth-secret-minimum-32-characters-long \ PORT=4000 \ PHX_HOST=$(WEB_HOST) \ DATABASE_HOST=localhost \ diff --git a/admin/src/lib/api/client.ts b/admin/src/lib/api/client.ts index d7bcfa0b..952ad9e3 100644 --- a/admin/src/lib/api/client.ts +++ b/admin/src/lib/api/client.ts @@ -51,7 +51,7 @@ interface AuthRequestConfig extends AxiosRequestConfig { let refreshPromise: Promise | null = null; async function refreshTokens(refreshToken: string): Promise { - const res = await axios.post(`${API_URL}/auth/refresh`, { + const res = await axios.post(`${API_URL}/v1/auth/refresh`, { refresh_token: refreshToken, }); return res.data; diff --git a/admin/src/lib/api/client/auth/index.ts b/admin/src/lib/api/client/auth/index.ts index e0e80a64..e27fdfcd 100644 --- a/admin/src/lib/api/client/auth/index.ts +++ b/admin/src/lib/api/client/auth/index.ts @@ -15,7 +15,7 @@ import type { export function login(input: LoginRequest): Promise { return Request({ method: "POST", - url: "/auth/login", + url: "/v1/auth/login", data: input, timeout: 15_000, }); @@ -25,7 +25,7 @@ export function login(input: LoginRequest): Promise { export function loginConfirm(input: LoginConfirmRequest): Promise { return Request({ method: "POST", - url: "/auth/login/confirm", + url: "/v1/auth/login/confirm", data: input, timeout: 15_000, }); @@ -34,7 +34,7 @@ export function loginConfirm(input: LoginConfirmRequest): Promise export function getMe(): Promise { return Request({ method: "GET", - url: "/auth/me", + url: "/v1/auth/me", authorization: true, }); } @@ -42,7 +42,7 @@ export function getMe(): Promise { export function logout(): Promise { return Request({ method: "POST", - url: "/auth/logout", + url: "/v1/auth/logout", authorization: true, }); } diff --git a/cmd/backend/main.go b/cmd/backend/main.go index 341872db..ccd35bc3 100644 --- a/cmd/backend/main.go +++ b/cmd/backend/main.go @@ -42,7 +42,9 @@ import ( idempotencyapp "github.com/warmbly/warmbly/internal/app/idempotency" "github.com/warmbly/warmbly/internal/app/integration" "github.com/warmbly/warmbly/internal/app/leadsync" + "github.com/warmbly/warmbly/internal/app/nativeactions" "github.com/warmbly/warmbly/internal/app/notification" + "github.com/warmbly/warmbly/internal/app/oauth" "github.com/warmbly/warmbly/internal/app/organization" "github.com/warmbly/warmbly/internal/app/passkey" "github.com/warmbly/warmbly/internal/app/placement" @@ -187,11 +189,13 @@ func main() { // survive the config block where they're initialized. var s3ForHandler *storage.Client var emailMessageMapForHandler repository.EmailMessageMapRepository + var trackedLinkRepository repository.TrackedLinkRepository var userRepoForHandler repository.UserRepository var organizationRepoForHandler repository.OrganizationRepository var warmupRoutingRepoForHandler repository.WarmupRoutingRepository var webhookServiceForHandler webhook.Service var integrationServiceForHandler integration.Service + var oauthService *oauth.Service var notificationService notification.Service var twofaService twofa.Service var contactRepoForHandler repository.ContactRepository @@ -312,16 +316,34 @@ func main() { log.Fatal(err) } - // Google Pub/Sub for realtime streaming (optional) - gcpProjectID := os.Getenv("GCP_PROJECT_ID") - if gcpProjectID != "" { + // Realtime event transport, chosen by PUBSUB_ENABLED — the SAME flag the + // Elixir realtime service reads — so the two sides can never split-brain + // (publisher on Pub/Sub while the subscriber listens on Redis = all events + // dropped). PUBSUB_ENABLED=true => Google Pub/Sub (prod); anything else => + // Redis bridge (local dev / non-GCP). Exactly one transport is active, so + // events are never delivered twice. + if os.Getenv("PUBSUB_ENABLED") == "true" { + gcpProjectID := os.Getenv("GCP_PROJECT_ID") + if gcpProjectID == "" { + log.Fatal("PUBSUB_ENABLED=true requires GCP_PROJECT_ID") + } pubsubClient, err := pubsub.NewClient(ctx, gcpProjectID) if err != nil { sentry.CaptureException(err) - log.Printf("Warning: Failed to initialize Pub/Sub client: %v", err) - } else { - streamingPublisher = pubsub.NewStreamingPublisher(pubsubClient) + log.Fatal("Failed to initialize Pub/Sub client: ", err) } + // Create the realtime topics + "-sub" subscriptions if missing, + // so the Elixir Broadway consumers always have a subscription to read. + if err := pubsubClient.EnsureRealtimeTopology(ctx); err != nil { + sentry.CaptureException(err) + log.Fatal("Failed to provision Pub/Sub topics/subscriptions: ", err) + } + streamingPublisher = pubsub.NewStreamingPublisher(pubsubClient) + log.Println("Realtime events published to Google Pub/Sub") + } + if streamingPublisher == nil { + streamingPublisher = pubsub.NewStreamingPublisher(pubsub.NewRedisBus(cache.Client, "")) + log.Println("Realtime events bridged over Redis (Pub/Sub disabled)") } emailCfg, err := cfg.LoadEmailConfig(ctx) @@ -465,6 +487,7 @@ func main() { "postgres", ) emailMessageMapForHandler = repository.NewEmailMessageMapRepository(primaryDB) + trackedLinkRepository = repository.NewTrackedLinkRepository(primaryDB.Pool) if err != nil { sentry.CaptureException(err) log.Fatal(err) @@ -526,6 +549,8 @@ func main() { webhookServiceForHandler = webhookService integrationRepository := repository.NewIntegrationRepository(primaryDB.Pool) + // OAuth 2.1 authorization server (third-party app registration + token flow). + oauthService = oauth.NewService(repository.NewOAuthRepository(primaryDB.Pool)) // integrationServiceForHandler is constructed after cipherService below — // OAuth/secret sealing depends on the envelope-encryption service. contactRepoForHandler = contactRepostory @@ -849,26 +874,39 @@ func main() { contactRepostory, campaignProgressRepository, crmRepository, + uniboxRepository, tasksClient, warmupService, ) // Fan reply + bounce events from the advanced-outreach brain out to // customer webhooks AND third-party integration actions (Slack / CRM). advancedService.WireDispatcher(webhookService) + // Let instant action chains (reply/open/click branches) launch a + // "run_automation" node, the same flow the scheduler runs at a step + // boundary. Backend ingests deliverability + can process replies too. + advancedService.WireAutomationRunner(integrationServiceForHandler) // Wire native (Warmbly-internal) automation actions + realtime now that // the advanced/contact/org services exist (the integration service was // constructed earlier). - integrationServiceForHandler.SetNativeActions(nativeActionsAdapter{ - adv: advancedService, - contacts: contactRepostory, - orgs: organizationRepository, + integrationServiceForHandler.SetNativeActions(nativeactions.Adapter{ + Adv: advancedService, + Contacts: contactRepostory, + Orgs: organizationRepository, }) integrationServiceForHandler.SetPublisher(streamingPublisher) + // Per-org daily outbound-action quota (anti-abuse on the HTTP-request node). + integrationServiceForHandler.SetOutboundQuotaCache(cache) // In-app notifications: API reads/writes happen here; also wire the gate // onto the backend's advanced service (deliverability webhooks can ingest // here too). notificationService = notification.NewService(repository.NewNotificationRepository(primaryDB.Pool), streamingPublisher) + notificationService.WireDelivery(emailNotificationService, integrationServiceForHandler, userRepostory) advancedService.WireNotifier(notificationService) + // New-device sign-in alerts: the token service fires this on session + // creation from an unrecognized device, delivered as a security + // notification (in-app + email per the user's channels). + tokenService.WireSignInAlerter(notification.NewSignInAlerter(notificationService)) + advancedService.WireRealtime(streamingPublisher) emailSender := tasks.NewEmailSender(emailRepostory, eventsPublisher) tasksService = tasks.NewService( tasksClient, @@ -892,6 +930,7 @@ func main() { campaignLogRepository, advancedService, attachmentRepoForHandler, + trackedLinkRepository, integrationServiceForHandler, // AutomationRunner for campaign run_automation steps ) @@ -920,6 +959,12 @@ func main() { // enqueue the first warmup task. go tasksService.StartWarmupReconciler(ctx, 10*time.Minute) + // Campaign reconciler: re-seed active campaigns whose self-perpetuating + // task chain died (a swallowed enqueue, a worker bounce mid-tick, or a + // crash between send and enqueue). Campaigns have no other bootstrap once + // started, so without this a stranded campaign stops sending forever. + go tasksService.StartCampaignReconciler(ctx, 5*time.Minute) + // Danger zone: schedule + execute delayed deletions (orgs, accounts). dangerZoneRepository := repository.NewDangerZoneRepository(primaryDB.Pool) dangerZoneService = dangerzone.NewService( @@ -1072,6 +1117,9 @@ func main() { ContactRepo: contactRepoForHandler, StreamingPublisher: streamingPublisher, + // OAuth 2.1 authorization server + OAuthService: oauthService, + // On-demand Google Sheets -> leads sync LeadSyncService: leadSyncServiceForHandler, @@ -1082,6 +1130,7 @@ func main() { Storage: s3ForHandler, EncryptedKeys: encryptedKeys, EmailMessageMap: emailMessageMapForHandler, + TrackedLinks: trackedLinkRepository, UserRepo: userRepoForHandler, OrgRepo: organizationRepoForHandler, AttachmentRepo: attachmentRepoForHandler, @@ -1105,6 +1154,7 @@ func main() { APIKeyService: apiKeyService, IdempotencyService: idempotencyService, OrganizationService: organizationService, + OAuthService: oauthService, } oidcH := &middleware.OidcHandler{ diff --git a/cmd/backend/native_actions_adapter.go b/cmd/backend/native_actions_adapter.go deleted file mode 100644 index 524d33bf..00000000 --- a/cmd/backend/native_actions_adapter.go +++ /dev/null @@ -1,97 +0,0 @@ -package main - -import ( - "context" - "fmt" - - "github.com/google/uuid" - - "github.com/warmbly/warmbly/internal/app/advanced" - "github.com/warmbly/warmbly/internal/models" - "github.com/warmbly/warmbly/internal/repository" -) - -// nativeActionsAdapter satisfies integration.NativeActions, bridging the -// integration package's automation executor to the advanced/contact/org -// services. It converts *errx.Error to error (so a nil error stays nil) and -// resolves the contact + org owner the native CRM/contact actions need. -type nativeActionsAdapter struct { - adv advanced.Service - contacts repository.ContactRepository - orgs repository.OrganizationRepository -} - -func (a nativeActionsAdapter) ResolveContact(ctx context.Context, orgID uuid.UUID, contactID, email string) (*models.Contact, error) { - // Both lookups are ORG-SCOPED — never resolve a contact id from another org, - // even if a stale/crafted id reaches the event data. - if contactID != "" { - if id, perr := uuid.Parse(contactID); perr == nil { - if cs, e := a.contacts.GetByIDsAndOrganization(ctx, orgID, []uuid.UUID{id}); e == nil && len(cs) > 0 { - return &cs[0], nil - } - } - } - if email != "" { - if c, e := a.contacts.GetByEmailAndOrganization(ctx, orgID, email); e == nil && c != nil { - return c, nil - } - } - return nil, nil -} - -func (a nativeActionsAdapter) OrgOwner(ctx context.Context, orgID uuid.UUID) (uuid.UUID, error) { - org, err := a.orgs.GetByID(ctx, orgID) - if err != nil { - return uuid.Nil, err - } - if org == nil { - return uuid.Nil, fmt.Errorf("organization not found") - } - return org.OwnerUserID, nil -} - -func (a nativeActionsAdapter) AddTag(ctx context.Context, orgID, actorID, contactID, categoryID uuid.UUID) error { - if _, e := a.contacts.Update(ctx, actorID.String(), contactID.String(), &models.UpdateContact{ - AddCategories: []string{categoryID.String()}, - }); e != nil { - return e - } - return nil -} - -func (a nativeActionsAdapter) RemoveTag(ctx context.Context, orgID, actorID, contactID, categoryID uuid.UUID) error { - if _, e := a.contacts.Update(ctx, actorID.String(), contactID.String(), &models.UpdateContact{ - RemoveCategories: []string{categoryID.String()}, - }); e != nil { - return e - } - return nil -} - -func (a nativeActionsAdapter) CreateTask(ctx context.Context, orgID, createdBy uuid.UUID, data *models.CreateCRMTask) error { - if _, e := a.adv.CreateContactTask(ctx, orgID, createdBy, data); e != nil { - return e - } - return nil -} - -func (a nativeActionsAdapter) CreateDeal(ctx context.Context, orgID, createdBy uuid.UUID, data *models.CreateDeal) error { - if _, e := a.adv.CreateContactDeal(ctx, orgID, createdBy, data); e != nil { - return e - } - return nil -} - -func (a nativeActionsAdapter) MoveDealStage(ctx context.Context, orgID, contactID, pipelineID, stageID uuid.UUID) error { - if _, e := a.adv.MoveContactDealStage(ctx, orgID, contactID, pipelineID, stageID); e != nil { - return e - } - return nil -} - -func (a nativeActionsAdapter) Unsubscribe(ctx context.Context, campaignID, contactID uuid.UUID) error { - if e := a.adv.Unsubscribe(ctx, campaignID, contactID); e != nil { - return e - } - return nil -} diff --git a/cmd/consumer/main.go b/cmd/consumer/main.go index 80fd4191..e764305b 100644 --- a/cmd/consumer/main.go +++ b/cmd/consumer/main.go @@ -14,6 +14,7 @@ import ( "github.com/warmbly/warmbly/internal/app/cipher" jobs "github.com/warmbly/warmbly/internal/app/consumer" "github.com/warmbly/warmbly/internal/app/integration" + "github.com/warmbly/warmbly/internal/app/nativeactions" "github.com/warmbly/warmbly/internal/app/notification" warmupapp "github.com/warmbly/warmbly/internal/app/warmup" "github.com/warmbly/warmbly/internal/app/webhook" @@ -29,6 +30,7 @@ import ( "github.com/warmbly/warmbly/internal/infrastructure/kms" "github.com/warmbly/warmbly/internal/infrastructure/pubsub" "github.com/warmbly/warmbly/internal/infrastructure/storage" + "github.com/warmbly/warmbly/internal/notify" "github.com/warmbly/warmbly/internal/observability" "github.com/warmbly/warmbly/internal/repository" ) @@ -150,18 +152,34 @@ func main() { log.Fatal(err) } - // Google Pub/Sub - gcpProjectID := os.Getenv("GCP_PROJECT_ID") + // Realtime event transport, chosen by PUBSUB_ENABLED — the SAME flag the + // backend and the Elixir realtime service read, so the three services can + // never split-brain. PUBSUB_ENABLED=true => Google Pub/Sub (prod); anything + // else => Redis bridge (local dev / non-GCP). Exactly one transport is active. var streamingPublisher *pubsub.StreamingPublisher - if gcpProjectID != "" { + if os.Getenv("PUBSUB_ENABLED") == "true" { + gcpProjectID := os.Getenv("GCP_PROJECT_ID") + if gcpProjectID == "" { + log.Fatal("PUBSUB_ENABLED=true requires GCP_PROJECT_ID") + } pubsubClient, err := pubsub.NewClient(ctx, gcpProjectID) if err != nil { sentry.CaptureException(err) log.Fatal(err) } defer pubsubClient.Close() + // Idempotently ensure the realtime topics + subscriptions exist (safe to + // run from both backend and consumer; AlreadyExists is treated as success). + if err := pubsubClient.EnsureRealtimeTopology(ctx); err != nil { + sentry.CaptureException(err) + log.Fatal("Failed to provision Pub/Sub topics/subscriptions: ", err) + } streamingPublisher = pubsub.NewStreamingPublisher(pubsubClient) } + if streamingPublisher == nil { + streamingPublisher = pubsub.NewStreamingPublisher(pubsub.NewRedisBus(redisCache.Client, "")) + log.Println("Realtime events bridged over Redis (Pub/Sub disabled)") + } // Repositories emailRepo := repository.NewEmailRepostory(primaryDB) @@ -185,6 +203,7 @@ func main() { contactRepo := repository.NewContactRepostory(primaryDB) campaignProgressRepo := repository.NewCampaignProgressRepository(primaryDB.Pool) crmRepo := repository.NewCRMRepository(primaryDB.Pool) + orgRepoConsumer := repository.NewOrganizationRepository(primaryDB.Pool) advancedRepo := repository.NewAdvancedOutreachRepository(primaryDB.Pool) // Reply → integration fan-out. The consumer is where inbound replies are @@ -219,16 +238,49 @@ func main() { contactRepo, campaignProgressRepo, crmRepo, + uniboxRepo, nil, // tasksClient: the consumer does not schedule Cloud Tasks warmupService, ) advancedService.WireDispatcher(webhookService) + // Reply/open/click instant action chains run in THIS process (inbox ingest + + // tracking consumer), so a "run_automation" node on an instant branch must be + // able to launch the flow here too. Without this it would be stamped sent and + // never fire. Mirrors the scheduler's automationRunner wiring. + advancedService.WireAutomationRunner(integrationServiceC) + // Native (Warmbly-internal) automation actions run wherever the event is + // dispatched. Reply/bounce/warmup events dispatch in THIS process, so without + // wiring native actions here a reply-triggered automation's add_tag / + // create_deal / label_email node would fail with "native actions are not + // available". Mirrors the backend wiring. + integrationServiceC.SetNativeActions(nativeactions.Adapter{ + Adv: advancedService, + Contacts: contactRepo, + Orgs: orgRepoConsumer, + }) + // Per-org daily outbound-action quota (event-driven automations run here). + integrationServiceC.SetOutboundQuotaCache(redisCache) // In-app notifications: the reply/bounce/complaint gate fires in THIS // process (inbox ingest + deliverability ingest run in the consumer), so the // notifier must be wired here. Missing this = notifications silently never // created. notificationService := notification.NewService(repository.NewNotificationRepository(primaryDB.Pool), streamingPublisher) + // Email + Slack delivery for notifications. Email is best-effort: the + // SES/SMTP service only constructs when email config is present (prod, or + // a dev env that sets it), so a bare dev consumer simply skips the email + // channel. Slack reuses the integration service (token decryption). + var notifEmail notification.EmailSender + if emailCfg, ecErr := cfg.LoadEmailConfig(ctx); ecErr == nil { + if smtpCfg := cfg.LoadSMTPConfig(ctx); smtpCfg != nil { + notifEmail = notify.NewSMTPEmailNotificationService(emailCfg.EmailName, emailCfg.EmailAddress, smtpCfg.Host, smtpCfg.Port) + } else if ses, sErr := notify.NewEmailNotficiationService(ctx, emailCfg.EmailName, emailCfg.EmailAddress); sErr == nil { + notifEmail = ses + } + } + notificationService.WireDelivery(notifEmail, integrationServiceC, repository.NewUserRepostory(primaryDB, kmsClient)) advancedService.WireNotifier(notificationService) + // Reply pulses fire in THIS process too (inbox ingest classifies replies). + advancedService.WireRealtime(streamingPublisher) // Events publisher — wraps the existing Kafka producer in an EventBus, // wraps Avrov2 in a Codec. Once EVENTBUS_PROVIDER=nats is exercised in diff --git a/deploy/README.md b/deploy/README.md index 62fd3097..0d928cb0 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -53,6 +53,15 @@ The Dockerfiles in `deploy/docker/` are the deployment unit. Production runs on Configuration is env-driven — see `deploy/config/env.example` for the full env reference, or [../resources/deployment-guide.md](../resources/deployment-guide.md) for a step-by-step. +### Realtime transport + +Backend, consumer, and the Elixir realtime service all pick their event transport from one flag, `PUBSUB_ENABLED`, so they cannot disagree: + +- `PUBSUB_ENABLED=false` (default): events bridge over Redis (`REDIS_URL`). No GCP needed. This is the local-dev and simple self-host path. +- `PUBSUB_ENABLED=true`: events flow through Google Pub/Sub. Also set `GCP_PROJECT_ID` and `GOOGLE_APPLICATION_CREDENTIALS_JSON` on every service. The backend and consumer auto-provision the realtime topics and their `-sub` pull subscriptions on boot (idempotent), so there is no manual `gcloud` step. The service account needs `roles/pubsub.editor`. + +Set the flag the same on all three services. A publisher on Pub/Sub with a subscriber on Redis silently drops every realtime event. + ## Worker deployment Workers run on per-VPS machines so cold-mail traffic spreads across many IPs. Worker identity is a deterministic UUIDv5 derived from the VPS's public IPv4 — same IP, same worker. diff --git a/deploy/config/env.example b/deploy/config/env.example index f56b6c2a..08b4de30 100644 --- a/deploy/config/env.example +++ b/deploy/config/env.example @@ -81,6 +81,12 @@ TRACKING_DOMAIN=track.warmbly.com # === Tracking Service === TRACKING_HOST=0.0.0.0 TRACKING_PORT=3000 +# Click-ticket resolution (REQUIRED, tracking service): emails carry only an +# opaque /c/ ticket; the service resolves destinations via the backend +# internal API. Same INTERNAL_API_TOKEN as the workers use. +BACKEND_INTERNAL_URL=http://backend:8080 +# Per-source request budget for the tracking endpoints (default 300/min) +TRACKING_RATE_LIMIT_PER_MIN=300 # === Realtime Service (Elixir/Phoenix) === PHX_HOST=localhost @@ -90,6 +96,12 @@ SECRET_KEY_BASE=your-64-char-minimum-phoenix-secret-key-base DATABASE_URL=postgres://user:pass@localhost:5432/warmbly_dev DATABASE_POOL_SIZE=10 REDIS_URL=redis://localhost:6379 +# Realtime transport switch. Read IDENTICALLY by the Go backend, the Go consumer, +# AND the Elixir realtime service, so the three can never split-brain (publisher +# on Pub/Sub while the subscriber listens on Redis = every realtime event silently +# dropped). false (default) => Redis bridge over REDIS_URL, works out of the box. +# true => Google Pub/Sub: ALSO set GCP_PROJECT_ID + GOOGLE_APPLICATION_CREDENTIALS_JSON +# below, on every service. Never set this true on one side only. PUBSUB_ENABLED=false CHECK_ORIGIN=false MAX_CONNECTIONS_PER_USER=10 @@ -99,9 +111,12 @@ RATE_LIMIT_WS_MESSAGE=120 RATE_LIMIT_WS_JOIN=30 RATE_LIMIT_WS_EVENT=60 -# === GCP === -GCP_PROJECT_ID=your-gcp-project # Optional - for Pub/Sub -GOOGLE_APPLICATION_CREDENTIALS_JSON= # JSON string - for GCP auth +# === GCP (required when PUBSUB_ENABLED=true; leave blank for the Redis bridge) === +# When PUBSUB_ENABLED=true the backend/consumer auto-provision the realtime topics +# and their "-sub" pull subscriptions on boot, so no manual gcloud step is +# needed. The service account needs roles/pubsub.editor (create topics + subs). +GCP_PROJECT_ID= # required when PUBSUB_ENABLED=true +GOOGLE_APPLICATION_CREDENTIALS_JSON= # JSON string - GCP auth (Go client + Elixir Goth) # === Observability === SENTRY_DSN= # Optional diff --git a/docs/content/docs/api/account-and-organization.mdx b/docs/content/docs/api/account-and-organization.mdx new file mode 100644 index 00000000..563b9361 --- /dev/null +++ b/docs/content/docs/api/account-and-organization.mdx @@ -0,0 +1,1993 @@ +--- +title: Account and organization +description: Authentication, sessions, your profile and security settings, organization governance, teams, and subscription billing. +icon: Users +--- + +This group covers everything tied to a human account and the workspace it belongs to: signing in, managing sessions, editing your profile and security settings (notification preferences, two-factor authentication, passkeys), running an organization (members, invitations, custom roles), grouping members into teams, and billing. + +Almost every endpoint here is session only. They depend on a human-bound JWT and are never reachable with an API key. The two exceptions are the team endpoints, which accept either a JWT or an API key, and the public auth endpoints (login, register, refresh, password reset, passkey login, 2FA verify), which carry no session at all because they exist to create one. Each endpoint below states its auth explicitly. + +All error responses use the shared envelope (`error`, `message`, `code`, `request_id`). See [Error codes](/api/error-codes/). + +## Authentication + +These endpoints are public (no session required). Login and registration are two-step: the first call sends a one-time code by email and returns a short-lived signed `session` token, the second call confirms the code and mints the real token pair. Captcha (Cloudflare Turnstile) is required on the start calls. + +### Start login + +`POST /auth/login` + +Sends a login code to the account email and returns a signed session token to carry into the confirm step. + +Auth: public (not available to API keys). + +#### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `email` | string | yes | Account email. | +| `password` | string | yes | Account password. | +| `turnstile` | string | yes | Cloudflare Turnstile token. | + +```json +{ + "email": "alex@acme.com", + "password": "correct horse battery staple", + "turnstile": "0.abc123..." +} +``` + +#### Response + +```json +{ + "session": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." +} +``` + +### Confirm login + +`POST /auth/login/confirm` + +Exchanges the emailed code plus the session token for a token pair. If the account has 2FA enabled, no token pair is returned: `two_fa_required` is `true` and a single-use `pending_token` is returned instead, to be passed to `/auth/2fa/verify/`. + +Auth: public (not available to API keys). + +#### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `session` | string | yes | The token returned by `/auth/login`. | +| `code` | string | yes | The one-time code from the email. | +| `turnstile` | string | no | Cloudflare Turnstile token. | + +```json +{ + "session": "eyJhbGciOiJIUzI1NiIs...", + "code": "489210" +} +``` + +#### Response + +On success, the full token pair: + +```json +{ + "access_token": "eyJhbGc...", + "access_token_expires_at": "2026-06-12T13:00:00Z", + "refresh_token": "eyJhbGc...", + "refresh_token_expires_at": "2026-07-12T12:00:00Z" +} +``` + +When 2FA is enabled, a challenge instead of a session: + +```json +{ + "two_fa_required": true, + "pending_token": "eyJhbGc...", + "expires_in": 300 +} +``` + +### Start registration + +`POST /auth/register` + +Creates a pending registration and emails a confirmation code. Returns a signed session token for the confirm step. + +Auth: public (not available to API keys). + +#### Request body + +Same shape as `/auth/login`: `email`, `password`, `turnstile`. + +```json +{ + "email": "newuser@acme.com", + "password": "correct horse battery staple", + "turnstile": "0.abc123..." +} +``` + +#### Response + +```json +{ + "session": "eyJhbGciOiJIUzI1NiIs..." +} +``` + +### Confirm registration + +`POST /auth/register/confirm` + +Confirms the emailed code and creates the account. Returns `204 No Content` (the client then signs in via the login flow). + +Auth: public (not available to API keys). + +#### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `session` | string | yes | The token returned by `/auth/register`. | +| `code` | string | yes | The one-time code from the email. | +| `turnstile` | string | no | Cloudflare Turnstile token. | + +#### Response + +`204 No Content`. + +### Refresh token + +`POST /auth/refresh` + +Exchanges a valid refresh token for a fresh token pair. + +Auth: public (not available to API keys). + +#### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `refresh_token` | string | yes | A valid, unexpired refresh token. | + +```json +{ + "refresh_token": "eyJhbGc..." +} +``` + +#### Response + +A new token pair, same shape as the login confirm success response (`access_token`, `access_token_expires_at`, `refresh_token`, `refresh_token_expires_at`). + +### Start password reset + +`POST /auth/reset-password` + +Emails a reset code if the address has an account. Always returns `200 OK` (it does not reveal whether the address exists). + +Auth: public (not available to API keys). + +#### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `email` | string | yes | Account email. | +| `turnstile` | string | yes | Cloudflare Turnstile token. | + +#### Response + +`200 OK` with an empty body. + +### Confirm password reset + +`POST /auth/reset-password/confirm` + +Sets a new password using the emailed reset code (carried inside the signed `session` token). + +Auth: public (not available to API keys). + +#### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `session` | string | yes | The reset session token tied to the email. | +| `password` | string | yes | The new password. | +| `turnstile` | string | yes | Cloudflare Turnstile token. | + +#### Response + +`200 OK` with an empty body. + +### Begin passkey login + +`POST /auth/passkey/login/begin` + +Starts a discoverable (usernameless) WebAuthn assertion. Returns the public-key request options the browser passes to `navigator.credentials.get()`. + +Auth: public (not available to API keys). + +#### Response + +The WebAuthn assertion options object (challenge, RP id, allowed credentials, timeout), to feed directly to the WebAuthn API. The embedded `session` is echoed back in the finish step. + +### Finish passkey login + +`POST /auth/passkey/login/finish` + +Verifies the signed assertion and mints a token pair. A passkey is strong auth, so there is no email code step. + +Auth: public (not available to API keys). + +#### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `session` | string | yes | The session value from the begin step. | +| `credential` | object | yes | The raw WebAuthn assertion (`PublicKeyCredential`) from the browser. | + +#### Response + +A token pair (same shape as the login confirm success response). + +### Verify 2FA login + +`POST /auth/2fa/verify` + +Exchanges the single-use `pending_token` from a 2FA-gated login plus a TOTP or recovery code for a real session. + +Auth: public (not available to API keys). + +#### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `pending_token` | string | yes | The `pending_token` returned by `/auth/login/confirm`. | +| `code` | string | yes | A current TOTP code or an unused recovery code. | + +```json +{ + "pending_token": "eyJhbGc...", + "code": "123456" +} +``` + +#### Response + +A token pair (same shape as the login confirm success response). + +## Sessions + +Self-service session management for the signed-in user. Revoking by id can only ever touch the caller's own sessions. + +### Sign out + +`POST /auth/logout` + +Revokes the current session. Returns `204 No Content`. + +Auth: Session only (not available to API keys). + +### Sign out everywhere + +`POST /auth/logout-all` + +Revokes every session for the user. Returns `204 No Content`. + +Auth: Session only (not available to API keys). + +### List sessions + +`GET /auth/sessions` + +Returns the user's active sessions for the account security page, with the current one flagged and floated to the top. + +Auth: Session only (not available to API keys). + +#### Response + +A bare array of session views. + +```json +[ + { + "id": "1f1d...", + "current": true, + "browser": "Chrome", + "os": "macOS", + "location_city": "Austin", + "location_region": "Texas", + "location_country": "United States", + "country_code": "US", + "auth_provider": "email", + "created_at": "2026-06-10T08:12:00Z", + "last_active_at": "2026-06-12T09:40:00Z" + } +] +``` + +### Revoke other sessions + +`DELETE /auth/sessions` + +Ends every active session except the current one. Returns `204 No Content`. + +Auth: Session only (not available to API keys). + +### Revoke a session + +`DELETE /auth/sessions/:id` + +Ends one of the user's other sessions by id. Returns `204 No Content`. + +Auth: Session only (not available to API keys). + +| Parameter | In | Type | Description | +|-----------|----|------|-------------| +| `id` | path | uuid | The session id to revoke. | + +## Profile and account + +### Get current user + +`GET /auth/me` + +Returns the signed-in user, including admin flags and the per-user label groups (folders, tags, categories) the dashboard needs on initial load. + +Auth: Session only (not available to API keys). + +#### Response + +```json +{ + "id": "9c2a...", + "first_name": "Alex", + "last_name": "Rivera", + "email": "alex@acme.com", + "avatar_url": "https://warmbly-assets.s3.amazonaws.com/avatars/users/9c2a-...jpg", + "roles": ["b1e4..."], + "referral_source": "google", + "onboarding_completed_at": "2026-05-01T10:00:00Z", + "max_organizations": 3, + "free_trial_used": true, + "admin_permissions": 0, + "is_admin": false, + "folders": [], + "tags": [], + "categories": [], + "created_at": "2026-04-20T12:00:00Z", + "updated_at": "2026-06-10T08:00:00Z" +} +``` + +### Update profile + +`PATCH /auth/me` + +Updates editable profile fields. First and last name are both required and capped at 50 characters. Returns `204 No Content`. + +Auth: Session only (not available to API keys). + +#### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `first_name` | string | yes | 1 to 50 characters. | +| `last_name` | string | yes | 1 to 50 characters. | + +```json +{ + "first_name": "Alex", + "last_name": "Rivera" +} +``` + +### Complete onboarding + +`PATCH /auth/me/onboarding` + +Persists the onboarding questionnaire (name plus optional persona answers). Returns `204 No Content`. + +Auth: Session only (not available to API keys). + +#### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `first_name` | string | yes | 1 to 50 characters. | +| `last_name` | string | yes | 1 to 50 characters. | +| `referral_source` | string | yes | One of `reddit`, `x`, `facebook`, `google`, `other`. | +| `role` | string | no | One of `founder`, `sales`, `marketing`, `agency`, `recruiter`, `other`. | +| `team_size` | string | no | One of `just_me`, `2-10`, `11-50`, `51-200`, `200+`. | + +```json +{ + "first_name": "Alex", + "last_name": "Rivera", + "referral_source": "google", + "role": "sales", + "team_size": "2-10" +} +``` + +### Upload avatar + +`POST /auth/me/avatar` + +Uploads a profile image. `multipart/form-data` with a single `file` field. PNG or JPG only, max 2 MB, max 1024x1024 px. + +Auth: Session only (not available to API keys). + +#### Response + +```json +{ + "avatar_url": "https://warmbly-assets.s3.amazonaws.com/avatars/users/9c2a-1718193600.jpg" +} +``` + +### Remove avatar + +`DELETE /auth/me/avatar` + +Clears the profile image. Returns `204 No Content`. + +Auth: Session only (not available to API keys). + +### Change password + +`POST /auth/me/password` + +Updates the signed-in user's password. Returns `200 OK`. + +Auth: Session only (not available to API keys). + +#### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `current_password` | string | yes | The existing password. | +| `new_password` | string | yes | The new password. | + +```json +{ + "current_password": "correct horse battery staple", + "new_password": "a longer better passphrase" +} +``` + +## Notification preferences and feed + +User-scoped, no organization gate. Preferences are a single per-user object; the feed is the in-app notification list. + +### Get notification preferences + +`GET /auth/me/notification-preferences` + +Returns the caller's preferences merged over the defaults. + +Auth: Session only (not available to API keys). + +#### Response + +```json +{ + "preferences": { + "inbound_reply": { "enabled": false, "channels": { "in_app": true, "email": false, "slack": false } }, + "inbound_out_of_office": { "enabled": false, "channels": { "in_app": true, "email": false, "slack": false } }, + "health_bounce": { "enabled": true, "channels": { "in_app": true, "email": false, "slack": false } }, + "health_complaint": { "enabled": true, "channels": { "in_app": true, "email": false, "slack": false } }, + "health_worker_downtime": { "enabled": true, "channels": { "in_app": true, "email": false, "slack": false } }, + "security_new_signin": { "enabled": true, "channels": { "in_app": true, "email": false, "slack": false } } + } +} +``` + +### Update notification preferences + +`PUT /auth/me/notification-preferences` + +Replaces the caller's preferences. The full preferences object is sent under a `preferences` key and echoed back. + +Auth: Session only (not available to API keys). + +#### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `preferences` | object | yes | The complete preferences object. Each of the six categories has `enabled` plus a `channels` object (`in_app`, `email`, `slack`). | + +```json +{ + "preferences": { + "inbound_reply": { "enabled": true, "channels": { "in_app": true, "email": true, "slack": false } }, + "inbound_out_of_office": { "enabled": false, "channels": { "in_app": true, "email": false, "slack": false } }, + "health_bounce": { "enabled": true, "channels": { "in_app": true, "email": false, "slack": false } }, + "health_complaint": { "enabled": true, "channels": { "in_app": true, "email": false, "slack": false } }, + "health_worker_downtime": { "enabled": true, "channels": { "in_app": true, "email": false, "slack": false } }, + "security_new_signin": { "enabled": true, "channels": { "in_app": true, "email": false, "slack": false } } + } +} +``` + +#### Response + +The same `{ "preferences": { ... } }` object that was sent. + +### List notifications + +`GET /auth/me/notifications` + +Returns the caller's recent in-app feed plus the unread count. This is a fixed-size feed, not a cursor-paginated list. + +Auth: Session only (not available to API keys). + +| Parameter | In | Type | Description | +|-----------|----|------|-------------| +| `limit` | query | int | Max items to return (default 50). | +| `unread` | query | string | `1` or `true` to return only unread items. | + +#### Response + +```json +{ + "notifications": [ + { + "id": "f0aa...", + "user_id": "9c2a...", + "organization_id": "3d11...", + "category": "health_bounce", + "title": "Hard bounce on alex@acme.com", + "body": "A message bounced and the recipient was suppressed.", + "link": "/app/mailboxes/abc", + "read_at": null, + "created_at": "2026-06-12T09:00:00Z" + } + ], + "unread": 3 +} +``` + +### Mark all read + +`PUT /auth/me/notifications` + +Marks the caller's whole feed read. + +Auth: Session only (not available to API keys). + +#### Response + +```json +{ "ok": true } +``` + +### Mark one read + +`POST /auth/me/notifications/:id/read` + +Marks a single notification read. + +Auth: Session only (not available to API keys). + +| Parameter | In | Type | Description | +|-----------|----|------|-------------| +| `id` | path | uuid | The notification id. | + +#### Response + +```json +{ "ok": true } +``` + +## Two-factor authentication + +TOTP-based 2FA. Enrollment and management require a live session. The login challenge itself (`/auth/2fa/verify`) is public and documented above. + +### 2FA status + +`GET /auth/2fa/status` + +Reports whether the caller has 2FA enabled. + +Auth: Session only (not available to API keys). + +#### Response + +```json +{ "enabled": false } +``` + +### Begin enrollment + +`POST /auth/2fa/enroll/start` + +Generates a TOTP secret and the `otpauth://` provisioning URI (returned once). The client renders the URI as a QR code. + +Auth: Session only (not available to API keys). + +#### Response + +```json +{ + "secret": "JBSWY3DPEHPK3PXP", + "otpauth_uri": "otpauth://totp/Warmbly:alex@acme.com?secret=JBSWY3DPEHPK3PXP&issuer=Warmbly" +} +``` + +### Confirm enrollment + +`POST /auth/2fa/enroll/confirm` + +Verifies a TOTP code, enables 2FA, and returns the one-time recovery codes (shown once). + +Auth: Session only (not available to API keys). + +#### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `code` | string | yes | A current TOTP code from the authenticator. | + +#### Response + +```json +{ + "recovery_codes": ["a1b2-c3d4", "e5f6-g7h8", "..."] +} +``` + +### Disable 2FA + +`DELETE /auth/2fa` + +Turns off 2FA. Requires a current TOTP or recovery code in the body. + +Auth: Session only (not available to API keys). + +#### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `code` | string | yes | A current TOTP or recovery code. | + +#### Response + +```json +{ "ok": true } +``` + +## Passkeys + +WebAuthn credential management for the signed-in user. The login flow (`/auth/passkey/login/begin` and `/finish`) is public and documented above. + +### Begin passkey registration + +`POST /auth/passkey/register/begin` + +Returns the WebAuthn creation options the browser passes to `navigator.credentials.create()`. + +Auth: Session only (not available to API keys). + +#### Response + +The WebAuthn creation options object (challenge, RP, user, pubkey params). + +### Finish passkey registration + +`POST /auth/passkey/register/finish` + +Stores the new credential and returns its display view. + +Auth: Session only (not available to API keys). + +#### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `name` | string | no | A friendly label for the passkey. | +| `credential` | object | yes | The raw WebAuthn attestation (`PublicKeyCredential`) from the browser. | + +#### Response + +```json +{ + "id": "c41f...", + "name": "MacBook Touch ID", + "credential_id": "b64url...", + "transports": ["internal"], + "backup_state": true, + "created_at": "2026-06-12T09:00:00Z", + "last_used_at": null +} +``` + +### List passkeys + +`GET /auth/passkey/credentials` + +Returns the user's stored passkeys. + +Auth: Session only (not available to API keys). + +#### Response + +A bare array of credential views (same shape as the register finish response). + +### Rename a passkey + +`PATCH /auth/passkey/credentials/:id` + +Renames a stored passkey and returns the updated view. + +Auth: Session only (not available to API keys). + +| Parameter | In | Type | Description | +|-----------|----|------|-------------| +| `id` | path | uuid | The credential id. | + +#### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `name` | string | yes | The new label. | + +### Delete a passkey + +`DELETE /auth/passkey/credentials/:id` + +Removes a stored passkey. Returns `204 No Content`. + +Auth: Session only (not available to API keys). + +| Parameter | In | Type | Description | +|-----------|----|------|-------------| +| `id` | path | uuid | The credential id. | + +## Account danger zone + +Delayed hard-delete of the caller's own account, with a confirmation phrase and a grace window. + +### Get account danger-zone status + +`GET /me/danger-zone` + +Returns the danger-zone summary plus any pending deletion. + +Auth: Session only (not available to API keys). + +#### Response + +```json +{ + "resource_type": "user", + "resource_id": "9c2a...", + "resource_name": "alex@acme.com", + "confirmation_hint": "alex@acme.com", + "grace_days": 14, + "pending_deletion": null +} +``` + +### Schedule account deletion + +`POST /me/danger-zone/delete` + +Schedules the account for a delayed hard delete. The confirmation must match the email. Returns `202 Accepted` with the scheduled-deletion record. + +Auth: Session only (not available to API keys). + +#### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `confirmation` | string | yes | Must equal the account email. | +| `reason` | string | no | Optional free-text reason. | + +```json +{ + "confirmation": "alex@acme.com", + "reason": "switching tools" +} +``` + +#### Response + +```json +{ + "resource_type": "user", + "resource_id": "9c2a...", + "requested_by_user_id": "9c2a...", + "scheduled_at": "2026-06-12T09:00:00Z", + "execute_after": "2026-06-26T09:00:00Z", + "grace_days": 14, + "status": "pending" +} +``` + +### Cancel account deletion + +`DELETE /me/danger-zone/delete` + +Cancels a pending account deletion. + +Auth: Session only (not available to API keys). + +#### Request body (optional) + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `reason` | string | no | Optional free-text reason. | + +#### Response + +```json +{ "message": "deletion cancelled" } +``` + +## Websocket bootstrap + +### Generate a websocket token + +`POST /getaway` + +Mints a single-session token for the realtime websocket and returns the connect URL plus its TTL in seconds. + +Auth: Session only (not available to API keys). + +#### Response + +```json +{ + "url": "wss://realtime.warmbly.com/socket?token=...", + "expires_in": 60 +} +``` + +## Organizations + +The organization is the workspace tenant. These endpoints handle creating and switching workspaces, the current workspace and its limits, members, custom roles, invitations, ownership transfer, avatar, and the workspace danger zone. All of `/organization/*` is session only. Mutations are gated by the caller's organization role, noted per endpoint. + +### Create an organization + +`POST /organization` + +Creates a new organization owned by the caller. Returns `201 Created`. + +Auth: Session only (not available to API keys). + +#### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `name` | string | yes | 1 to 255 characters. | + +```json +{ "name": "Acme Outbound" } +``` + +#### Response + +The created organization object. + +```json +{ + "id": "3d11...", + "name": "Acme Outbound", + "slug": null, + "avatar_url": null, + "owner_user_id": "9c2a...", + "presence_show_online": true, + "presence_show_activity": true, + "created_at": "2026-06-12T09:00:00Z", + "updated_at": "2026-06-12T09:00:00Z" +} +``` + +### List my organizations + +`GET /organization` + +Returns the organizations the caller is a member of. + +Auth: Session only (not available to API keys). + +#### Response + +```json +{ + "data": [ + { + "id": "ab12...", + "organization_id": "3d11...", + "user_id": "9c2a...", + "role": "owner", + "permissions": 524287, + "email": "alex@acme.com", + "name": "Alex Rivera", + "invited_at": "2026-06-12T09:00:00Z" + } + ] +} +``` + +### Switch organization + +`POST /organization/switch/:id` + +Sets the session's current organization. The caller must be a member. + +Auth: Session only (not available to API keys). + +| Parameter | In | Type | Description | +|-----------|----|------|-------------| +| `id` | path | uuid | The organization to switch to. | + +#### Response + +```json +{ + "message": "organization switched", + "organization_id": "3d11..." +} +``` + +### Get current organization + +`GET /organization/current` + +Returns the session's current organization with its resolved limits and live counts. + +Auth: Session only (not available to API keys). + +#### Response + +```json +{ + "id": "3d11...", + "name": "Acme Outbound", + "owner_user_id": "9c2a...", + "presence_show_online": true, + "presence_show_activity": true, + "created_at": "2026-06-12T09:00:00Z", + "updated_at": "2026-06-12T09:00:00Z", + "limits": { + "max_campaigns": 50, + "max_team_members": 10, + "max_email_accounts": 25, + "daily_campaign_limit": 500 + }, + "counts": { + "total_campaigns": 8, + "active_campaigns": 2, + "total_contacts": 1240, + "total_members": 3, + "email_accounts": 6, + "emails_sent_today": 180 + } +} +``` + +### Update current organization + +`PATCH /organization/current` + +Updates the current organization (name, slug, presence privacy toggles). A presence privacy change re-gates connected sockets live. + +Auth: Session only (not available to API keys). **Org permission** `manage_settings`. + +#### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `name` | string | no | New workspace name. | +| `slug` | string | no | New workspace slug. | +| `presence_show_online` | boolean | no | When false the realtime service tracks no member (nobody appears online). | +| `presence_show_activity` | boolean | no | When false online is still shown but viewing/editing detail is stripped. | + +```json +{ + "name": "Acme Outbound", + "presence_show_activity": false +} +``` + +#### Response + +The updated organization object. + +### Get organization limits + +`GET /organization/current/limits` + +Returns the current organization's plan-resolved limits and current usage counts. + +Auth: Session only (not available to API keys). Requires a selected organization. + +#### Response + +```json +{ + "limits": { + "max_campaigns": 50, + "max_team_members": 10, + "max_email_accounts": 25, + "daily_campaign_limit": 500 + }, + "counts": { + "total_campaigns": 8, + "active_campaigns": 2, + "total_contacts": 1240, + "total_members": 3, + "email_accounts": 6, + "emails_sent_today": 180 + } +} +``` + +### List members + +`GET /organization/members` + +Returns the current organization's members, each hydrated with the user's email, display name, role, and effective permission bitmask. + +Auth: Session only (not available to API keys). Requires a selected organization. + +#### Response + +```json +{ + "data": [ + { + "id": "ab12...", + "organization_id": "3d11...", + "user_id": "9c2a...", + "role": "owner", + "roles": [{ "id": "r1...", "name": "Owner", "color": "#0ea5e9" }], + "permissions": 524287, + "email": "alex@acme.com", + "name": "Alex Rivera", + "invited_at": "2026-06-12T09:00:00Z", + "accepted_at": "2026-06-12T09:05:00Z" + } + ] +} +``` + +### Invite a member + +`POST /organization/members/invite` + +Invites an email to the organization and emails an accept link. The invitee lands in the given role set. Returns `201 Created`. + +Auth: Session only (not available to API keys). **Org permission** `manage_team`. + +#### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `email` | string | yes | The invitee's email. | +| `role_ids` | uuid[] | no | The workspace roles to assign (at least one role overall). | +| `role_id` | uuid | no | Single-role shorthand, merged with `role_ids`. | + +```json +{ + "email": "sam@acme.com", + "role_ids": ["r2..."] +} +``` + +#### Response + +```json +{ + "message": "invitation sent", + "invitation": { + "id": "inv1...", + "organization_id": "3d11...", + "email": "sam@acme.com", + "role": "member", + "permissions": 12288, + "invited_by": "9c2a...", + "expires_at": "2026-06-19T09:00:00Z", + "created_at": "2026-06-12T09:00:00Z" + } +} +``` + +### Update a member's roles + +`PATCH /organization/members/:id` + +Replaces a member's assigned role set. Returns the updated member. + +Auth: Session only (not available to API keys). **Org permission** `manage_team`. + +| Parameter | In | Type | Description | +|-----------|----|------|-------------| +| `id` | path | uuid | The member's user id. | + +#### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `role_ids` | uuid[] | no | New assigned role set (at least one role overall). | +| `role_id` | uuid | no | Single-role shorthand, merged with `role_ids`. | + +```json +{ "role_ids": ["r2...", "r3..."] } +``` + +#### Response + +The updated member object (same shape as a list-members entry). + +### Remove a member + +`DELETE /organization/members/:id` + +Removes a member from the organization. + +Auth: Session only (not available to API keys). **Org permission** `manage_team`. + +| Parameter | In | Type | Description | +|-----------|----|------|-------------| +| `id` | path | uuid | The member's user id. | + +#### Response + +```json +{ "message": "member removed" } +``` + +### List custom roles + +`GET /organization/roles` + +Returns the organization's custom roles (named permission sets). Listing is open to every member so role chips render on the roster. + +Auth: Session only (not available to API keys). Requires a selected organization. + +#### Response + +```json +{ + "data": [ + { + "id": "r2...", + "organization_id": "3d11...", + "name": "Sales rep", + "description": "Run campaigns, view contacts", + "color": "#22c55e", + "permissions": 4352, + "member_count": 4, + "created_at": "2026-05-01T09:00:00Z", + "updated_at": "2026-06-01T09:00:00Z" + } + ] +} +``` + +### Create a custom role + +`POST /organization/roles` + +Creates a custom role. Returns `201 Created` with the role. + +Auth: Session only (not available to API keys). **Org permission** `manage_team`. + +#### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `name` | string | yes | Role name. | +| `description` | string | no | Role description. | +| `color` | string | no | Hex color for the chip. | +| `permissions` | int | no | Organization permission bitmask granted by the role. | + +```json +{ + "name": "Sales rep", + "description": "Run campaigns, view contacts", + "color": "#22c55e", + "permissions": 4352 +} +``` + +### Update a custom role + +`PATCH /organization/roles/:id` + +Edits a custom role. Edits propagate to every member assigned to it (permission readers stay JOIN-free). + +Auth: Session only (not available to API keys). **Org permission** `manage_team`. + +| Parameter | In | Type | Description | +|-----------|----|------|-------------| +| `id` | path | uuid | The role id. | + +#### Request body + +All fields optional; nil fields are left untouched. + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `name` | string | no | New name. | +| `description` | string | no | New description. | +| `color` | string | no | New chip color. | +| `permissions` | int | no | New permission bitmask. | + +### Delete a custom role + +`DELETE /organization/roles/:id` + +Removes a custom role. Returns `204 No Content`. + +Auth: Session only (not available to API keys). **Org permission** `manage_team`. + +| Parameter | In | Type | Description | +|-----------|----|------|-------------| +| `id` | path | uuid | The role id. | + +### List pending invitations + +`GET /organization/invitations` + +Returns the organization's outstanding invitations. + +Auth: Session only (not available to API keys). **Org permission** `manage_team`. + +#### Response + +```json +{ + "data": [ + { + "id": "inv1...", + "organization_id": "3d11...", + "email": "sam@acme.com", + "role": "member", + "permissions": 12288, + "invited_by": "9c2a...", + "expires_at": "2026-06-19T09:00:00Z", + "created_at": "2026-06-12T09:00:00Z" + } + ] +} +``` + +### Cancel an invitation + +`DELETE /organization/invitations/:id` + +Cancels a pending invitation. + +Auth: Session only (not available to API keys). **Org permission** `manage_team`. + +| Parameter | In | Type | Description | +|-----------|----|------|-------------| +| `id` | path | uuid | The invitation id. | + +#### Response + +```json +{ "message": "invitation cancelled" } +``` + +### Get an invitation link + +`GET /organization/invitations/:id/link` + +Returns the shareable accept token for a pending invitation so a team manager can copy a real accept link. + +Auth: Session only (not available to API keys). **Org permission** `manage_team`. + +| Parameter | In | Type | Description | +|-----------|----|------|-------------| +| `id` | path | uuid | The invitation id. | + +#### Response + +```json +{ "token": "inv_token_abc123" } +``` + +### Transfer ownership + +`POST /organization/transfer-ownership` + +Transfers organization ownership to another member. + +Auth: Session only (not available to API keys). **Org permission** `transfer_ownership`. + +#### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `new_owner_user_id` | uuid | yes | The member's user id to promote to owner. | + +```json +{ "new_owner_user_id": "7b88..." } +``` + +#### Response + +```json +{ "message": "ownership transferred" } +``` + +### Upload organization avatar + +`POST /organization/avatar` + +Uploads the workspace image. `multipart/form-data` with a single `file` field. Owner only. PNG or JPG, max 2 MB, max 1024x1024 px. + +Auth: Session only (not available to API keys). Requires a selected organization (owner only). + +#### Response + +```json +{ "avatar_url": "https://warmbly-assets.s3.amazonaws.com/avatars/organizations/3d11-1718193600.jpg" } +``` + +### Remove organization avatar + +`DELETE /organization/avatar` + +Clears the workspace image. Owner only. Returns `204 No Content`. + +Auth: Session only (not available to API keys). Requires a selected organization (owner only). + +### Get organization danger-zone status + +`GET /organization/current/danger-zone` + +Returns the workspace danger-zone summary plus any pending deletion. + +Auth: Session only (not available to API keys). Requires a selected organization. + +#### Response + +Same shape as the account danger zone, with `resource_type` of `organization` and the org name as the confirmation hint. + +### Schedule organization deletion + +`POST /organization/current/danger-zone/delete` + +Schedules the current organization for a delayed hard delete. Owner only; the confirmation must match the org name. Returns `202 Accepted` with the scheduled-deletion record. + +Auth: Session only (not available to API keys). Requires a selected organization (owner only). + +#### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `confirmation` | string | yes | Must equal the organization name. | +| `reason` | string | no | Optional free-text reason. | + +```json +{ "confirmation": "Acme Outbound" } +``` + +### Cancel organization deletion + +`DELETE /organization/current/danger-zone/delete` + +Cancels a pending organization deletion. + +Auth: Session only (not available to API keys). Requires a selected organization. + +#### Request body (optional) + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `reason` | string | no | Optional free-text reason. | + +#### Response + +```json +{ "message": "deletion cancelled" } +``` + +### Submit a limit-increase request + +`POST /organization/:orgId/limit-requests` + +Submits a request to raise one of the organization's limits. The current effective value is captured server-side at submission time. Returns `201 Created`. + +Auth: Session only (not available to API keys). + +| Parameter | In | Type | Description | +|-----------|----|------|-------------| +| `orgId` | path | uuid | The organization id. | + +#### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `field` | string | yes | A limit field (for example `max_email_accounts`, `daily_campaign_limit`). | +| `requested` | int | yes | The requested value, must be greater than the current effective limit. | +| `reason` | string | yes | 1 to 2000 characters. | + +```json +{ + "field": "max_email_accounts", + "requested": 50, + "reason": "Onboarding a new sales team next month." +} +``` + +#### Response + +```json +{ + "id": "lr1...", + "organization_id": "3d11...", + "field": "max_email_accounts", + "current_effective": 25, + "requested": 50, + "reason": "Onboarding a new sales team next month.", + "status": "pending", + "submitted_by": "9c2a...", + "submitted_at": "2026-06-12T09:00:00Z", + "review_notes": "" +} +``` + +### List limit requests + +`GET /organization/:orgId/limit-requests` + +Returns the organization's limit-increase requests. + +Auth: Session only (not available to API keys). + +| Parameter | In | Type | Description | +|-----------|----|------|-------------| +| `orgId` | path | uuid | The organization id. | + +#### Response + +```json +{ "data": [ { "id": "lr1...", "field": "max_email_accounts", "status": "pending", "requested": 50 } ] } +``` + +### Cancel a limit request + +`DELETE /limit-requests/:id` + +Cancels a pending limit request. Submitter only. Returns `204 No Content`. + +Auth: Session only (not available to API keys). + +| Parameter | In | Type | Description | +|-----------|----|------|-------------| +| `id` | path | uuid | The limit-request id. | + +## Invitations (for the invitee) + +These sit outside `/organization` because they act on the signed-in user's own pending invitations. + +### List my pending invitations + +`GET /invitations` + +Returns invitations addressed to the caller's email. + +Auth: Session only (not available to API keys). + +#### Response + +```json +{ + "data": [ + { + "id": "inv1...", + "organization_id": "3d11...", + "email": "alex@acme.com", + "role": "member", + "expires_at": "2026-06-19T09:00:00Z", + "created_at": "2026-06-12T09:00:00Z", + "organization": { "id": "3d11...", "name": "Acme Outbound" } + } + ] +} +``` + +### Accept an invitation + +`POST /invitations/accept` + +Accepts an invitation, either by the secret token (from a `/invite` link) or by the invitation id (from the caller's own pending list). Returns the new membership. + +Auth: Session only (not available to API keys). + +#### Request body + +Exactly one of `token` or `invitation_id` is required. + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `token` | string | conditional | The secret token from a `/invite` link. | +| `invitation_id` | uuid | conditional | The invitation id from the caller's pending list. | + +```json +{ "token": "inv_token_abc123" } +``` + +#### Response + +```json +{ + "message": "invitation accepted", + "member": { + "id": "ab13...", + "organization_id": "3d11...", + "user_id": "9c2a...", + "role": "member", + "permissions": 12288, + "email": "alex@acme.com", + "name": "Alex Rivera" + } +} +``` + +## Teams + +Teams group existing organization members under a named, color-tagged label (used for CRM ownership and routing). Unlike the rest of this group, the team endpoints accept either a JWT or an API key, and all require a selected organization. Reads map to the CRM read scope and writes to the CRM write scope. + +### List teams + +`GET /teams` + +Returns the organization's teams, each hydrated with its members. + +Auth: **Scope** `READ_CRM` · **Org permission** `view_contacts`. Requires a selected organization. + +#### Response + +```json +{ + "data": [ + { + "id": "t1...", + "organization_id": "3d11...", + "name": "West coast", + "color": "#0ea5e9", + "members": [ + { "user_id": "9c2a...", "email": "alex@acme.com", "name": "Alex Rivera", "added_at": "2026-06-01T09:00:00Z" } + ], + "created_at": "2026-05-01T09:00:00Z", + "updated_at": "2026-06-01T09:00:00Z" + } + ] +} +``` + +### Create a team + +`POST /teams` + +Creates a team. Returns `201 Created` with the team. + +Auth: **Scope** `WRITE_CRM` · **Org permission** `manage_team`. Requires a selected organization. + +#### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `name` | string | yes | 1 to 255 characters. | +| `color` | string | no | Hex color (defaults to `#94a3b8`). | + +```json +{ "name": "West coast", "color": "#0ea5e9" } +``` + +#### Response + +The created team object (members starts empty). + +### Get a team + +`GET /teams/:id` + +Returns a single team with its members. + +Auth: **Scope** `READ_CRM` · **Org permission** `view_contacts`. Requires a selected organization. + +| Parameter | In | Type | Description | +|-----------|----|------|-------------| +| `id` | path | uuid | The team id. | + +### Update a team + +`PATCH /teams/:id` + +Partial-updates a team's name or color. Nil fields are left untouched. + +Auth: **Scope** `WRITE_CRM` · **Org permission** `manage_team`. Requires a selected organization. + +| Parameter | In | Type | Description | +|-----------|----|------|-------------| +| `id` | path | uuid | The team id. | + +#### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `name` | string | no | New name. | +| `color` | string | no | New color. | + +#### Response + +The updated team object. + +### Delete a team + +`DELETE /teams/:id` + +Deletes a team. Returns `204 No Content`. + +Auth: **Scope** `WRITE_CRM` · **Org permission** `manage_team`. Requires a selected organization. + +| Parameter | In | Type | Description | +|-----------|----|------|-------------| +| `id` | path | uuid | The team id. | + +### Add a team member + +`POST /teams/:id/members` + +Adds an existing organization member to the team. Returns the updated team. + +Auth: **Scope** `WRITE_CRM` · **Org permission** `manage_team`. Requires a selected organization. + +| Parameter | In | Type | Description | +|-----------|----|------|-------------| +| `id` | path | uuid | The team id. | + +#### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `user_id` | uuid | yes | The member's user id (must already belong to the organization). | + +```json +{ "user_id": "7b88..." } +``` + +#### Response + +The updated team object, including the new member. + +### Remove a team member + +`DELETE /teams/:id/members/:userId` + +Removes a member from the team. Returns `204 No Content`. + +Auth: **Scope** `WRITE_CRM` · **Org permission** `manage_team`. Requires a selected organization. + +| Parameter | In | Type | Description | +|-----------|----|------|-------------| +| `id` | path | uuid | The team id. | +| `userId` | path | uuid | The member's user id. | + +## Subscription and billing + +The subscription is per-organization. These endpoints read the current subscription, trial, feature access, manage Stripe checkout and the billing portal, change plans, validate discount codes, and submit enterprise inquiries. All of `/subscription/*` is session only. + +### Get subscription + +`GET /subscription` + +Returns the current organization's subscription. + +Auth: Session only (not available to API keys). Requires a selected organization. + +#### Response + +```json +{ + "id": "sub1...", + "user_id": "9c2a...", + "organization_id": "3d11...", + "plan_id": "plan_pro...", + "stripe_customer_id": "cus_...", + "stripe_subscription_id": "sub_...", + "status": "active", + "current_period_start": "2026-06-01T00:00:00Z", + "current_period_end": "2026-07-01T00:00:00Z", + "cancel_at_period_end": false, + "is_enterprise": false, + "plan": { "id": "plan_pro...", "name": "Pro", "price": 99, "duration": "month" }, + "created_at": "2026-04-20T12:00:00Z", + "updated_at": "2026-06-01T00:00:00Z" +} +``` + +### Get subscription with limits + +`GET /subscription/limits` + +Returns the subscription plus the plan's rate limits. + +Auth: Session only (not available to API keys). Requires a selected organization. + +#### Response + +The subscription object as above, extended with the plan's limit fields. + +### Get trial status + +`GET /subscription/trial` + +Returns the organization's free-trial status. + +Auth: Session only (not available to API keys). Requires a selected organization. + +#### Response + +The trial status object (whether a trial is active, when it ends, whether it has been used). + +### Get feature status + +`GET /subscription/features` + +Returns the organization's feature-access status plus convenience flags for the major gated features. + +Auth: Session only (not available to API keys). Requires a selected organization. + +#### Response + +```json +{ + "subscription": { "status": "active", "plan": "Pro" }, + "can_send_campaigns": true, + "can_use_warmup": true, + "can_use_unibox": true +} +``` + +### Create a checkout session + +`POST /subscription/checkout` + +Creates a Stripe checkout session for a price and returns the redirect URL. + +Auth: Session only (not available to API keys). Requires a selected organization. + +#### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `price_id` | string | yes | The Stripe price id to subscribe to. | +| `success_url` | string | yes | Redirect target after successful checkout. | +| `cancel_url` | string | yes | Redirect target if the user cancels. | +| `discount_code` | string | no | A discount code to apply. | + +```json +{ + "price_id": "price_123", + "success_url": "https://app.warmbly.com/billing?status=success", + "cancel_url": "https://app.warmbly.com/billing?status=cancel" +} +``` + +#### Response + +```json +{ + "session_id": "cs_test_...", + "checkout_url": "https://checkout.stripe.com/c/pay/cs_test_..." +} +``` + +### Validate a discount code + +`POST /subscription/discount/validate` + +Previews whether a discount code is valid for the current organization (optionally against a target plan), returning the discount details for display. + +Auth: Session only (not available to API keys). Requires a selected organization. + +#### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `code` | string | yes | The discount code. | +| `plan_id` | uuid | no | A target plan to compute the discounted amount against. | + +```json +{ "code": "LAUNCH20", "plan_id": "plan_pro..." } +``` + +#### Response + +```json +{ + "valid": true, + "code": "LAUNCH20", + "type": "percent", + "percent_off": 20, + "duration": "once" +} +``` + +When invalid, `valid` is `false` and `reason` explains why. + +### Create a billing portal session + +`POST /subscription/portal` + +Creates a Stripe billing portal session for the organization's customer and returns the portal URL. + +Auth: Session only (not available to API keys). Requires a selected organization. + +#### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `return_url` | string | yes | Where Stripe returns the user after the portal. | + +```json +{ "return_url": "https://app.warmbly.com/billing" } +``` + +#### Response + +```json +{ "portal_url": "https://billing.stripe.com/p/session/..." } +``` + +### Cancel subscription + +`POST /subscription/cancel` + +Cancels the organization's subscription, either at period end or immediately. + +Auth: Session only (not available to API keys). Requires a selected organization. + +#### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `cancel_at_period_end` | boolean | no | When true, cancel at the end of the current period instead of immediately. | + +```json +{ "cancel_at_period_end": true } +``` + +#### Response + +```json +{ "message": "subscription cancelled" } +``` + +### Change plan + +`POST /subscription/change-plan` + +Changes the organization's plan with proration. + +Auth: Session only (not available to API keys). **Org permission** `manage_billing`. Requires a selected organization. + +#### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `plan_id` | uuid | yes | The target plan id. | +| `proration_behavior` | string | no | One of `create_prorations`, `always_invoice`, `none`. | +| `discount_code` | string | no | A discount code to apply. | +| `interval` | string | no | `month` or `year` (defaults to monthly). | + +```json +{ + "plan_id": "plan_scale...", + "proration_behavior": "create_prorations", + "interval": "year" +} +``` + +#### Response + +```json +{ + "message": "plan changed successfully", + "subscription": { "id": "sub1...", "plan_id": "plan_scale...", "status": "active" } +} +``` + +### Preview a plan change + +`GET /subscription/preview-change` + +Previews the proration for a plan change without applying it. + +Auth: Session only (not available to API keys). **Org permission** `manage_billing`. Requires a selected organization. + +| Parameter | In | Type | Description | +|-----------|----|------|-------------| +| `new_plan_id` | query | uuid | The target plan id. | + +#### Response + +The proration preview object (line items, immediate charge, and next invoice amount). + +### Submit an enterprise inquiry + +`POST /subscription/enterprise-inquiry` + +Submits an enterprise pricing inquiry. Returns a confirmation plus the new inquiry id. + +Auth: Session only (not available to API keys). + +#### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `company_name` | string | yes | Company name. | +| `contact_name` | string | yes | Contact name. | +| `contact_email` | string | yes | Contact email. | +| `estimated_volume` | int | no | Estimated monthly email volume. | +| `team_size` | int | no | Team size. | +| `notes` | string | no | Free-text notes. | + +```json +{ + "company_name": "Acme Inc", + "contact_name": "Alex Rivera", + "contact_email": "alex@acme.com", + "estimated_volume": 50000, + "team_size": 25 +} +``` + +#### Response + +```json +{ + "message": "Thank you! Our team will contact you within 24 hours.", + "inquiry_id": "eiq1..." +} +``` + +## Reference data + +Two read-only reference endpoints sit alongside billing. Unlike the rest of this group they accept any authenticated key (JWT or API key); auth only exists to keep them from being scraped. + +### List plans + +`GET /plans` + +Returns the available public subscription plans. + +Auth: any authenticated caller (JWT or API key). + +#### Response + +```json +{ + "plans": [ + { "id": "plan_pro...", "name": "Pro", "price": 99, "duration": "month", "public": true } + ] +} +``` + +### List timezones + +`GET /timezones` + +Returns the supported timezone identifiers (for campaign schedule windows and the like). + +Auth: any authenticated caller (JWT or API key). + +## See also + +- [Authentication](/api/authentication/) +- [Permissions reference](/api/permissions/) +- [Error codes](/api/error-codes/) diff --git a/docs/content/docs/api/api-keys.mdx b/docs/content/docs/api/api-keys.mdx new file mode 100644 index 00000000..02208155 --- /dev/null +++ b/docs/content/docs/api/api-keys.mdx @@ -0,0 +1,391 @@ +--- +title: API keys +description: Create, inspect, rotate, and revoke programmatic API keys and read their usage analytics. +icon: KeyRound +--- + +API keys are how integrations authenticate to the Warmbly API. Each key belongs to an organization, carries a permission bitmask that scopes what it can do, and can be restricted to specific source IPs or specific mailboxes. This group is self-service: a key that holds the `API_KEYS` scope can manage its own organization's keys without going through the dashboard, so an integration can rotate its credentials programmatically. + +Every endpoint in this group requires both **Scope** `API_KEYS` (for API-key callers) and **Org permission** `manage_api_keys` (for session/JWT callers). All routes are organization-scoped and write rate-limited. + +## The permission bitmask + +A key's `permissions` field is a `uint64` bitmask. Each grant is a single bit, and a key is allowed to perform a request only when its mask contains every bit the route requires. Combine bits with bitwise OR. The full list of bit names and values, along with the `read_only` and `full_access` presets, is available from the [permissions endpoint](#list-available-permissions) below and documented in [API permissions](/api/permissions/). Unknown bits are rejected on create so a stale client cannot accidentally grant a future scope. + +## The plaintext secret is shown once + +When you create a key, the response includes a `secret` field containing the full plaintext key. This is the only time the secret is ever returned. Warmbly stores only a hash plus a short prefix and suffix for display, so the plaintext cannot be recovered later. Capture it at creation time and store it securely. If it is lost, revoke the key and create a new one. See [Authentication](/api/authentication/) for how to present the key on requests. + +## List API keys + +`GET /api-keys` + +Returns the organization's API keys, newest first, with the secret never included. + +Auth: **Scope** `API_KEYS` · **Org permission** `manage_api_keys` + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `cursor` | query | string | Opaque cursor from the previous page's `pagination.next_cursor`. Omit for the first page. | +| `limit` | query | integer | Page size, 1 to 100. Defaults to 50. Out-of-range or invalid values fall back to the default. | + +### Response + +A `data` plus `pagination` envelope. Each item is an API key without its secret. + +```json +{ + "data": [ + { + "id": "0b2d5e7a-1c3f-4a9b-8c2d-1e2f3a4b5c6d", + "user_id": "9f8e7d6c-5b4a-3210-fedc-ba9876543210", + "organization_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "name": "Zapier production", + "description": "Lead sync from the marketing site", + "key_prefix": "wk_live_", + "key_suffix": "9f3a", + "permissions": 8447, + "allowed_ips": ["203.0.113.10"], + "allowed_email_accounts": [], + "rate_limit_per_minute": 120, + "status": "active", + "last_used_at": "2026-06-11T18:42:10Z", + "last_request_ip": "203.0.113.10", + "expires_at": null, + "created_at": "2026-05-01T09:00:00Z", + "updated_at": "2026-06-11T18:42:10Z" + } + ], + "pagination": { + "total": null, + "next_cursor": "c1_b3BhcXVlLWN1cnNvcg", + "has_more": true + } +} +``` + +## Create an API key + +`POST /api-keys` + +Creates a new key and returns the plaintext secret exactly once (see the note above). + +Auth: **Scope** `API_KEYS` · **Org permission** `manage_api_keys` + +### Request body + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `name` | string | yes | Human-readable label, up to 255 characters. | +| `description` | string | no | Free-form note about the key's purpose. | +| `permissions` | integer (uint64) | yes | The permission bitmask. Must contain only defined bits; unknown bits are rejected. | +| `allowed_ips` | string array | no | If set, the key is usable only from these source IPs. Omit or leave empty to allow any IP. | +| `allowed_email_accounts` | uuid array | no | If set, mailbox-scoped routes accept only these email account ids. | +| `rate_limit_per_minute` | integer | no | Per-key sliding-window request cap. Omit or send `0` to use the default (60 r/m). | +| `expires_at` | string (RFC3339) | no | When the key should stop working. Omit for a non-expiring key. | + +```json +{ + "name": "Zapier production", + "description": "Lead sync from the marketing site", + "permissions": 8447, + "allowed_ips": ["203.0.113.10"], + "rate_limit_per_minute": 120, + "expires_at": "2027-01-01T00:00:00Z" +} +``` + +### Response + +`201 Created`. The full key object plus the one-time `secret`. Everything except `secret` matches the shape returned by the list and get endpoints. + +```json +{ + "id": "0b2d5e7a-1c3f-4a9b-8c2d-1e2f3a4b5c6d", + "user_id": "9f8e7d6c-5b4a-3210-fedc-ba9876543210", + "organization_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "name": "Zapier production", + "description": "Lead sync from the marketing site", + "key_prefix": "wk_live_", + "key_suffix": "9f3a", + "permissions": 8447, + "allowed_ips": ["203.0.113.10"], + "rate_limit_per_minute": 120, + "status": "active", + "expires_at": "2027-01-01T00:00:00Z", + "created_at": "2026-06-11T19:00:00Z", + "updated_at": "2026-06-11T19:00:00Z", + "secret": "wk_live_3f9a...the-only-time-you-see-this...9f3a" +} +``` + +This endpoint mutates state and supports [`Idempotency-Key`](/api/authentication/) for safe retries. + +## List available permissions + +`GET /api-keys/permissions` + +Returns the catalog of permission bits and the built-in presets, so a client can render a picker or grant a sane default without hard-coding values. + +Auth: **Scope** `API_KEYS` · **Org permission** `manage_api_keys` + +### Response + +An object with a `permissions` array (each entry carries its `name`, numeric `value`, `description`, and `category` of `read`, `write`, `bulk`, or `special`) and a `presets` object with the `read_only` and `full_access` masks. + +```json +{ + "permissions": [ + { + "name": "READ_EMAILS", + "value": 1, + "description": "View email accounts and settings", + "category": "read" + }, + { + "name": "WRITE_CAMPAIGNS", + "value": 64, + "description": "Create and modify campaigns and sequences", + "category": "write" + } + // ... one entry per defined permission bit + ], + "presets": { + "read_only": 4329731, + "full_access": 8388607 + } +} +``` + +## Get an API key + +`GET /api-keys/:id` + +Returns a single key by id. The secret is never included. + +Auth: **Scope** `API_KEYS` · **Org permission** `manage_api_keys` + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `id` | path | uuid | The API key id. | + +### Response + +The key object, identical in shape to one element of the list `data` array. + +```json +{ + "id": "0b2d5e7a-1c3f-4a9b-8c2d-1e2f3a4b5c6d", + "user_id": "9f8e7d6c-5b4a-3210-fedc-ba9876543210", + "organization_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "name": "Zapier production", + "description": "Lead sync from the marketing site", + "key_prefix": "wk_live_", + "key_suffix": "9f3a", + "permissions": 8447, + "allowed_ips": ["203.0.113.10"], + "rate_limit_per_minute": 120, + "status": "active", + "last_used_at": "2026-06-11T18:42:10Z", + "last_request_ip": "203.0.113.10", + "expires_at": null, + "created_at": "2026-05-01T09:00:00Z", + "updated_at": "2026-06-11T18:42:10Z" +} +``` + +## Update an API key + +`PATCH /api-keys/:id` + +Updates the mutable fields of a key. Every field is optional; only the fields you send are changed. You cannot rotate the secret here (create a new key and revoke the old one instead). + +Auth: **Scope** `API_KEYS` · **Org permission** `manage_api_keys` + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `id` | path | uuid | The API key id. | + +### Request body + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `name` | string | no | New label. | +| `description` | string | no | New description. | +| `permissions` | integer (uint64) | no | Replacement permission bitmask. | +| `allowed_ips` | string array | no | Replacement IP allowlist. | +| `allowed_email_accounts` | uuid array | no | Replacement mailbox allowlist. | +| `rate_limit_per_minute` | integer | no | New per-key rate cap (`0` means use the default). | + +```json +{ + "description": "Lead sync, now read-only", + "permissions": 4329731, + "rate_limit_per_minute": 60 +} +``` + +### Response + +The updated key object, same shape as get. + +```json +{ + "id": "0b2d5e7a-1c3f-4a9b-8c2d-1e2f3a4b5c6d", + "name": "Zapier production", + "description": "Lead sync, now read-only", + "permissions": 4329731, + "rate_limit_per_minute": 60, + "status": "active", + "updated_at": "2026-06-11T19:30:00Z" + // ... remaining key fields unchanged +} +``` + +## Revoke an API key + +`DELETE /api-keys/:id` + +Revokes a key immediately. The key stops authenticating right away; this is not reversible. + +Auth: **Scope** `API_KEYS` · **Org permission** `manage_api_keys` + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `id` | path | uuid | The API key id. | +| `reason` | query | string | Optional revocation note stored on the key. Defaults to `Revoked by user`. | + +### Response + +A small status envelope. + +```json +{ "status": "revoked" } +``` + +## Usage summary + +`GET /api-keys/usage/summary` + +Returns the organization-level usage strip: key counts by status plus a 24-hour request, error, and latency rollup. + +Auth: **Scope** `API_KEYS` · **Org permission** `manage_api_keys` + +### Response + +A single summary object. Counts under the `24h` fields cover the last 24 hours. + +```json +{ + "active_keys": 3, + "revoked_keys": 1, + "expired_keys": 0, + "requests_24h": 14820, + "errors_24h": 37, + "avg_latency_ms_24h": 42.6, + "last_call_at": "2026-06-11T18:42:10Z" +} +``` + +## Usage analytics + +`GET /api-keys/usage/analytics` +`GET /api-keys/:id/analytics` + +Returns a time-bucketed request series plus a per-endpoint breakdown. Both routes share one handler: the org-wide form lives at `/api-keys/usage/analytics`, and the per-key form is `/api-keys/:id/analytics`. You can also pass the literal `:id` value `all` on the per-key route to get the org-wide aggregate. + +Auth: **Scope** `API_KEYS` · **Org permission** `manage_api_keys` + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `id` | path | uuid | The API key id, or the literal `all` for the org-wide aggregate (per-key route only). | +| `from` | query | string (RFC3339) | Start of the window. Defaults to 24 hours before `to`. | +| `to` | query | string (RFC3339) | End of the window. Defaults to now. | +| `interval` | query | string | Bucket granularity: `minute`, `hour`, or `day`. | + +### Response + +An analytics object: `buckets` is the graph series, `endpoints` is the top-endpoints table, and `total` / `errors` are the window totals. For the org-wide aggregate, `api_key_id` is the all-zero UUID. + +```json +{ + "api_key_id": "0b2d5e7a-1c3f-4a9b-8c2d-1e2f3a4b5c6d", + "from": "2026-06-10T19:00:00Z", + "to": "2026-06-11T19:00:00Z", + "interval": "hour", + "buckets": [ + { + "bucket": "2026-06-11T18:00:00Z", + "total": 612, + "success": 605, + "client_errors": 6, + "server_errors": 1, + "avg_latency_ms": 41.2 + } + // ... one bucket per interval + ], + "endpoints": [ + { + "endpoint": "/api/v1/contacts", + "method": "POST", + "count": 980, + "error_count": 4, + "avg_latency_ms": 55.1 + } + ], + "total": 14820, + "errors": 37 +} +``` + +## List per-key usage logs + +`GET /api-keys/:id/logs` + +Returns the recent raw request entries for a single key, newest first. Useful for debugging which requests a key made and how they responded. + +Auth: **Scope** `API_KEYS` · **Org permission** `manage_api_keys` + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `id` | path | uuid | The API key id. | +| `cursor` | query | string | Opaque cursor from the previous page's `pagination.next_cursor`. | +| `limit` | query | integer | Page size, 1 to 200. Defaults to 50. | + +### Response + +A `data` plus `pagination` envelope. Each entry is one recorded request. + +```json +{ + "data": [ + { + "id": "7c1f9a2b-3d4e-5f60-7a8b-9c0d1e2f3a4b", + "api_key_id": "0b2d5e7a-1c3f-4a9b-8c2d-1e2f3a4b5c6d", + "endpoint": "/api/v1/contacts", + "method": "POST", + "ip_address": "203.0.113.10", + "user_agent": "warmbly-zapier/1.4", + "response_code": 201, + "response_time_ms": 48, + "created_at": "2026-06-11T18:42:10Z" + } + ], + "pagination": { + "total": null, + "next_cursor": "c1_b3BhcXVlLWN1cnNvcg", + "has_more": true + } +} +``` + +## Errors + +All endpoints use the shared error envelope with stable `code` and `request_id` fields. Common cases for this group: + +- `400` when no organization is selected, the request body is invalid, or a permission bitmask contains unknown bits. +- `401` when the caller is unauthenticated. +- `403` when the caller lacks the `API_KEYS` scope or the `manage_api_keys` org permission. +- `404` when the `:id` path value is not a valid UUID or the key does not belong to the organization. + +See [Error codes](/api/error-codes/) for the full list. diff --git a/docs/content/docs/api/authentication.mdx b/docs/content/docs/api/authentication.mdx index 568fc849..a8ae725e 100644 --- a/docs/content/docs/api/authentication.mdx +++ b/docs/content/docs/api/authentication.mdx @@ -23,13 +23,17 @@ Keys are stored as a SHA-256 hash; the plaintext is shown exactly once on creati Include your API key in the `Authorization` header of every request: ```bash -curl -X GET "https://api.warmbly.com/api-keys" \ +curl -X GET "https://api.warmbly.com/v1/api-keys" \ -H "Authorization: Bearer wmbly_abc123..." \ -H "Content-Type: application/json" ``` For mutation retries, include an `Idempotency-Key` header with a unique value per logical operation. Warmbly stores completed mutation responses for 24 hours per organization and key, then replays matching retries instead of performing the operation again. +## OAuth access tokens + +API keys authenticate your own scripts. If you are building an app that other people connect their Warmbly workspace to, use OAuth instead: the user grants your app scoped access and you receive a bearer **access token** (prefix `wmat_`). It goes in the same `Authorization: Bearer` header and is checked against the same permissions, so every endpoint below behaves identically whether you present an API key or an OAuth token. The difference is only how the credential is obtained. See [OAuth](/api/oauth/) for the full flow. + ## Key security best practices diff --git a/docs/content/docs/api/endpoints.mdx b/docs/content/docs/api/endpoints.mdx index 15c07459..7a3cfe38 100644 --- a/docs/content/docs/api/endpoints.mdx +++ b/docs/content/docs/api/endpoints.mdx @@ -12,6 +12,8 @@ This page is the source of truth for what an API key can and cannot reach. Every When an endpoint says "JWT permission: X / API permission: Y", the dual-auth middleware checks the relevant one based on which credential the caller used. +All paths below are relative to the versioned base URL `https://api.warmbly.com/v1` (for example `/campaigns` is `https://api.warmbly.com/v1/campaigns`). See [versioning](/api/) for details. + ## API key accepted ### Emails @@ -46,7 +48,7 @@ When an endpoint says "JWT permission: X / API permission: Y", the dual-auth mid | POST | `/campaigns/:id/start` | `SEND_CAMPAIGNS` | | POST | `/campaigns/:id/stop` | `SEND_CAMPAIGNS` | | GET | `/campaigns/:id/logs` | `READ_CAMPAIGNS` | -| GET/POST/PATCH/DELETE | `/campaigns/:id/sequences[/:sid]` | `READ_CAMPAIGNS` for GET, `WRITE_CAMPAIGNS` otherwise | +| GET/POST/PATCH/DELETE | `/campaigns/:id/steps[/:sid]` | `READ_CAMPAIGNS` for GET, `WRITE_CAMPAIGNS` otherwise | ### Contacts @@ -107,6 +109,19 @@ When an endpoint says "JWT permission: X / API permission: Y", the dual-auth mid | PATCH | `/api-keys/:id` | `API_KEYS` | | DELETE | `/api-keys/:id` | `API_KEYS` | +### OAuth apps + +Registering and managing the OAuth apps your workspace owns. The flow itself (authorize, token, revoke) is listed under JWT only and Public below. + +| Method | Path | API Permission | +|--------|------|----------------| +| GET | `/oauth/applications` | `API_KEYS` | +| POST | `/oauth/applications` | `API_KEYS` | +| GET | `/oauth/applications/:id` | `API_KEYS` | +| PATCH | `/oauth/applications/:id` | `API_KEYS` | +| DELETE | `/oauth/applications/:id` | `API_KEYS` | +| POST | `/oauth/applications/:id/rotate-secret` | `API_KEYS` | + ### Operations | Method | Path | API Permission | @@ -140,6 +155,8 @@ These never accept an API key. They depend on a human-bound session: billing flo - `POST /auth/logout`, `POST /auth/logout-all`, `GET /auth/me`, `PATCH /auth/me/onboarding` - `POST /auth/me/avatar`, `DELETE /auth/me/avatar` - `POST /emails/onboarding/oauth/start`, `POST /emails/onboarding/oauth/finish`, `POST /emails/onboarding/smtp-imap` +- `GET /oauth/authorize/details`, `POST /oauth/authorize` (the consent flow: a human approves a third-party app) +- `GET /oauth/authorized-apps`, `DELETE /oauth/authorized-apps/:id` (apps the user has authorized) - `POST /getaway` (websocket bootstrap) - `GET /realtime/info` - `GET /me/danger-zone`, `POST /me/danger-zone/delete`, `DELETE /me/danger-zone/delete` @@ -155,6 +172,8 @@ These never accept an API key. They depend on a human-bound session: billing flo - `POST /webhook/stripe` (Stripe signature) - `POST /webhook/campaign`, `/webhook/email`, `/webhook/user-email` (Google OIDC token from Cloud Tasks) - `GET /addresses/google/callback`, `GET /addresses/outlook/callback` (OAuth bouncer pages) +- `POST /oauth/token`, `POST /oauth/revoke` (OAuth token endpoints, authenticated by the client's id and secret) +- `GET /.well-known/oauth-authorization-server` (OAuth discovery metadata) ## Notes diff --git a/docs/content/docs/api/index.mdx b/docs/content/docs/api/index.mdx index 5c7bfadd..deb84992 100644 --- a/docs/content/docs/api/index.mdx +++ b/docs/content/docs/api/index.mdx @@ -7,7 +7,7 @@ icon: Rocket The Warmbly API lets you drive everything you can do in the dashboard programmatically: mailboxes, campaigns, contacts, the unibox, and more. It is a JSON API over HTTPS. ``` -https://api.warmbly.com +https://api.warmbly.com/v1 ``` ## Quickstart @@ -15,19 +15,27 @@ https://api.warmbly.com Create an API key in your dashboard under Settings, then pass it as a Bearer token on every request: ```bash -curl -X GET "https://api.warmbly.com/campaigns" \ +curl -X GET "https://api.warmbly.com/v1/campaigns" \ -H "Authorization: Bearer wmbly_your_api_key_here" \ -H "Content-Type: application/json" ``` -See [Authentication](/api/authentication) for the key format, scoping, and security guidance. +See [Authentication](/api/authentication/) for the key format, scoping, and security guidance. + +## Versioning + +The API is versioned in the URL. The current version is `v1`, and the base URL includes it: every endpoint path in these docs is relative to `https://api.warmbly.com/v1`. + +- **Stability**: within a version, changes are additive (new fields, new endpoints). A breaking change ships as a new version, so code written against `v1` keeps working. +- **Version header**: every response carries an `API-Version` header so you can confirm which version answered. +- **No unversioned alias**: every endpoint lives under `/v1`; there are no bare, unversioned paths. A future breaking change will ship as `/v2` while `/v1` keeps working. ## Conventions -- **Permissions**: every key carries an explicit permission set; a request beyond the key's permissions returns `403`. The full list is in the [permissions reference](/api/permissions). +- **Permissions**: every key carries an explicit permission set; a request beyond the key's permissions returns `403`. The full list is in the [permissions reference](/api/permissions/). - **List responses**: list endpoints return a consistent `data` plus `pagination` shape with opaque cursors. - **Errors**: error responses carry a machine-readable `code` and a `request_id` alongside the human-readable text. Client logic should branch on `code` and HTTP status, never on message text. See [error codes](/api/error-codes). -- **Endpoint coverage**: the [endpoint scope map](/api/endpoints) lists every route, whether it accepts API keys, and which permission it requires. +- **Endpoint coverage**: the [endpoint scope map](/api/endpoints) lists every route, whether it accepts API keys, and which permission it requires. The [endpoint reference](/api/reference/mailboxes/) documents each route's request and response structures, grouped by resource. ```json { diff --git a/docs/content/docs/api/mailboxes.mdx b/docs/content/docs/api/mailboxes.mdx new file mode 100644 index 00000000..9e7dd1ba --- /dev/null +++ b/docs/content/docs/api/mailboxes.mdx @@ -0,0 +1,558 @@ +--- +title: Mailboxes +description: Connect, configure, warm up, verify, and send from sender mailboxes (email accounts). +icon: Mail +--- + +Mailboxes are the sender accounts Warmbly sends campaign and warmup mail from. These endpoints live under `/emails` and let you list and inspect connected mailboxes, update their sending and warmup settings, point a custom tracking domain at a mailbox, drive the warmup lifecycle, check authentication and ban status, verify addresses before sending, and send a one-off message from a specific mailbox. + +Most read routes require the **Read emails** scope and write routes require the **Write emails** scope. The mailbox connection (onboarding) routes are session only because they write user-encrypted refresh tokens through the SPA popup flow, and the send route requires the **Send campaigns** scope because it transmits real mail. When an API key is scoped to specific mailboxes, every `/:id` route is additionally gated to keys allowed to act on that mailbox. + +## List mailboxes + +`GET /emails` + +Returns the organization's connected mailboxes, newest first, with cursor pagination. + +Auth: **Scope** `READ_EMAILS` · **Org permission** `view_campaigns` + +| Parameter | In | Type | Description | +|-----------|----|------|-------------| +| `q` | query | string | Optional free-text search over mailbox address and name. | +| `tag` | query | string (UUID) | Optional tag id to filter by. Must be a valid UUID. | +| `cursor` | query | string (UUID) | Opaque cursor from a previous `pagination.next_cursor`. | +| `limit` | query | integer | Page size. Defaults to `50`. Invalid limits return `400`. | + +### Response + +A `data` array of mailbox objects plus a `pagination` envelope. + +```json +{ + "data": [ + { + "id": "0c0f1a2b-3c4d-5e6f-7a8b-9c0d1e2f3a4b", + "user_id": "a1b2c3d4-...", + "organization_id": "f9e8d7c6-...", + "worker_id": "7b6a5c4d-...", + "email": "sales@acme.com", + "name": "Acme Sales", + "signature_plain": "", + "signature_html": "", + "signature_sync": false, + "signature_code": false, + "provider": "gmail", + "status": "active", + "last_synced_at": "2026-06-11T09:14:00Z", + "last_id": 184213, + "campaign_limit": 50, + "min_wait_time": 600, + "reply_to": "", + "tracking_domain": "t.acme.com", + "tracking_domain_verified": true, + "tracking_domain_verified_at": "2026-06-01T12:00:00Z", + "warmup": "2026-05-20T00:00:00Z", + "warmup_paused_at": null, + "warmup_base": 10, + "warmup_max": 40, + "warmup_increase": 1, + "warmup_reply_rate": 30, + "warmup_tag": "", + "warmup_pool_type": "premium", + "warmup_start_time": "09:00", + "warmup_end_time": "17:00", + "warmup_days": 5, + "timezone": "America/New_York", + "tags": ["outbound"], + "created_at": "2026-05-19T18:00:00Z", + "updated_at": "2026-06-11T09:14:00Z" + } + ], + "pagination": { + "total": 12, + "next_cursor": "c1_b3BhcXVlLWN1cnNvcg", + "has_more": true + } +} +``` + +`provider` is one of `gmail`, `outlook`, or `smtp_imap`. `status` is one of `active`, `inactive`, or `revoked`. `warmup` is the warmup anchor timestamp (null when warmup has never been enabled); a non-null `warmup_paused_at` means warmup is enabled but paused. `total` and `next_cursor` may be null when not applicable. + +## Get a mailbox + +`GET /emails/:id` + +Returns a single mailbox by id. + +Auth: **Scope** `READ_EMAILS` · **Org permission** `view_campaigns` + +| Parameter | In | Type | Description | +|-----------|----|------|-------------| +| `id` | path | string (UUID) | The mailbox (email account) id. | + +### Response + +A bare mailbox object, same shape as one element of the list `data` array. + +```json +{ + "id": "0c0f1a2b-3c4d-5e6f-7a8b-9c0d1e2f3a4b", + "email": "sales@acme.com", + "name": "Acme Sales", + "provider": "gmail", + "status": "active", + "campaign_limit": 50, + "min_wait_time": 600, + "warmup": "2026-05-20T00:00:00Z", + "warmup_paused_at": null, + "warmup_pool_type": "premium", + "tracking_domain": "t.acme.com", + "tracking_domain_verified": true, + "tags": ["outbound"], + "created_at": "2026-05-19T18:00:00Z", + "updated_at": "2026-06-11T09:14:00Z" +} +``` + +## Update a mailbox + +`PATCH /emails/:id` + +Updates mailbox settings: display name, signature, status, sending caps, reply-to, warmup configuration, and tags. All fields are optional; only present fields are applied. + +Auth: **Scope** `WRITE_EMAILS` · **Org permission** `manage_emails` + +| Parameter | In | Type | Description | +|-----------|----|------|-------------| +| `id` | path | string (UUID) | The mailbox id. | + +### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `name` | string | no | Display name on outgoing mail. | +| `signature_plain` | string | no | Plain-text signature. | +| `signature_html` | string | no | HTML signature. | +| `signature_sync` | boolean | no | Keep the signature synced from the provider. | +| `signature_code` | boolean | no | Treat the HTML signature as raw code. | +| `status` | string | no | `active`, `inactive`, or `revoked`. | +| `campaign_limit` | integer | no | Daily cold-campaign cap for this mailbox (validated up to `100`). | +| `min_wait_time` | integer | no | Minimum seconds between sends. | +| `reply_to` | string | no | Reply-to address. | +| `warmup` | boolean | no | Enable or disable warmup. | +| `warmup_base` | integer | no | Warmup starting volume per day. | +| `warmup_max` | integer | no | Warmup daily ceiling. | +| `warmup_increase` | integer | no | Per-day warmup ramp increment. | +| `warmup_reply_rate` | integer | no | Percentage of warmup threads to reply to. | +| `warmup_tag` | string | no | Tag applied to warmup threads. | +| `warmup_start_time` | string | no | Daily warmup window start, `HH:MM`. | +| `warmup_end_time` | string | no | Daily warmup window end, `HH:MM`. | +| `warmup_days` | integer | no | Number of active warmup days per week. | +| `tags` | string[] | no | Tag ids assigned to the mailbox. | + +```json +{ + "name": "Acme Sales (US)", + "status": "active", + "campaign_limit": 40, + "min_wait_time": 720, + "reply_to": "replies@acme.com", + "warmup_max": 35, + "tags": ["outbound", "us"] +} +``` + +### Response + +The updated mailbox object (same shape as Get a mailbox). + +## Update the tracking domain + +`PATCH /emails/:id/track` + +Sets or clears the custom open/click tracking domain for a mailbox. The backend resolves the CNAME on save and marks it verified once the customer subdomain points at the shared tracking host (`t.warmbly.com`). DNS can lag a freshly added record, so a miss is reported as unverified (pending), not an error. Send an empty domain to clear the custom domain and fall back to the shared default. + +Auth: **Scope** `WRITE_EMAILS` · **Org permission** `manage_emails` + +| Parameter | In | Type | Description | +|-----------|----|------|-------------| +| `id` | path | string (UUID) | The mailbox id. | +| `domain` | query | string | The custom tracking subdomain (for example `t.acme.com`). Empty clears it. | + +### Response + +```json +{ + "tracking_domain": "t.acme.com", + "tracking_domain_verified": true, + "tracking_domain_verified_at": "2026-06-11T09:20:00Z" +} +``` + +`tracking_domain_verified_at` is null until the CNAME resolves to the tracking host. + +## Start warmup + +`POST /emails/:id/warmup/start` + +Enables warmup for a mailbox. When resuming from a paused state it preserves ramp progress and seeds the warmup task chain immediately rather than waiting for the next reconciler pass. + +Auth: **Scope** `WRITE_EMAILS` · **Org permission** `manage_emails` + +| Parameter | In | Type | Description | +|-----------|----|------|-------------| +| `id` | path | string (UUID) | The mailbox id. | + +### Response + +The updated mailbox object, reflecting the new warmup state. + +```json +{ + "id": "0c0f1a2b-3c4d-5e6f-7a8b-9c0d1e2f3a4b", + "email": "sales@acme.com", + "warmup": "2026-06-11T09:25:00Z", + "warmup_paused_at": null, + "warmup_pool_type": "premium" +} +``` + +## Pause warmup + +`POST /emails/:id/warmup/pause` + +Pauses warmup without losing ramp progress. A later start continues from the same daily volume. + +Auth: **Scope** `WRITE_EMAILS` · **Org permission** `manage_emails` + +| Parameter | In | Type | Description | +|-----------|----|------|-------------| +| `id` | path | string (UUID) | The mailbox id. | + +### Response + +The updated mailbox object. A paused mailbox has a non-null `warmup_paused_at`. + +```json +{ + "id": "0c0f1a2b-3c4d-5e6f-7a8b-9c0d1e2f3a4b", + "warmup": "2026-06-11T09:25:00Z", + "warmup_paused_at": "2026-06-11T11:00:00Z" +} +``` + +## Resume warmup + +`POST /emails/:id/warmup/resume` + +Resumes a paused warmup, shifting the ramp anchor forward so progress continues where it left off, and re-seeds the warmup task chain immediately. + +Auth: **Scope** `WRITE_EMAILS` · **Org permission** `manage_emails` + +| Parameter | In | Type | Description | +|-----------|----|------|-------------| +| `id` | path | string (UUID) | The mailbox id. | + +### Response + +The updated mailbox object, with `warmup_paused_at` cleared. + +## Stop warmup + +`POST /emails/:id/warmup/stop` + +Disables warmup entirely and clears ramp progress. Distinct from pause: a later start begins a fresh ramp. + +Auth: **Scope** `WRITE_EMAILS` · **Org permission** `manage_emails` + +| Parameter | In | Type | Description | +|-----------|----|------|-------------| +| `id` | path | string (UUID) | The mailbox id. | + +### Response + +The updated mailbox object, with warmup disabled. + +```json +{ + "id": "0c0f1a2b-3c4d-5e6f-7a8b-9c0d1e2f3a4b", + "warmup": null, + "warmup_paused_at": null +} +``` + +## Check domain authentication + +`GET /emails/:id/auth-check` + +Validates SPF, DKIM, and DMARC for the mailbox's sending domain on demand. Authentication alignment is a hard bulk-sender requirement and a common silent deliverability failure, so this confirms the domain is configured correctly without leaving the dashboard. + +Auth: **Scope** `READ_EMAILS` · **Org permission** `view_campaigns` + +| Parameter | In | Type | Description | +|-----------|----|------|-------------| +| `id` | path | string (UUID) | The mailbox id. The domain is derived from the mailbox address. | + +### Response + +```json +{ + "domain": "acme.com", + "spf_found": true, + "spf_record": "v=spf1 include:_spf.google.com ~all", + "dkim_found": true, + "dkim_selectors": ["google"], + "dmarc_found": true, + "dmarc_policy": "quarantine", + "all_aligned": true, + "summary": "SPF, DKIM, and DMARC are all present and aligned." +} +``` + +`spf_record`, `dkim_selectors`, and `dmarc_policy` are omitted when the corresponding record is not found. + +## Verify an email address + +`POST /emails/verify` + +Verifies a single email address on demand (syntax, then MX, then an SMTP RCPT probe, then catch-all detection). This is pre-send verification: confirm an address is deliverable before a worker ever sends to it, instead of learning from a hard bounce. The probe runs from the backend (a non-sending IP), never from worker IPs. + +Auth: **Scope** `READ_EMAILS` · **Org permission** `view_campaigns` + +The address may be supplied in the JSON body or as the `email` query param; the body takes precedence. + +### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `email` | string | no | The address to verify. Required if the `email` query param is not set. | + +```json +{ + "email": "jane.doe@example.com" +} +``` + +### Response + +```json +{ + "email": "jane.doe@example.com", + "status": "valid", + "reason": "accepted by recipient mail server", + "is_catch_all": false, + "has_mx": true, + "checked_at": "2026-06-11T09:30:00Z" +} +``` + +`status` is one of `valid`, `risky`, `invalid`, or `unknown`. A missing or empty address returns a `400` error envelope. + +## Get warmup ban status + +`GET /emails/:id/warmup/ban-status` + +Returns whether a mailbox is blocked from the shared warmup pool, why, and whether the owner can appeal. Powers the dashboard ban banner. + +Auth: **Scope** `READ_EMAILS` · **Org permission** `view_campaigns` + +| Parameter | In | Type | Description | +|-----------|----|------|-------------| +| `id` | path | string (UUID) | The mailbox id. | + +### Response + +```json +{ + "email_account_id": "0c0f1a2b-3c4d-5e6f-7a8b-9c0d1e2f3a4b", + "blocked": true, + "health_state": "quarantined", + "reason": "spam-folder placement above threshold", + "blocked_at": "2026-06-09T14:00:00Z", + "blocked_until": "2026-06-16T14:00:00Z", + "can_appeal": true, + "pending_appeal": false +} +``` + +`reason`, `blocked_at`, and `blocked_until` are omitted when the mailbox is not blocked. `health_state` reflects the mailbox's rolling warmup health (for example `healthy`, `watch`, `throttled`, `quarantined`, or `blocked`). + +## Submit a warmup appeal + +`POST /emails/:id/warmup/appeal` + +Lets the mailbox owner appeal a warmup ban with a reason. + +Auth: **Scope** `WRITE_EMAILS` · **Org permission** `manage_emails` + +| Parameter | In | Type | Description | +|-----------|----|------|-------------| +| `id` | path | string (UUID) | The mailbox id. | + +### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `reason` | string | no | The owner's explanation for the appeal. | + +```json +{ + "reason": "Authentication is fixed and the high-bounce list has been removed." +} +``` + +### Response + +```json +{ + "appeal_id": "5e6f7a8b-9c0d-1e2f-3a4b-5c6d7e8f9a0b" +} +``` + +## Delete a mailbox + +`DELETE /emails/:id` + +Disconnects and deletes a mailbox. It is removed from all warmup pools and an account-disconnected event fans out. + +Auth: **Scope** `WRITE_EMAILS` · **Org permission** `manage_emails` + +| Parameter | In | Type | Description | +|-----------|----|------|-------------| +| `id` | path | string (UUID) | The mailbox id. | + +### Response + +`204 No Content` with an empty body. + +## Send from a mailbox + +`POST /emails/:id/send` + +Sends a one-off email from a specific mailbox. The send is scheduled and dispatched through the mailbox's assigned worker. Choose how it is scheduled with `send_mode`. + +Auth: **Scope** `SEND_CAMPAIGNS` · **Org permission** `send_campaigns`. Requires an active organization. + +| Parameter | In | Type | Description | +|-----------|----|------|-------------| +| `id` | path | string (UUID) | The sending mailbox id. | + +### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `to` | string[] | yes | Recipient addresses. | +| `cc` | string[] | no | CC addresses. | +| `bcc` | string[] | no | BCC addresses. | +| `subject` | string | yes | Email subject. | +| `body_html` | string | no | HTML body. | +| `body_plain` | string | no | Plain-text body. | +| `in_reply_to` | string[] | no | Message ids this email replies to. | +| `thread_id` | string | no | Thread id to attach the message to. | +| `send_mode` | string | no | `instant` (default), `smart` (next per-mailbox scheduler gap), or `scheduled` (use `scheduled_at`). | +| `scheduled_at` | string (RFC 3339) | no | Required when `send_mode` is `scheduled`. Must be in the future. | + +```json +{ + "to": ["jane.doe@example.com"], + "subject": "Quick question about your rollout", + "body_html": "

Hi Jane, ...

", + "body_plain": "Hi Jane, ...", + "send_mode": "smart" +} +``` + +### Response + +```json +{ + "task_id": "9a0b1c2d-3e4f-5a6b-7c8d-9e0f1a2b3c4d", + "scheduled_at": "2026-06-11T09:45:00Z", + "send_mode": "smart" +} +``` + +`task_id` identifies the queued send task. `scheduled_at` is the resolved dispatch time (immediate for `instant`, the next gap for `smart`, or the requested time for `scheduled`). + +## Connect a mailbox (onboarding) + +The three onboarding routes connect a new mailbox. They are **session only (not available to API keys)** because they write user-encrypted provider refresh tokens through the SPA popup flow. + +### Start OAuth + +`POST /emails/onboarding/oauth/start` + +Begins an OAuth round trip for a Gmail or Outlook mailbox and returns the provider authorization URL plus an opaque `state` to round-trip back. + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `provider` | string | yes | `gmail` or `outlook`. | + +```json +{ + "provider": "gmail" +} +``` + +Response: + +```json +{ + "url": "https://accounts.google.com/o/oauth2/auth?...", + "state": "n0nc3-opaque-state" +} +``` + +### Finish OAuth + +`POST /emails/onboarding/oauth/finish` + +Completes the OAuth round trip with the authorization code and state from the provider, then creates the mailbox. + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `code` | string | yes | Authorization code from the provider. | +| `state` | string | yes | The `state` returned from start. | + +```json +{ + "code": "4/0Ax...", + "state": "n0nc3-opaque-state" +} +``` + +Response: `201 Created` with the new mailbox object (same shape as Get a mailbox). + +### Connect SMTP/IMAP + +`POST /emails/onboarding/smtp-imap` + +Connects an SMTP/IMAP mailbox in a single call. + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `email` | string | yes | The mailbox address. | +| `name` | string | no | Display name. | +| `smtp` | object | yes | SMTP credentials: `username`, `password`, `host`, `port`. | +| `imap` | object | yes | IMAP credentials: `username`, `password`, `host`, `port`. | + +```json +{ + "email": "sales@acme.com", + "name": "Acme Sales", + "smtp": { + "username": "sales@acme.com", + "password": "app-specific-password", + "host": "smtp.acme.com", + "port": 587 + }, + "imap": { + "username": "sales@acme.com", + "password": "app-specific-password", + "host": "imap.acme.com", + "port": 993 + } +} +``` + +Response: `201 Created` with the new mailbox object. diff --git a/docs/content/docs/api/meta.json b/docs/content/docs/api/meta.json index 74a070d2..99f373ee 100644 --- a/docs/content/docs/api/meta.json +++ b/docs/content/docs/api/meta.json @@ -6,8 +6,12 @@ "pages": [ "index", "authentication", + "oauth", "permissions", "endpoints", + "openapi", + "reference", + "realtime", "error-codes" ] } diff --git a/docs/content/docs/api/oauth.mdx b/docs/content/docs/api/oauth.mdx new file mode 100644 index 00000000..494f6157 --- /dev/null +++ b/docs/content/docs/api/oauth.mdx @@ -0,0 +1,150 @@ +--- +title: OAuth +description: Let third-party apps act on behalf of a Warmbly workspace with the authorization-code flow. +icon: LockKeyhole +--- + +API keys are for your own scripts. When you build an app that other people connect their Warmbly workspace to, use OAuth instead: the user grants your app scoped access on a consent screen, and you receive tokens that act on their behalf. You never see their password or API key. + +Warmbly implements the OAuth 2.0 authorization-code flow. Every app holds a client secret, with PKCE available as an optional extra layer. The implicit and password grants are not supported. + +## Register your app + +In the dashboard, open **Settings -> OAuth apps** and register an application. You provide: + +- a **name**, optional description, logo, and website (shown on the consent screen), +- one or more **redirect URIs** (where users are sent back after they approve), matched exactly and required to be HTTPS, except loopback URLs for native apps, +- the **scopes** your app may request. + +You receive a **client ID** and a **client secret**, shown only once. Every app has a secret: keep it server-side and use it to authenticate the token exchange. Browser and mobile apps that cannot safely hold a secret should add PKCE on top (see below) rather than embedding it. + +## Scopes + +OAuth scopes are the same permissions as API keys, lowercased (for example `read_campaigns`, `write_contacts`). A token can only ever carry scopes that the app was registered with. See the [permission reference](/api/permissions/) for the full list. The issued access token authenticates API calls with exactly those permissions, through the same gates as an API key. + +## The flow + +### 1. Send the user to the authorize page + +Redirect the user's browser to the consent page with the standard parameters. PKCE is optional but recommended: generate a `code_verifier` (a high-entropy random string) and send its S256 challenge. + +``` +code_challenge = base64url( sha256( code_verifier ) ) +``` + +``` +https://app.warmbly.com/oauth/authorize + ?response_type=code + &client_id=wmcid_... + &redirect_uri=https://yourapp.com/oauth/callback + &scope=read_campaigns%20read_contacts + &state= + &code_challenge= + &code_challenge_method=S256 +``` + +`state` is yours to verify on return (CSRF protection). The `code_challenge` and `code_challenge_method` are optional; if you include them, `code_challenge_method` must be `S256`. + +### 2. The user approves + +Warmbly shows the app, the scopes it wants, and an authorize or deny choice. On approval the browser is redirected back to your `redirect_uri` with a single-use code: + +``` +https://yourapp.com/oauth/callback?code=wmac_...&state= +``` + +If the user denies, the redirect carries `?error=access_denied&state=...` instead. + +### 3. Exchange the code for tokens + +Verify `state` matches, then `POST` to the token endpoint. The request is `application/x-www-form-urlencoded`; send your `client_secret` (in the body or via HTTP Basic auth), plus the original `code_verifier` if you used PKCE. + +```bash +curl -X POST "https://api.warmbly.com/v1/oauth/token" \ + -d grant_type=authorization_code \ + -d code=wmac_... \ + -d redirect_uri=https://yourapp.com/oauth/callback \ + -d client_id=wmcid_... \ + -d client_secret=wmcs_... \ + -d code_verifier= +``` + +```json +{ + "access_token": "wmat_...", + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": "wmrt_...", + "scope": "read_campaigns read_contacts" +} +``` + +### 4. Call the API + +Use the access token exactly like an API key: + +```bash +curl "https://api.warmbly.com/v1/campaigns" \ + -H "Authorization: Bearer wmat_..." +``` + +### 5. Refresh when it expires + +Access tokens last one hour. Exchange the refresh token for a new pair before or after expiry. Refresh tokens **rotate**: the old one stops working the moment a new pair is issued, so always store the latest one. + +```bash +curl -X POST "https://api.warmbly.com/v1/oauth/token" \ + -d grant_type=refresh_token \ + -d refresh_token=wmrt_... \ + -d client_id=wmcid_... \ + -d client_secret=wmcs_... +``` + +### Revoke + +Revoke an access or refresh token (and the grant behind it) when the user disconnects: + +```bash +curl -X POST "https://api.warmbly.com/v1/oauth/revoke" \ + -d token=wmat_... \ + -d client_id=wmcid_... \ + -d client_secret=wmcs_... +``` + +Users can also revoke your app themselves under **Settings -> OAuth apps -> Authorized apps**, which invalidates every token issued to it for that workspace. + +## Token lifetimes + +| Token | Lifetime | Notes | +| --- | --- | --- | +| Authorization code | 10 minutes | Single use, PKCE-bound | +| Access token | 1 hour | Bearer, carries the granted scopes | +| Refresh token | 90 days | Rotates on every use | + +## Discovery + +Endpoint URLs and capabilities are published per RFC 8414: + +``` +GET https://api.warmbly.com/.well-known/oauth-authorization-server +``` + +It lists the authorization, token, and revocation endpoints, the supported scopes, `code` as the only response type, `authorization_code` and `refresh_token` grants, and `S256` as the only PKCE method. + +## Security notes + +- Redirect URIs are matched exactly, so register every callback you use. +- Always send and verify `state`. +- PKCE is optional but recommended; when used, only `S256` is accepted. +- Keep your client secret server-side. Browser and mobile apps should add PKCE rather than embedding the secret. + +## Related + + + + The scopes an OAuth app can request, shared with API keys. + + + Using bearer tokens (API keys and OAuth) on the API. + + diff --git a/docs/content/docs/api/openapi.mdx b/docs/content/docs/api/openapi.mdx new file mode 100644 index 00000000..b5ed244d --- /dev/null +++ b/docs/content/docs/api/openapi.mdx @@ -0,0 +1,31 @@ +--- +title: OpenAPI spec +description: A machine-readable OpenAPI 3.1 description of the Warmbly API. +icon: FileJson +--- + +The full Warmbly API is described by a machine-readable OpenAPI 3.1 document: + +``` +https://docs.warmbly.com/openapi.json +``` + +Everything in this reference is generated from the same surface the spec describes, so the document and the docs never drift. + +## What you can do with it + +- **Generate a client.** Point any OpenAPI generator (openapi-generator, oapi-codegen, openapi-typescript, and friends) at the URL to produce a typed client in your language. +- **Explore it interactively.** Import the URL into Postman or Insomnia to get a ready-to-call collection with every endpoint, parameter, and schema. +- **Validate against it.** Use it for contract tests so your integration breaks loudly when an assumption changes. + +There are no official SDKs yet, so generating a client from this spec (or calling the API over plain HTTP) is the supported path today. + +The realtime WebSocket gateway has its own machine-readable description in [AsyncAPI 3.1](https://docs.warmbly.com/asyncapi.json); see the [realtime guide](/api/realtime/). + +## Conventions baked into the spec + +- The single server is `https://api.warmbly.com/v1`. Every path in the spec is relative to that versioned base. See [versioning](/api/). +- Authentication is an API key sent as a Bearer token (`bearerAuth`). See [authentication](/api/authentication/). +- List endpoints share a `data` plus `pagination` envelope with an opaque `cursor` token, and errors share a `code` plus `request_id` shape. See the [overview](/api/) and [error codes](/api/error-codes/). + +The spec is versioned with the API: a breaking change ships as a new spec under a new version, never as an in-place edit to `v1`. diff --git a/docs/content/docs/api/permissions.mdx b/docs/content/docs/api/permissions.mdx index 45ac4a43..174964a0 100644 --- a/docs/content/docs/api/permissions.mdx +++ b/docs/content/docs/api/permissions.mdx @@ -6,6 +6,8 @@ icon: ShieldCheck Warmbly uses a bitmask system for API permissions. Each permission is one bit in a `uint64`, so a single integer can express any combination of the permissions below. +These same permissions are the [OAuth](/api/oauth/) scopes, lowercased: `READ_EMAILS` is the scope `read_emails`. An OAuth access token carries a bitmask of granted permissions and is checked through the identical gates as an API key. + ## Permission values | Permission | Bit | Value | Category | Description | diff --git a/docs/content/docs/api/realtime.mdx b/docs/content/docs/api/realtime.mdx new file mode 100644 index 00000000..73dee323 --- /dev/null +++ b/docs/content/docs/api/realtime.mdx @@ -0,0 +1,302 @@ +--- +title: Realtime WebSocket +description: Subscribe to live Warmbly events over a WebSocket connection. +icon: Radio +--- + +Warmbly pushes dashboard events (campaign sends, opens, clicks, replies, inbox arrivals, audit entries, and more) over a WebSocket. The same socket that powers the live dashboard is available to developers. Events are ordered and carry a monotonic `seq`, and a reconnecting client can resume and replay what it missed. + +The gateway has a machine-readable AsyncAPI 3.1 description you can generate clients or docs from: + +``` +https://docs.warmbly.com/asyncapi.json +``` + +## Connecting + +The socket speaks the Phoenix channel protocol (serializer version `1.0.0`) at: + +``` +wss://realtime.warmbly.com/socket/websocket?vsn=1.0.0&token= +``` + +Three token types are accepted, and all of them go in the same `token` query parameter: + +- **API key** (`wmbly_…`): pass a key with the `REALTIME_SUBSCRIBE` permission (bit 11) directly as `token`. See [Permissions](/api/permissions/) for how to grant it. +- **OAuth access token** (`wmat_…`): pass an access token whose grant includes the realtime scope (the lowercased permission, `realtime_subscribe`). It connects on the granting user's behalf with exactly the scopes they approved. See [OAuth](/api/oauth/). +- **Short-lived JWT**: browser sessions call the authenticated socket endpoint to mint a 10-minute connection token. This is what the dashboard itself uses. + +In short, both developer auth methods work here: an API key with `REALTIME_SUBSCRIBE`, or an OAuth access token with the realtime scope. They authenticate through the same gate, so everything below applies to either. + +If the key (or OAuth app) has IP restrictions configured, they are enforced on the socket handshake as well. + +## Channels + +After connecting, join one or more topics with a `phx_join` message: + +| Topic | Scope | Notes | +| --- | --- | --- | +| `user:` | Events for the key's owning user | Always joinable by yourself | +| `org:` | Organization-wide events | Requires membership; events are filtered by your member permissions | +| `campaign:` | One campaign's activity | Requires `view_campaigns` | +| `account:` | One mailbox's sync and warmup events | Requires `manage_emails` | +| `bulk:` | Progress of one bulk operation | Import/export progress | + +Events arrive as channel messages whose event name is the event type, for example `EMAIL_SENT`, `EMAIL_OPENED`, `EMAIL_REPLIED`, `EMAIL_RECEIVED`, `CAMPAIGN_COMPLETED`, `TASK_PROGRESS`, `ACCOUNT_HEALTH_CHANGED`, `AUDIT_CREATED`, `AUTOMATION_RUN`, `MEETING_BOOKED`, `NOTIFICATION_CREATED`. Payloads always include `event_type` and a timestamp, plus the relevant ids (`campaign_id`, `contact_id`, `thread_id`, and so on). + +Permission filtering happens per event on the org channel: for example inbox events require `access_unibox`, campaign pulses require `view_campaigns`, member changes require `manage_team`, and billing events require `manage_billing`. A member without the permission simply never receives the event. + +## Selecting events with intents + +By default an `org:` subscriber receives every event the member is permitted to see. To narrow the stream, pass an `intents` array in the `phx_join` payload listing the event families you want: + +```json +{ "intents": ["AUDIT", "CAMPAIGN", "EMAIL"] } +``` + +Each token is matched as a case-insensitive substring of the event type, so `CAMPAIGN` matches `CAMPAIGN_*`, `EMAIL` matches `EMAIL_SENT` / `EMAIL_OPENED` / `EMAIL_RECEIVED`, and `AUDIT` matches `AUDIT_CREATED`. An absent or empty array means the full stream. Intents reduce traffic against the per-connection message limit, they are not a security boundary (permission filtering still applies on top). + +## Custom events (fire event) + +You can have Warmbly emit your own events to this stream, so your app reacts to things happening in Warmbly without hosting a public webhook URL. Add a **Fire event** step to an automation or a campaign sequence: you give it an event name and a set of key/value fields (each value templated against the event/contact data, e.g. `{{.contact_email}}`). When the step runs, Warmbly publishes a `CUSTOM_EVENT` on the org channel. + +```json +{ + "event_type": "CUSTOM_EVENT", + "name": "lead.replied", + "payload": { "contact_email": "jane@example.com", "intent": "positive" }, + "source": "automation", + "source_id": "…", + "org_id": "…", + "timestamp": "2026-06-14T15:00:00Z" +} +``` + +`name` is the event name you chose and `payload` is your fields. Subscribe with an API key carrying `REALTIME_SUBSCRIBE` (or an OAuth access token with the realtime scope) and match on `name` (or use the `CUSTOM` intent to receive only these). This is the recommended way to "tell my system this happened": no inbound endpoint, no SSRF surface, and it works behind a firewall. + +## Heartbeats + +The `org:` join reply doubles as a HELLO: it returns the cadence the server expects (so a client library does not hardcode it) and the current stream `seq`. + +```json +{ "org_id": "...", "role": "owner", "heartbeat_interval_ms": 25000, "server_timeout_ms": 60000, "seq": 4821, "resume_supported": true } +``` + +Heartbeats are client-initiated (standard Phoenix): send a `heartbeat` event on the `phoenix` topic every `heartbeat_interval_ms`. If the server receives nothing for `server_timeout_ms` it closes the socket. The reference client also arms a short watchdog after each heartbeat and force-reconnects if the reply does not arrive, which detects a silently dead connection faster than the timeout. + +## Resuming after a disconnect + +Every event carries a monotonic per-organization sequence number in its `seq` field, delivered in order. Track the highest `seq` you have processed; the HELLO also returns the current `seq`. + +To resume after a reconnect, rejoin the `org:` channel with a resume token instead of starting fresh: + +```json +{ "resume": { "last_seq": 4821 } } +``` + +The server then either replays what you missed or tells you to resync: + +- **Replay.** The events with `seq` greater than `last_seq` are pushed as normal channel messages (same shape as live, filtered by your permissions and intents exactly like live delivery), followed by a `resumed` marker: + + ```json + { "from": 4821, "current_seq": 4895, "replayed": 12 } + ``` + +- **Resync.** If your position is no longer in the buffer (you were disconnected longer than the buffer window) or the token is malformed, the server pushes `resume_failed` and no events: + + ```json + { "reason": "buffer_evicted", "current_seq": 5300 } + ``` + + On `resume_failed`, refetch the affected resources over the REST API, then continue live from `current_seq`. + +Resume is **at-least-once**: a replay may re-deliver an event you already handled, so dedupe by `seq`. The buffer holds roughly the most recent events per organization, bounded by both size and time, so resume covers short disconnects (deploys, network blips, tab sleep) but not arbitrarily long ones. + +## Reference client + +The serializer (`vsn=1.0.0`) frames every message as a five-element array `[join_ref, ref, topic, event, payload]`. The client below speaks that wire format directly over a plain `WebSocket`, so it has no dependencies (if you already use the `phoenix` npm package, its `Socket`/`Channel` classes do the same framing for you). It walks the whole lifecycle: connect with a token, join `org:` with intents, read the HELLO join reply, run the heartbeat loop at the advertised cadence, resume from the last `seq` on reconnect, handle events (including `CUSTOM_EVENT`), and back off on close or rejection. + +```js +// No dependencies. token can be an API key (wmbly_… with REALTIME_SUBSCRIBE) +// or an OAuth access token (wmat_… with the realtime scope). +function connectRealtime({ token, orgId, intents = ["CUSTOM"], onEvent }) { + const url = `wss://realtime.warmbly.com/socket/websocket?vsn=1.0.0&token=${encodeURIComponent(token)}`; + const topic = `org:${orgId}`; + + let ws, ref = 0, joinRef = null, heartbeatTimer = null, watchdog = null; + let heartbeatRef = null, backoff = 1000, closedByUs = false; + let lastSeq = 0; // highest seq processed; survives reconnects so we can resume + + const nextRef = () => String(++ref); + + // Phoenix frame: [join_ref, ref, topic, event, payload] + const send = (frameTopic, event, payload, useJoinRef = false) => { + const r = nextRef(); + ws.send(JSON.stringify([useJoinRef ? joinRef : null, r, frameTopic, event, payload])); + return r; + }; + + const join = () => { + joinRef = nextRef(); + // Resume from last_seq when we have one, otherwise a fresh join. + const payload = { intents }; + if (lastSeq > 0) payload.resume = { last_seq: lastSeq }; + ws.send(JSON.stringify([joinRef, joinRef, topic, "phx_join", payload])); + }; + + const startHeartbeats = (intervalMs) => { + stopHeartbeats(); + heartbeatTimer = setInterval(() => { + heartbeatRef = send("phoenix", "heartbeat", {}); + // Watchdog: if the reply never lands, the socket is silently dead. + clearTimeout(watchdog); + watchdog = setTimeout(() => ws.close(4000, "heartbeat timeout"), intervalMs); + }, intervalMs); + }; + + const stopHeartbeats = () => { + clearInterval(heartbeatTimer); + clearTimeout(watchdog); + heartbeatTimer = watchdog = null; + }; + + const open = () => { + closedByUs = false; + ws = new WebSocket(url); + + ws.onopen = () => { backoff = 1000; join(); }; + + ws.onmessage = (e) => { + const [, msgRef, msgTopic, event, payload] = JSON.parse(e.data); + + // Heartbeat reply on the phoenix topic clears the watchdog. + if (msgTopic === "phoenix" && msgRef === heartbeatRef) { + clearTimeout(watchdog); + return; + } + + // Join reply doubles as HELLO (heartbeat cadence, current seq, resume support). + if (event === "phx_reply" && msgRef === joinRef) { + if (payload.status === "ok") { + const hello = payload.response || {}; + lastSeq = Math.max(lastSeq, hello.seq || 0); + startHeartbeats(hello.heartbeat_interval_ms || 25000); + console.log("joined", { seq: hello.seq, resume_supported: hello.resume_supported }); + } else { + // Post-join rejection: structured { code, reason }. + const err = payload.response || {}; + console.error("join rejected", err.code, err.reason); + ws.close(4000, "join rejected"); + } + return; + } + + // Resume outcome markers. + if (event === "resumed") { lastSeq = Math.max(lastSeq, payload.current_seq || lastSeq); return; } + if (event === "resume_failed") { + lastSeq = payload.current_seq || lastSeq; // resync over REST, then continue live + console.warn("resume_failed", payload.reason, "-> resync from", lastSeq); + return; + } + + // Server-side throttle of outbound delivery. + if (event === "rate_limited") return; + + // Real events. Track seq for resume and dedupe by it (delivery is at-least-once). + if (typeof payload?.seq === "number") { + if (payload.seq <= lastSeq) return; // already handled + lastSeq = payload.seq; + } + if (event === "CUSTOM_EVENT") { + onEvent?.({ name: payload.name, payload: payload.payload, raw: payload }); + } else { + onEvent?.({ name: event, payload, raw: payload }); + } + }; + + ws.onclose = (e) => { + stopHeartbeats(); + // Connect-level rejections currently surface as a failed handshake (HTTP 403 + // carrying the reason), not a close code; either way, reconnect with backoff. + // Re-mint the token first if it may have expired (4004). + if (closedByUs) return; + console.warn("closed", e.code, e.reason, "- retrying in", backoff, "ms"); + setTimeout(open, backoff); + backoff = Math.min(backoff * 2, 30000); // exponential backoff, capped + }; + + ws.onerror = () => { try { ws.close(); } catch {} }; + }; + + open(); + return { close: () => { closedByUs = true; stopHeartbeats(); ws?.close(1000); } }; +} + +// Usage +const conn = connectRealtime({ + token: "wmbly_…", // or an OAuth "wmat_…" access token + orgId: "org_123", + intents: ["CUSTOM", "EMAIL", "AUDIT"], + onEvent: ({ name, payload }) => console.log(name, payload), +}); +``` + +## Presence + +The org channel carries team presence for the collaboration features in the dashboard (who is online, who is viewing or replying to a record). Standard Phoenix presence events are used: + +- `presence_state` is pushed once after a successful join with the full member map. +- `presence_diff` carries joins and leaves as they happen. + +Clients update their own activity by pushing a `presence:update` event with `{ "page": "/app/unibox", "resource": "thread:", "action": "viewing" }`. Valid actions are `viewing`, `editing`, `replying`, and `idle`. + +Presence is subject to the workspace's privacy settings. An admin can turn off "show who's online", in which case no member is tracked and the presence map stays empty, or "show activity", in which case members appear online but their `page`, `resource`, and `action` are stripped. These are enforced server-side, so the hidden detail never reaches any subscriber. Changing the setting re-gates connected sockets immediately and emits the corresponding `presence_diff`. + +API-key connections receive presence events but are never tracked as presences themselves: machines are not teammates. + +## Rate limits and connection caps + +The realtime service protects itself against connection spam. All limits are per user (the key's owner) unless noted: + +| Limit | Default | +| --- | --- | +| Concurrent connections | 10 (plan-dependent) | +| Concurrent connections per IP | 50 | +| Channel joins | 30/minute | +| Client-sent events (including `presence:update`) | 60/minute | +| Server-to-client messages | 120/minute | + +When a client-sent event is throttled you receive an error reply with `reason: "rate_limited"` and a `retry_after_ms` hint. When outbound delivery is throttled the channel pushes a `rate_limited` message instead of the event. + +## Connection rejections + +A connection can be refused at two points, and the two surface differently today: + +- **Connect-level** (authentication, the realtime permission, the IP allowlist, the rate limit, the connection cap): the WebSocket upgrade is refused at the HTTP layer. The handshake returns an **HTTP 403** carrying the reason, and the client observes a failed upgrade rather than an application close frame. +- **Post-join** (a `phx_join` that fails after the socket is open, for example a channel you may not see): the join reply comes back with `status: "error"` and a structured error object `{ code, reason }` in its `response`. + +Both paths use the same reason codes: + +| Code | Meaning | Client action | +| --- | --- | --- | +| 4003 | Not authenticated (token missing) | Add a valid `token` to the connection URL | +| 4004 | Authentication failed: token expired, or invalid key | Re-mint the token (or check the key), then reconnect | +| 4007 | Rate limited | Back off and retry | +| 4009 | Connection limit exceeded | Reduce concurrent sockets | +| 4010 | Permission denied, or IP not allowed | Grant `REALTIME_SUBSCRIBE` (or the realtime scope), or fix the IP allowlist | + +Because connect-level rejections are an HTTP 403 and not a WebSocket close frame today, a client cannot branch on the connect-level code programmatically: treat any failed upgrade as "re-mint the token if it expired, otherwise back off". Post-join rejections are already machine-readable through `{ code, reason }`. Surfacing connect-level reasons as application close codes too is planned. + +## Delivery guarantees + +Every event has a monotonic per-organization `seq` and is delivered in order. Within the buffer window, a reconnecting client can [resume](#resuming-after-a-disconnect) and replay exactly what it missed; across a resume, delivery is at-least-once (dedupe by `seq`). + +The buffer is not an infinite log. It holds roughly the most recent events per organization, bounded by size and time, so a client disconnected longer than that window gets a `resume_failed` and must do a full resync over the REST API. For delivery that must survive arbitrary downtime, use [webhooks](/api/endpoints/), which are persisted and retried; the WebSocket is the low-latency path, webhooks are the durable one. + +## Good citizenship + +- Reuse one connection per process and multiplex channels over it instead of opening one socket per topic. +- Pass `intents` so you only receive (and pay the message-rate budget for) the events you act on. +- Reconnect with exponential backoff; the limits above treat reconnect storms the same as connection spam. +- After a reconnect, resume with your last `seq` instead of refetching everything; only fall back to a full REST resync on `resume_failed`. +- Treat event payloads as invalidation signals (they carry ids, not full state) and refetch the resource over the REST API when you need its current contents. diff --git a/docs/content/docs/api/reference/account-org.mdx b/docs/content/docs/api/reference/account-org.mdx new file mode 100644 index 00000000..563b9361 --- /dev/null +++ b/docs/content/docs/api/reference/account-org.mdx @@ -0,0 +1,1993 @@ +--- +title: Account and organization +description: Authentication, sessions, your profile and security settings, organization governance, teams, and subscription billing. +icon: Users +--- + +This group covers everything tied to a human account and the workspace it belongs to: signing in, managing sessions, editing your profile and security settings (notification preferences, two-factor authentication, passkeys), running an organization (members, invitations, custom roles), grouping members into teams, and billing. + +Almost every endpoint here is session only. They depend on a human-bound JWT and are never reachable with an API key. The two exceptions are the team endpoints, which accept either a JWT or an API key, and the public auth endpoints (login, register, refresh, password reset, passkey login, 2FA verify), which carry no session at all because they exist to create one. Each endpoint below states its auth explicitly. + +All error responses use the shared envelope (`error`, `message`, `code`, `request_id`). See [Error codes](/api/error-codes/). + +## Authentication + +These endpoints are public (no session required). Login and registration are two-step: the first call sends a one-time code by email and returns a short-lived signed `session` token, the second call confirms the code and mints the real token pair. Captcha (Cloudflare Turnstile) is required on the start calls. + +### Start login + +`POST /auth/login` + +Sends a login code to the account email and returns a signed session token to carry into the confirm step. + +Auth: public (not available to API keys). + +#### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `email` | string | yes | Account email. | +| `password` | string | yes | Account password. | +| `turnstile` | string | yes | Cloudflare Turnstile token. | + +```json +{ + "email": "alex@acme.com", + "password": "correct horse battery staple", + "turnstile": "0.abc123..." +} +``` + +#### Response + +```json +{ + "session": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." +} +``` + +### Confirm login + +`POST /auth/login/confirm` + +Exchanges the emailed code plus the session token for a token pair. If the account has 2FA enabled, no token pair is returned: `two_fa_required` is `true` and a single-use `pending_token` is returned instead, to be passed to `/auth/2fa/verify/`. + +Auth: public (not available to API keys). + +#### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `session` | string | yes | The token returned by `/auth/login`. | +| `code` | string | yes | The one-time code from the email. | +| `turnstile` | string | no | Cloudflare Turnstile token. | + +```json +{ + "session": "eyJhbGciOiJIUzI1NiIs...", + "code": "489210" +} +``` + +#### Response + +On success, the full token pair: + +```json +{ + "access_token": "eyJhbGc...", + "access_token_expires_at": "2026-06-12T13:00:00Z", + "refresh_token": "eyJhbGc...", + "refresh_token_expires_at": "2026-07-12T12:00:00Z" +} +``` + +When 2FA is enabled, a challenge instead of a session: + +```json +{ + "two_fa_required": true, + "pending_token": "eyJhbGc...", + "expires_in": 300 +} +``` + +### Start registration + +`POST /auth/register` + +Creates a pending registration and emails a confirmation code. Returns a signed session token for the confirm step. + +Auth: public (not available to API keys). + +#### Request body + +Same shape as `/auth/login`: `email`, `password`, `turnstile`. + +```json +{ + "email": "newuser@acme.com", + "password": "correct horse battery staple", + "turnstile": "0.abc123..." +} +``` + +#### Response + +```json +{ + "session": "eyJhbGciOiJIUzI1NiIs..." +} +``` + +### Confirm registration + +`POST /auth/register/confirm` + +Confirms the emailed code and creates the account. Returns `204 No Content` (the client then signs in via the login flow). + +Auth: public (not available to API keys). + +#### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `session` | string | yes | The token returned by `/auth/register`. | +| `code` | string | yes | The one-time code from the email. | +| `turnstile` | string | no | Cloudflare Turnstile token. | + +#### Response + +`204 No Content`. + +### Refresh token + +`POST /auth/refresh` + +Exchanges a valid refresh token for a fresh token pair. + +Auth: public (not available to API keys). + +#### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `refresh_token` | string | yes | A valid, unexpired refresh token. | + +```json +{ + "refresh_token": "eyJhbGc..." +} +``` + +#### Response + +A new token pair, same shape as the login confirm success response (`access_token`, `access_token_expires_at`, `refresh_token`, `refresh_token_expires_at`). + +### Start password reset + +`POST /auth/reset-password` + +Emails a reset code if the address has an account. Always returns `200 OK` (it does not reveal whether the address exists). + +Auth: public (not available to API keys). + +#### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `email` | string | yes | Account email. | +| `turnstile` | string | yes | Cloudflare Turnstile token. | + +#### Response + +`200 OK` with an empty body. + +### Confirm password reset + +`POST /auth/reset-password/confirm` + +Sets a new password using the emailed reset code (carried inside the signed `session` token). + +Auth: public (not available to API keys). + +#### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `session` | string | yes | The reset session token tied to the email. | +| `password` | string | yes | The new password. | +| `turnstile` | string | yes | Cloudflare Turnstile token. | + +#### Response + +`200 OK` with an empty body. + +### Begin passkey login + +`POST /auth/passkey/login/begin` + +Starts a discoverable (usernameless) WebAuthn assertion. Returns the public-key request options the browser passes to `navigator.credentials.get()`. + +Auth: public (not available to API keys). + +#### Response + +The WebAuthn assertion options object (challenge, RP id, allowed credentials, timeout), to feed directly to the WebAuthn API. The embedded `session` is echoed back in the finish step. + +### Finish passkey login + +`POST /auth/passkey/login/finish` + +Verifies the signed assertion and mints a token pair. A passkey is strong auth, so there is no email code step. + +Auth: public (not available to API keys). + +#### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `session` | string | yes | The session value from the begin step. | +| `credential` | object | yes | The raw WebAuthn assertion (`PublicKeyCredential`) from the browser. | + +#### Response + +A token pair (same shape as the login confirm success response). + +### Verify 2FA login + +`POST /auth/2fa/verify` + +Exchanges the single-use `pending_token` from a 2FA-gated login plus a TOTP or recovery code for a real session. + +Auth: public (not available to API keys). + +#### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `pending_token` | string | yes | The `pending_token` returned by `/auth/login/confirm`. | +| `code` | string | yes | A current TOTP code or an unused recovery code. | + +```json +{ + "pending_token": "eyJhbGc...", + "code": "123456" +} +``` + +#### Response + +A token pair (same shape as the login confirm success response). + +## Sessions + +Self-service session management for the signed-in user. Revoking by id can only ever touch the caller's own sessions. + +### Sign out + +`POST /auth/logout` + +Revokes the current session. Returns `204 No Content`. + +Auth: Session only (not available to API keys). + +### Sign out everywhere + +`POST /auth/logout-all` + +Revokes every session for the user. Returns `204 No Content`. + +Auth: Session only (not available to API keys). + +### List sessions + +`GET /auth/sessions` + +Returns the user's active sessions for the account security page, with the current one flagged and floated to the top. + +Auth: Session only (not available to API keys). + +#### Response + +A bare array of session views. + +```json +[ + { + "id": "1f1d...", + "current": true, + "browser": "Chrome", + "os": "macOS", + "location_city": "Austin", + "location_region": "Texas", + "location_country": "United States", + "country_code": "US", + "auth_provider": "email", + "created_at": "2026-06-10T08:12:00Z", + "last_active_at": "2026-06-12T09:40:00Z" + } +] +``` + +### Revoke other sessions + +`DELETE /auth/sessions` + +Ends every active session except the current one. Returns `204 No Content`. + +Auth: Session only (not available to API keys). + +### Revoke a session + +`DELETE /auth/sessions/:id` + +Ends one of the user's other sessions by id. Returns `204 No Content`. + +Auth: Session only (not available to API keys). + +| Parameter | In | Type | Description | +|-----------|----|------|-------------| +| `id` | path | uuid | The session id to revoke. | + +## Profile and account + +### Get current user + +`GET /auth/me` + +Returns the signed-in user, including admin flags and the per-user label groups (folders, tags, categories) the dashboard needs on initial load. + +Auth: Session only (not available to API keys). + +#### Response + +```json +{ + "id": "9c2a...", + "first_name": "Alex", + "last_name": "Rivera", + "email": "alex@acme.com", + "avatar_url": "https://warmbly-assets.s3.amazonaws.com/avatars/users/9c2a-...jpg", + "roles": ["b1e4..."], + "referral_source": "google", + "onboarding_completed_at": "2026-05-01T10:00:00Z", + "max_organizations": 3, + "free_trial_used": true, + "admin_permissions": 0, + "is_admin": false, + "folders": [], + "tags": [], + "categories": [], + "created_at": "2026-04-20T12:00:00Z", + "updated_at": "2026-06-10T08:00:00Z" +} +``` + +### Update profile + +`PATCH /auth/me` + +Updates editable profile fields. First and last name are both required and capped at 50 characters. Returns `204 No Content`. + +Auth: Session only (not available to API keys). + +#### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `first_name` | string | yes | 1 to 50 characters. | +| `last_name` | string | yes | 1 to 50 characters. | + +```json +{ + "first_name": "Alex", + "last_name": "Rivera" +} +``` + +### Complete onboarding + +`PATCH /auth/me/onboarding` + +Persists the onboarding questionnaire (name plus optional persona answers). Returns `204 No Content`. + +Auth: Session only (not available to API keys). + +#### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `first_name` | string | yes | 1 to 50 characters. | +| `last_name` | string | yes | 1 to 50 characters. | +| `referral_source` | string | yes | One of `reddit`, `x`, `facebook`, `google`, `other`. | +| `role` | string | no | One of `founder`, `sales`, `marketing`, `agency`, `recruiter`, `other`. | +| `team_size` | string | no | One of `just_me`, `2-10`, `11-50`, `51-200`, `200+`. | + +```json +{ + "first_name": "Alex", + "last_name": "Rivera", + "referral_source": "google", + "role": "sales", + "team_size": "2-10" +} +``` + +### Upload avatar + +`POST /auth/me/avatar` + +Uploads a profile image. `multipart/form-data` with a single `file` field. PNG or JPG only, max 2 MB, max 1024x1024 px. + +Auth: Session only (not available to API keys). + +#### Response + +```json +{ + "avatar_url": "https://warmbly-assets.s3.amazonaws.com/avatars/users/9c2a-1718193600.jpg" +} +``` + +### Remove avatar + +`DELETE /auth/me/avatar` + +Clears the profile image. Returns `204 No Content`. + +Auth: Session only (not available to API keys). + +### Change password + +`POST /auth/me/password` + +Updates the signed-in user's password. Returns `200 OK`. + +Auth: Session only (not available to API keys). + +#### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `current_password` | string | yes | The existing password. | +| `new_password` | string | yes | The new password. | + +```json +{ + "current_password": "correct horse battery staple", + "new_password": "a longer better passphrase" +} +``` + +## Notification preferences and feed + +User-scoped, no organization gate. Preferences are a single per-user object; the feed is the in-app notification list. + +### Get notification preferences + +`GET /auth/me/notification-preferences` + +Returns the caller's preferences merged over the defaults. + +Auth: Session only (not available to API keys). + +#### Response + +```json +{ + "preferences": { + "inbound_reply": { "enabled": false, "channels": { "in_app": true, "email": false, "slack": false } }, + "inbound_out_of_office": { "enabled": false, "channels": { "in_app": true, "email": false, "slack": false } }, + "health_bounce": { "enabled": true, "channels": { "in_app": true, "email": false, "slack": false } }, + "health_complaint": { "enabled": true, "channels": { "in_app": true, "email": false, "slack": false } }, + "health_worker_downtime": { "enabled": true, "channels": { "in_app": true, "email": false, "slack": false } }, + "security_new_signin": { "enabled": true, "channels": { "in_app": true, "email": false, "slack": false } } + } +} +``` + +### Update notification preferences + +`PUT /auth/me/notification-preferences` + +Replaces the caller's preferences. The full preferences object is sent under a `preferences` key and echoed back. + +Auth: Session only (not available to API keys). + +#### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `preferences` | object | yes | The complete preferences object. Each of the six categories has `enabled` plus a `channels` object (`in_app`, `email`, `slack`). | + +```json +{ + "preferences": { + "inbound_reply": { "enabled": true, "channels": { "in_app": true, "email": true, "slack": false } }, + "inbound_out_of_office": { "enabled": false, "channels": { "in_app": true, "email": false, "slack": false } }, + "health_bounce": { "enabled": true, "channels": { "in_app": true, "email": false, "slack": false } }, + "health_complaint": { "enabled": true, "channels": { "in_app": true, "email": false, "slack": false } }, + "health_worker_downtime": { "enabled": true, "channels": { "in_app": true, "email": false, "slack": false } }, + "security_new_signin": { "enabled": true, "channels": { "in_app": true, "email": false, "slack": false } } + } +} +``` + +#### Response + +The same `{ "preferences": { ... } }` object that was sent. + +### List notifications + +`GET /auth/me/notifications` + +Returns the caller's recent in-app feed plus the unread count. This is a fixed-size feed, not a cursor-paginated list. + +Auth: Session only (not available to API keys). + +| Parameter | In | Type | Description | +|-----------|----|------|-------------| +| `limit` | query | int | Max items to return (default 50). | +| `unread` | query | string | `1` or `true` to return only unread items. | + +#### Response + +```json +{ + "notifications": [ + { + "id": "f0aa...", + "user_id": "9c2a...", + "organization_id": "3d11...", + "category": "health_bounce", + "title": "Hard bounce on alex@acme.com", + "body": "A message bounced and the recipient was suppressed.", + "link": "/app/mailboxes/abc", + "read_at": null, + "created_at": "2026-06-12T09:00:00Z" + } + ], + "unread": 3 +} +``` + +### Mark all read + +`PUT /auth/me/notifications` + +Marks the caller's whole feed read. + +Auth: Session only (not available to API keys). + +#### Response + +```json +{ "ok": true } +``` + +### Mark one read + +`POST /auth/me/notifications/:id/read` + +Marks a single notification read. + +Auth: Session only (not available to API keys). + +| Parameter | In | Type | Description | +|-----------|----|------|-------------| +| `id` | path | uuid | The notification id. | + +#### Response + +```json +{ "ok": true } +``` + +## Two-factor authentication + +TOTP-based 2FA. Enrollment and management require a live session. The login challenge itself (`/auth/2fa/verify`) is public and documented above. + +### 2FA status + +`GET /auth/2fa/status` + +Reports whether the caller has 2FA enabled. + +Auth: Session only (not available to API keys). + +#### Response + +```json +{ "enabled": false } +``` + +### Begin enrollment + +`POST /auth/2fa/enroll/start` + +Generates a TOTP secret and the `otpauth://` provisioning URI (returned once). The client renders the URI as a QR code. + +Auth: Session only (not available to API keys). + +#### Response + +```json +{ + "secret": "JBSWY3DPEHPK3PXP", + "otpauth_uri": "otpauth://totp/Warmbly:alex@acme.com?secret=JBSWY3DPEHPK3PXP&issuer=Warmbly" +} +``` + +### Confirm enrollment + +`POST /auth/2fa/enroll/confirm` + +Verifies a TOTP code, enables 2FA, and returns the one-time recovery codes (shown once). + +Auth: Session only (not available to API keys). + +#### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `code` | string | yes | A current TOTP code from the authenticator. | + +#### Response + +```json +{ + "recovery_codes": ["a1b2-c3d4", "e5f6-g7h8", "..."] +} +``` + +### Disable 2FA + +`DELETE /auth/2fa` + +Turns off 2FA. Requires a current TOTP or recovery code in the body. + +Auth: Session only (not available to API keys). + +#### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `code` | string | yes | A current TOTP or recovery code. | + +#### Response + +```json +{ "ok": true } +``` + +## Passkeys + +WebAuthn credential management for the signed-in user. The login flow (`/auth/passkey/login/begin` and `/finish`) is public and documented above. + +### Begin passkey registration + +`POST /auth/passkey/register/begin` + +Returns the WebAuthn creation options the browser passes to `navigator.credentials.create()`. + +Auth: Session only (not available to API keys). + +#### Response + +The WebAuthn creation options object (challenge, RP, user, pubkey params). + +### Finish passkey registration + +`POST /auth/passkey/register/finish` + +Stores the new credential and returns its display view. + +Auth: Session only (not available to API keys). + +#### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `name` | string | no | A friendly label for the passkey. | +| `credential` | object | yes | The raw WebAuthn attestation (`PublicKeyCredential`) from the browser. | + +#### Response + +```json +{ + "id": "c41f...", + "name": "MacBook Touch ID", + "credential_id": "b64url...", + "transports": ["internal"], + "backup_state": true, + "created_at": "2026-06-12T09:00:00Z", + "last_used_at": null +} +``` + +### List passkeys + +`GET /auth/passkey/credentials` + +Returns the user's stored passkeys. + +Auth: Session only (not available to API keys). + +#### Response + +A bare array of credential views (same shape as the register finish response). + +### Rename a passkey + +`PATCH /auth/passkey/credentials/:id` + +Renames a stored passkey and returns the updated view. + +Auth: Session only (not available to API keys). + +| Parameter | In | Type | Description | +|-----------|----|------|-------------| +| `id` | path | uuid | The credential id. | + +#### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `name` | string | yes | The new label. | + +### Delete a passkey + +`DELETE /auth/passkey/credentials/:id` + +Removes a stored passkey. Returns `204 No Content`. + +Auth: Session only (not available to API keys). + +| Parameter | In | Type | Description | +|-----------|----|------|-------------| +| `id` | path | uuid | The credential id. | + +## Account danger zone + +Delayed hard-delete of the caller's own account, with a confirmation phrase and a grace window. + +### Get account danger-zone status + +`GET /me/danger-zone` + +Returns the danger-zone summary plus any pending deletion. + +Auth: Session only (not available to API keys). + +#### Response + +```json +{ + "resource_type": "user", + "resource_id": "9c2a...", + "resource_name": "alex@acme.com", + "confirmation_hint": "alex@acme.com", + "grace_days": 14, + "pending_deletion": null +} +``` + +### Schedule account deletion + +`POST /me/danger-zone/delete` + +Schedules the account for a delayed hard delete. The confirmation must match the email. Returns `202 Accepted` with the scheduled-deletion record. + +Auth: Session only (not available to API keys). + +#### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `confirmation` | string | yes | Must equal the account email. | +| `reason` | string | no | Optional free-text reason. | + +```json +{ + "confirmation": "alex@acme.com", + "reason": "switching tools" +} +``` + +#### Response + +```json +{ + "resource_type": "user", + "resource_id": "9c2a...", + "requested_by_user_id": "9c2a...", + "scheduled_at": "2026-06-12T09:00:00Z", + "execute_after": "2026-06-26T09:00:00Z", + "grace_days": 14, + "status": "pending" +} +``` + +### Cancel account deletion + +`DELETE /me/danger-zone/delete` + +Cancels a pending account deletion. + +Auth: Session only (not available to API keys). + +#### Request body (optional) + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `reason` | string | no | Optional free-text reason. | + +#### Response + +```json +{ "message": "deletion cancelled" } +``` + +## Websocket bootstrap + +### Generate a websocket token + +`POST /getaway` + +Mints a single-session token for the realtime websocket and returns the connect URL plus its TTL in seconds. + +Auth: Session only (not available to API keys). + +#### Response + +```json +{ + "url": "wss://realtime.warmbly.com/socket?token=...", + "expires_in": 60 +} +``` + +## Organizations + +The organization is the workspace tenant. These endpoints handle creating and switching workspaces, the current workspace and its limits, members, custom roles, invitations, ownership transfer, avatar, and the workspace danger zone. All of `/organization/*` is session only. Mutations are gated by the caller's organization role, noted per endpoint. + +### Create an organization + +`POST /organization` + +Creates a new organization owned by the caller. Returns `201 Created`. + +Auth: Session only (not available to API keys). + +#### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `name` | string | yes | 1 to 255 characters. | + +```json +{ "name": "Acme Outbound" } +``` + +#### Response + +The created organization object. + +```json +{ + "id": "3d11...", + "name": "Acme Outbound", + "slug": null, + "avatar_url": null, + "owner_user_id": "9c2a...", + "presence_show_online": true, + "presence_show_activity": true, + "created_at": "2026-06-12T09:00:00Z", + "updated_at": "2026-06-12T09:00:00Z" +} +``` + +### List my organizations + +`GET /organization` + +Returns the organizations the caller is a member of. + +Auth: Session only (not available to API keys). + +#### Response + +```json +{ + "data": [ + { + "id": "ab12...", + "organization_id": "3d11...", + "user_id": "9c2a...", + "role": "owner", + "permissions": 524287, + "email": "alex@acme.com", + "name": "Alex Rivera", + "invited_at": "2026-06-12T09:00:00Z" + } + ] +} +``` + +### Switch organization + +`POST /organization/switch/:id` + +Sets the session's current organization. The caller must be a member. + +Auth: Session only (not available to API keys). + +| Parameter | In | Type | Description | +|-----------|----|------|-------------| +| `id` | path | uuid | The organization to switch to. | + +#### Response + +```json +{ + "message": "organization switched", + "organization_id": "3d11..." +} +``` + +### Get current organization + +`GET /organization/current` + +Returns the session's current organization with its resolved limits and live counts. + +Auth: Session only (not available to API keys). + +#### Response + +```json +{ + "id": "3d11...", + "name": "Acme Outbound", + "owner_user_id": "9c2a...", + "presence_show_online": true, + "presence_show_activity": true, + "created_at": "2026-06-12T09:00:00Z", + "updated_at": "2026-06-12T09:00:00Z", + "limits": { + "max_campaigns": 50, + "max_team_members": 10, + "max_email_accounts": 25, + "daily_campaign_limit": 500 + }, + "counts": { + "total_campaigns": 8, + "active_campaigns": 2, + "total_contacts": 1240, + "total_members": 3, + "email_accounts": 6, + "emails_sent_today": 180 + } +} +``` + +### Update current organization + +`PATCH /organization/current` + +Updates the current organization (name, slug, presence privacy toggles). A presence privacy change re-gates connected sockets live. + +Auth: Session only (not available to API keys). **Org permission** `manage_settings`. + +#### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `name` | string | no | New workspace name. | +| `slug` | string | no | New workspace slug. | +| `presence_show_online` | boolean | no | When false the realtime service tracks no member (nobody appears online). | +| `presence_show_activity` | boolean | no | When false online is still shown but viewing/editing detail is stripped. | + +```json +{ + "name": "Acme Outbound", + "presence_show_activity": false +} +``` + +#### Response + +The updated organization object. + +### Get organization limits + +`GET /organization/current/limits` + +Returns the current organization's plan-resolved limits and current usage counts. + +Auth: Session only (not available to API keys). Requires a selected organization. + +#### Response + +```json +{ + "limits": { + "max_campaigns": 50, + "max_team_members": 10, + "max_email_accounts": 25, + "daily_campaign_limit": 500 + }, + "counts": { + "total_campaigns": 8, + "active_campaigns": 2, + "total_contacts": 1240, + "total_members": 3, + "email_accounts": 6, + "emails_sent_today": 180 + } +} +``` + +### List members + +`GET /organization/members` + +Returns the current organization's members, each hydrated with the user's email, display name, role, and effective permission bitmask. + +Auth: Session only (not available to API keys). Requires a selected organization. + +#### Response + +```json +{ + "data": [ + { + "id": "ab12...", + "organization_id": "3d11...", + "user_id": "9c2a...", + "role": "owner", + "roles": [{ "id": "r1...", "name": "Owner", "color": "#0ea5e9" }], + "permissions": 524287, + "email": "alex@acme.com", + "name": "Alex Rivera", + "invited_at": "2026-06-12T09:00:00Z", + "accepted_at": "2026-06-12T09:05:00Z" + } + ] +} +``` + +### Invite a member + +`POST /organization/members/invite` + +Invites an email to the organization and emails an accept link. The invitee lands in the given role set. Returns `201 Created`. + +Auth: Session only (not available to API keys). **Org permission** `manage_team`. + +#### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `email` | string | yes | The invitee's email. | +| `role_ids` | uuid[] | no | The workspace roles to assign (at least one role overall). | +| `role_id` | uuid | no | Single-role shorthand, merged with `role_ids`. | + +```json +{ + "email": "sam@acme.com", + "role_ids": ["r2..."] +} +``` + +#### Response + +```json +{ + "message": "invitation sent", + "invitation": { + "id": "inv1...", + "organization_id": "3d11...", + "email": "sam@acme.com", + "role": "member", + "permissions": 12288, + "invited_by": "9c2a...", + "expires_at": "2026-06-19T09:00:00Z", + "created_at": "2026-06-12T09:00:00Z" + } +} +``` + +### Update a member's roles + +`PATCH /organization/members/:id` + +Replaces a member's assigned role set. Returns the updated member. + +Auth: Session only (not available to API keys). **Org permission** `manage_team`. + +| Parameter | In | Type | Description | +|-----------|----|------|-------------| +| `id` | path | uuid | The member's user id. | + +#### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `role_ids` | uuid[] | no | New assigned role set (at least one role overall). | +| `role_id` | uuid | no | Single-role shorthand, merged with `role_ids`. | + +```json +{ "role_ids": ["r2...", "r3..."] } +``` + +#### Response + +The updated member object (same shape as a list-members entry). + +### Remove a member + +`DELETE /organization/members/:id` + +Removes a member from the organization. + +Auth: Session only (not available to API keys). **Org permission** `manage_team`. + +| Parameter | In | Type | Description | +|-----------|----|------|-------------| +| `id` | path | uuid | The member's user id. | + +#### Response + +```json +{ "message": "member removed" } +``` + +### List custom roles + +`GET /organization/roles` + +Returns the organization's custom roles (named permission sets). Listing is open to every member so role chips render on the roster. + +Auth: Session only (not available to API keys). Requires a selected organization. + +#### Response + +```json +{ + "data": [ + { + "id": "r2...", + "organization_id": "3d11...", + "name": "Sales rep", + "description": "Run campaigns, view contacts", + "color": "#22c55e", + "permissions": 4352, + "member_count": 4, + "created_at": "2026-05-01T09:00:00Z", + "updated_at": "2026-06-01T09:00:00Z" + } + ] +} +``` + +### Create a custom role + +`POST /organization/roles` + +Creates a custom role. Returns `201 Created` with the role. + +Auth: Session only (not available to API keys). **Org permission** `manage_team`. + +#### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `name` | string | yes | Role name. | +| `description` | string | no | Role description. | +| `color` | string | no | Hex color for the chip. | +| `permissions` | int | no | Organization permission bitmask granted by the role. | + +```json +{ + "name": "Sales rep", + "description": "Run campaigns, view contacts", + "color": "#22c55e", + "permissions": 4352 +} +``` + +### Update a custom role + +`PATCH /organization/roles/:id` + +Edits a custom role. Edits propagate to every member assigned to it (permission readers stay JOIN-free). + +Auth: Session only (not available to API keys). **Org permission** `manage_team`. + +| Parameter | In | Type | Description | +|-----------|----|------|-------------| +| `id` | path | uuid | The role id. | + +#### Request body + +All fields optional; nil fields are left untouched. + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `name` | string | no | New name. | +| `description` | string | no | New description. | +| `color` | string | no | New chip color. | +| `permissions` | int | no | New permission bitmask. | + +### Delete a custom role + +`DELETE /organization/roles/:id` + +Removes a custom role. Returns `204 No Content`. + +Auth: Session only (not available to API keys). **Org permission** `manage_team`. + +| Parameter | In | Type | Description | +|-----------|----|------|-------------| +| `id` | path | uuid | The role id. | + +### List pending invitations + +`GET /organization/invitations` + +Returns the organization's outstanding invitations. + +Auth: Session only (not available to API keys). **Org permission** `manage_team`. + +#### Response + +```json +{ + "data": [ + { + "id": "inv1...", + "organization_id": "3d11...", + "email": "sam@acme.com", + "role": "member", + "permissions": 12288, + "invited_by": "9c2a...", + "expires_at": "2026-06-19T09:00:00Z", + "created_at": "2026-06-12T09:00:00Z" + } + ] +} +``` + +### Cancel an invitation + +`DELETE /organization/invitations/:id` + +Cancels a pending invitation. + +Auth: Session only (not available to API keys). **Org permission** `manage_team`. + +| Parameter | In | Type | Description | +|-----------|----|------|-------------| +| `id` | path | uuid | The invitation id. | + +#### Response + +```json +{ "message": "invitation cancelled" } +``` + +### Get an invitation link + +`GET /organization/invitations/:id/link` + +Returns the shareable accept token for a pending invitation so a team manager can copy a real accept link. + +Auth: Session only (not available to API keys). **Org permission** `manage_team`. + +| Parameter | In | Type | Description | +|-----------|----|------|-------------| +| `id` | path | uuid | The invitation id. | + +#### Response + +```json +{ "token": "inv_token_abc123" } +``` + +### Transfer ownership + +`POST /organization/transfer-ownership` + +Transfers organization ownership to another member. + +Auth: Session only (not available to API keys). **Org permission** `transfer_ownership`. + +#### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `new_owner_user_id` | uuid | yes | The member's user id to promote to owner. | + +```json +{ "new_owner_user_id": "7b88..." } +``` + +#### Response + +```json +{ "message": "ownership transferred" } +``` + +### Upload organization avatar + +`POST /organization/avatar` + +Uploads the workspace image. `multipart/form-data` with a single `file` field. Owner only. PNG or JPG, max 2 MB, max 1024x1024 px. + +Auth: Session only (not available to API keys). Requires a selected organization (owner only). + +#### Response + +```json +{ "avatar_url": "https://warmbly-assets.s3.amazonaws.com/avatars/organizations/3d11-1718193600.jpg" } +``` + +### Remove organization avatar + +`DELETE /organization/avatar` + +Clears the workspace image. Owner only. Returns `204 No Content`. + +Auth: Session only (not available to API keys). Requires a selected organization (owner only). + +### Get organization danger-zone status + +`GET /organization/current/danger-zone` + +Returns the workspace danger-zone summary plus any pending deletion. + +Auth: Session only (not available to API keys). Requires a selected organization. + +#### Response + +Same shape as the account danger zone, with `resource_type` of `organization` and the org name as the confirmation hint. + +### Schedule organization deletion + +`POST /organization/current/danger-zone/delete` + +Schedules the current organization for a delayed hard delete. Owner only; the confirmation must match the org name. Returns `202 Accepted` with the scheduled-deletion record. + +Auth: Session only (not available to API keys). Requires a selected organization (owner only). + +#### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `confirmation` | string | yes | Must equal the organization name. | +| `reason` | string | no | Optional free-text reason. | + +```json +{ "confirmation": "Acme Outbound" } +``` + +### Cancel organization deletion + +`DELETE /organization/current/danger-zone/delete` + +Cancels a pending organization deletion. + +Auth: Session only (not available to API keys). Requires a selected organization. + +#### Request body (optional) + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `reason` | string | no | Optional free-text reason. | + +#### Response + +```json +{ "message": "deletion cancelled" } +``` + +### Submit a limit-increase request + +`POST /organization/:orgId/limit-requests` + +Submits a request to raise one of the organization's limits. The current effective value is captured server-side at submission time. Returns `201 Created`. + +Auth: Session only (not available to API keys). + +| Parameter | In | Type | Description | +|-----------|----|------|-------------| +| `orgId` | path | uuid | The organization id. | + +#### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `field` | string | yes | A limit field (for example `max_email_accounts`, `daily_campaign_limit`). | +| `requested` | int | yes | The requested value, must be greater than the current effective limit. | +| `reason` | string | yes | 1 to 2000 characters. | + +```json +{ + "field": "max_email_accounts", + "requested": 50, + "reason": "Onboarding a new sales team next month." +} +``` + +#### Response + +```json +{ + "id": "lr1...", + "organization_id": "3d11...", + "field": "max_email_accounts", + "current_effective": 25, + "requested": 50, + "reason": "Onboarding a new sales team next month.", + "status": "pending", + "submitted_by": "9c2a...", + "submitted_at": "2026-06-12T09:00:00Z", + "review_notes": "" +} +``` + +### List limit requests + +`GET /organization/:orgId/limit-requests` + +Returns the organization's limit-increase requests. + +Auth: Session only (not available to API keys). + +| Parameter | In | Type | Description | +|-----------|----|------|-------------| +| `orgId` | path | uuid | The organization id. | + +#### Response + +```json +{ "data": [ { "id": "lr1...", "field": "max_email_accounts", "status": "pending", "requested": 50 } ] } +``` + +### Cancel a limit request + +`DELETE /limit-requests/:id` + +Cancels a pending limit request. Submitter only. Returns `204 No Content`. + +Auth: Session only (not available to API keys). + +| Parameter | In | Type | Description | +|-----------|----|------|-------------| +| `id` | path | uuid | The limit-request id. | + +## Invitations (for the invitee) + +These sit outside `/organization` because they act on the signed-in user's own pending invitations. + +### List my pending invitations + +`GET /invitations` + +Returns invitations addressed to the caller's email. + +Auth: Session only (not available to API keys). + +#### Response + +```json +{ + "data": [ + { + "id": "inv1...", + "organization_id": "3d11...", + "email": "alex@acme.com", + "role": "member", + "expires_at": "2026-06-19T09:00:00Z", + "created_at": "2026-06-12T09:00:00Z", + "organization": { "id": "3d11...", "name": "Acme Outbound" } + } + ] +} +``` + +### Accept an invitation + +`POST /invitations/accept` + +Accepts an invitation, either by the secret token (from a `/invite` link) or by the invitation id (from the caller's own pending list). Returns the new membership. + +Auth: Session only (not available to API keys). + +#### Request body + +Exactly one of `token` or `invitation_id` is required. + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `token` | string | conditional | The secret token from a `/invite` link. | +| `invitation_id` | uuid | conditional | The invitation id from the caller's pending list. | + +```json +{ "token": "inv_token_abc123" } +``` + +#### Response + +```json +{ + "message": "invitation accepted", + "member": { + "id": "ab13...", + "organization_id": "3d11...", + "user_id": "9c2a...", + "role": "member", + "permissions": 12288, + "email": "alex@acme.com", + "name": "Alex Rivera" + } +} +``` + +## Teams + +Teams group existing organization members under a named, color-tagged label (used for CRM ownership and routing). Unlike the rest of this group, the team endpoints accept either a JWT or an API key, and all require a selected organization. Reads map to the CRM read scope and writes to the CRM write scope. + +### List teams + +`GET /teams` + +Returns the organization's teams, each hydrated with its members. + +Auth: **Scope** `READ_CRM` · **Org permission** `view_contacts`. Requires a selected organization. + +#### Response + +```json +{ + "data": [ + { + "id": "t1...", + "organization_id": "3d11...", + "name": "West coast", + "color": "#0ea5e9", + "members": [ + { "user_id": "9c2a...", "email": "alex@acme.com", "name": "Alex Rivera", "added_at": "2026-06-01T09:00:00Z" } + ], + "created_at": "2026-05-01T09:00:00Z", + "updated_at": "2026-06-01T09:00:00Z" + } + ] +} +``` + +### Create a team + +`POST /teams` + +Creates a team. Returns `201 Created` with the team. + +Auth: **Scope** `WRITE_CRM` · **Org permission** `manage_team`. Requires a selected organization. + +#### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `name` | string | yes | 1 to 255 characters. | +| `color` | string | no | Hex color (defaults to `#94a3b8`). | + +```json +{ "name": "West coast", "color": "#0ea5e9" } +``` + +#### Response + +The created team object (members starts empty). + +### Get a team + +`GET /teams/:id` + +Returns a single team with its members. + +Auth: **Scope** `READ_CRM` · **Org permission** `view_contacts`. Requires a selected organization. + +| Parameter | In | Type | Description | +|-----------|----|------|-------------| +| `id` | path | uuid | The team id. | + +### Update a team + +`PATCH /teams/:id` + +Partial-updates a team's name or color. Nil fields are left untouched. + +Auth: **Scope** `WRITE_CRM` · **Org permission** `manage_team`. Requires a selected organization. + +| Parameter | In | Type | Description | +|-----------|----|------|-------------| +| `id` | path | uuid | The team id. | + +#### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `name` | string | no | New name. | +| `color` | string | no | New color. | + +#### Response + +The updated team object. + +### Delete a team + +`DELETE /teams/:id` + +Deletes a team. Returns `204 No Content`. + +Auth: **Scope** `WRITE_CRM` · **Org permission** `manage_team`. Requires a selected organization. + +| Parameter | In | Type | Description | +|-----------|----|------|-------------| +| `id` | path | uuid | The team id. | + +### Add a team member + +`POST /teams/:id/members` + +Adds an existing organization member to the team. Returns the updated team. + +Auth: **Scope** `WRITE_CRM` · **Org permission** `manage_team`. Requires a selected organization. + +| Parameter | In | Type | Description | +|-----------|----|------|-------------| +| `id` | path | uuid | The team id. | + +#### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `user_id` | uuid | yes | The member's user id (must already belong to the organization). | + +```json +{ "user_id": "7b88..." } +``` + +#### Response + +The updated team object, including the new member. + +### Remove a team member + +`DELETE /teams/:id/members/:userId` + +Removes a member from the team. Returns `204 No Content`. + +Auth: **Scope** `WRITE_CRM` · **Org permission** `manage_team`. Requires a selected organization. + +| Parameter | In | Type | Description | +|-----------|----|------|-------------| +| `id` | path | uuid | The team id. | +| `userId` | path | uuid | The member's user id. | + +## Subscription and billing + +The subscription is per-organization. These endpoints read the current subscription, trial, feature access, manage Stripe checkout and the billing portal, change plans, validate discount codes, and submit enterprise inquiries. All of `/subscription/*` is session only. + +### Get subscription + +`GET /subscription` + +Returns the current organization's subscription. + +Auth: Session only (not available to API keys). Requires a selected organization. + +#### Response + +```json +{ + "id": "sub1...", + "user_id": "9c2a...", + "organization_id": "3d11...", + "plan_id": "plan_pro...", + "stripe_customer_id": "cus_...", + "stripe_subscription_id": "sub_...", + "status": "active", + "current_period_start": "2026-06-01T00:00:00Z", + "current_period_end": "2026-07-01T00:00:00Z", + "cancel_at_period_end": false, + "is_enterprise": false, + "plan": { "id": "plan_pro...", "name": "Pro", "price": 99, "duration": "month" }, + "created_at": "2026-04-20T12:00:00Z", + "updated_at": "2026-06-01T00:00:00Z" +} +``` + +### Get subscription with limits + +`GET /subscription/limits` + +Returns the subscription plus the plan's rate limits. + +Auth: Session only (not available to API keys). Requires a selected organization. + +#### Response + +The subscription object as above, extended with the plan's limit fields. + +### Get trial status + +`GET /subscription/trial` + +Returns the organization's free-trial status. + +Auth: Session only (not available to API keys). Requires a selected organization. + +#### Response + +The trial status object (whether a trial is active, when it ends, whether it has been used). + +### Get feature status + +`GET /subscription/features` + +Returns the organization's feature-access status plus convenience flags for the major gated features. + +Auth: Session only (not available to API keys). Requires a selected organization. + +#### Response + +```json +{ + "subscription": { "status": "active", "plan": "Pro" }, + "can_send_campaigns": true, + "can_use_warmup": true, + "can_use_unibox": true +} +``` + +### Create a checkout session + +`POST /subscription/checkout` + +Creates a Stripe checkout session for a price and returns the redirect URL. + +Auth: Session only (not available to API keys). Requires a selected organization. + +#### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `price_id` | string | yes | The Stripe price id to subscribe to. | +| `success_url` | string | yes | Redirect target after successful checkout. | +| `cancel_url` | string | yes | Redirect target if the user cancels. | +| `discount_code` | string | no | A discount code to apply. | + +```json +{ + "price_id": "price_123", + "success_url": "https://app.warmbly.com/billing?status=success", + "cancel_url": "https://app.warmbly.com/billing?status=cancel" +} +``` + +#### Response + +```json +{ + "session_id": "cs_test_...", + "checkout_url": "https://checkout.stripe.com/c/pay/cs_test_..." +} +``` + +### Validate a discount code + +`POST /subscription/discount/validate` + +Previews whether a discount code is valid for the current organization (optionally against a target plan), returning the discount details for display. + +Auth: Session only (not available to API keys). Requires a selected organization. + +#### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `code` | string | yes | The discount code. | +| `plan_id` | uuid | no | A target plan to compute the discounted amount against. | + +```json +{ "code": "LAUNCH20", "plan_id": "plan_pro..." } +``` + +#### Response + +```json +{ + "valid": true, + "code": "LAUNCH20", + "type": "percent", + "percent_off": 20, + "duration": "once" +} +``` + +When invalid, `valid` is `false` and `reason` explains why. + +### Create a billing portal session + +`POST /subscription/portal` + +Creates a Stripe billing portal session for the organization's customer and returns the portal URL. + +Auth: Session only (not available to API keys). Requires a selected organization. + +#### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `return_url` | string | yes | Where Stripe returns the user after the portal. | + +```json +{ "return_url": "https://app.warmbly.com/billing" } +``` + +#### Response + +```json +{ "portal_url": "https://billing.stripe.com/p/session/..." } +``` + +### Cancel subscription + +`POST /subscription/cancel` + +Cancels the organization's subscription, either at period end or immediately. + +Auth: Session only (not available to API keys). Requires a selected organization. + +#### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `cancel_at_period_end` | boolean | no | When true, cancel at the end of the current period instead of immediately. | + +```json +{ "cancel_at_period_end": true } +``` + +#### Response + +```json +{ "message": "subscription cancelled" } +``` + +### Change plan + +`POST /subscription/change-plan` + +Changes the organization's plan with proration. + +Auth: Session only (not available to API keys). **Org permission** `manage_billing`. Requires a selected organization. + +#### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `plan_id` | uuid | yes | The target plan id. | +| `proration_behavior` | string | no | One of `create_prorations`, `always_invoice`, `none`. | +| `discount_code` | string | no | A discount code to apply. | +| `interval` | string | no | `month` or `year` (defaults to monthly). | + +```json +{ + "plan_id": "plan_scale...", + "proration_behavior": "create_prorations", + "interval": "year" +} +``` + +#### Response + +```json +{ + "message": "plan changed successfully", + "subscription": { "id": "sub1...", "plan_id": "plan_scale...", "status": "active" } +} +``` + +### Preview a plan change + +`GET /subscription/preview-change` + +Previews the proration for a plan change without applying it. + +Auth: Session only (not available to API keys). **Org permission** `manage_billing`. Requires a selected organization. + +| Parameter | In | Type | Description | +|-----------|----|------|-------------| +| `new_plan_id` | query | uuid | The target plan id. | + +#### Response + +The proration preview object (line items, immediate charge, and next invoice amount). + +### Submit an enterprise inquiry + +`POST /subscription/enterprise-inquiry` + +Submits an enterprise pricing inquiry. Returns a confirmation plus the new inquiry id. + +Auth: Session only (not available to API keys). + +#### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `company_name` | string | yes | Company name. | +| `contact_name` | string | yes | Contact name. | +| `contact_email` | string | yes | Contact email. | +| `estimated_volume` | int | no | Estimated monthly email volume. | +| `team_size` | int | no | Team size. | +| `notes` | string | no | Free-text notes. | + +```json +{ + "company_name": "Acme Inc", + "contact_name": "Alex Rivera", + "contact_email": "alex@acme.com", + "estimated_volume": 50000, + "team_size": 25 +} +``` + +#### Response + +```json +{ + "message": "Thank you! Our team will contact you within 24 hours.", + "inquiry_id": "eiq1..." +} +``` + +## Reference data + +Two read-only reference endpoints sit alongside billing. Unlike the rest of this group they accept any authenticated key (JWT or API key); auth only exists to keep them from being scraped. + +### List plans + +`GET /plans` + +Returns the available public subscription plans. + +Auth: any authenticated caller (JWT or API key). + +#### Response + +```json +{ + "plans": [ + { "id": "plan_pro...", "name": "Pro", "price": 99, "duration": "month", "public": true } + ] +} +``` + +### List timezones + +`GET /timezones` + +Returns the supported timezone identifiers (for campaign schedule windows and the like). + +Auth: any authenticated caller (JWT or API key). + +## See also + +- [Authentication](/api/authentication/) +- [Permissions reference](/api/permissions/) +- [Error codes](/api/error-codes/) diff --git a/docs/content/docs/api/reference/analytics.mdx b/docs/content/docs/api/reference/analytics.mdx new file mode 100644 index 00000000..902be937 --- /dev/null +++ b/docs/content/docs/api/reference/analytics.mdx @@ -0,0 +1,512 @@ +--- +title: Analytics and audit +description: Read dashboard, deliverability, warmup, campaign, account, and usage analytics, plus your organization's audit trail. +icon: BarChart3 +--- + +The analytics endpoints expose the same rollups that power the dashboard: an org-wide overview, deliverability posture, warmup progress, per-campaign performance (with daily and hourly breakdowns and side-by-side comparison), mailbox health, and account usage. The audit endpoint returns your organization's activity trail ("who did what, when, from where"). All of these are read-only. + +Every analytics route shares one auth gate: **Scope** `READ_ANALYTICS` · **Org permission** `view_analytics`. The audit route uses the same org permission with a dedicated scope. See [permissions](/api/permissions/) for the scope reference and [authentication](/api/authentication/) for how to present credentials. + +Dates are parsed as `YYYY-MM-DD` unless noted. Errors follow the standard `{error, message, code, request_id}` envelope documented in [error codes](/api/error-codes/). + +## Get dashboard analytics + +`GET /analytics/dashboard` + +Returns the main dashboard overview for the active organization: aggregate stats, recent activity, top campaigns, account health, and a daily trend series. Auth: **Scope** `READ_ANALYTICS` · **Org permission** `view_analytics`. + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `period` | query | string | One of `7d`, `30d`, `90d`. Defaults to `7d`; any other value falls back to `7d`. | + +### Response + +```json +{ + "period": "7d", + "overall_stats": { + "total_emails_sent": 1240, + "total_opens": 612, + "machine_opens": 88, + "total_clicks": 143, + "total_replies": 57, + "total_bounces": 9, + "open_rate": 49.35, + "click_rate": 11.53, + "reply_rate": 4.6, + "bounce_rate": 0.73, + "active_campaigns": 4, + "active_accounts": 12 + }, + "recent_activity": [ + { + "type": "replied", + "campaign_id": "b1f2c3d4-0000-0000-0000-000000000001", + "campaign_name": "Q2 outbound", + "contact_email": "lead@example.com", + "contact_id": "c0ffee00-0000-0000-0000-000000000002", + "timestamp": "2026-06-11T14:02:11Z" + } + ], + "top_campaigns": [ + { + "campaign_id": "b1f2c3d4-0000-0000-0000-000000000001", + "name": "Q2 outbound", + "status": "active", + "emails_sent": 820, + "open_rate": 51.2, + "click_rate": 12.1, + "reply_rate": 5.0 + } + ], + "account_health": { + "total_accounts": 12, + "healthy_accounts": 10, + "warning_accounts": 1, + "error_accounts": 1 + }, + "daily_trend": [ + { "date": "2026-06-05", "sent": 160, "opens": 79, "clicks": 18, "replies": 7 } + ] +} +``` + +## Get deliverability dashboard + +`GET /analytics/deliverability` + +Returns the organization's deliverability posture for a time window: bounce, complaint, open, click, and reply counts and rates, suppression and dead-letter pressure, reply-intent breakdown, seed inbox-placement, an overall health band (from the documented thresholds), a daily timeseries, and per-mailbox and per-campaign breakdowns. Requires an organization context. Auth: **Scope** `READ_ANALYTICS` · **Org permission** `view_analytics`. + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `from` | query | string | Window start as an RFC 3339 timestamp. Defaults to 7 days ago (UTC). | +| `to` | query | string | Window end as an RFC 3339 timestamp. Defaults to now (UTC). | + +### Response + +```json +{ + "from": "2026-06-05T00:00:00Z", + "to": "2026-06-12T00:00:00Z", + "events_total": 1380, + "bounce_count": 9, + "complaint_count": 1, + "unsubscribe_count": 4, + "reply_count": 57, + "open_count": 612, + "click_count": 143, + "suppressed_recipients": 21, + "dlq_pending": 0, + "intent_positive": 18, + "intent_negative": 6, + "intent_out_of_office": 11, + "intent_question": 9, + "intent_neutral": 13, + "emails_sent": 1240, + "bounce_rate": 0.73, + "complaint_rate": 0.08, + "open_rate": 49.35, + "click_rate": 11.53, + "reply_rate": 4.6, + "spam_placement_rate": 6.5, + "inbox_placement_rate": 93.5, + "placement_samples": 40, + "band": "healthy", + "timeseries": [ + { + "date": "2026-06-05", + "sent": 160, + "bounces": 1, + "complaints": 0, + "opens": 79, + "clicks": 18, + "replies": 7, + "unsubscribes": 1 + } + ], + "by_mailbox": [ + { + "email_account_id": "a0a1a2a3-0000-0000-0000-000000000003", + "email": "sales@yourdomain.com", + "sent": 420, + "bounces": 2, + "complaints": 0, + "bounce_rate": 0.48, + "complaint_rate": 0.0, + "band": "healthy" + } + ], + "by_campaign": [ + { + "campaign_id": "b1f2c3d4-0000-0000-0000-000000000001", + "name": "Q2 outbound", + "sent": 820, + "bounces": 5, + "complaints": 1, + "bounce_rate": 0.61, + "complaint_rate": 0.12, + "band": "watch" + } + ] +} +``` + +`spam_placement_rate` and `inbox_placement_rate` are omitted when there are no seed samples in the window. + +## Get warmup analytics + +`GET /analytics/warmup` + +Returns warmup send and reply statistics over a date range, with a summary and per-day series. Optionally scoped to a single mailbox. Auth: **Scope** `READ_ANALYTICS` · **Org permission** `view_analytics`. + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `from` | query | string | Required. Range start (`YYYY-MM-DD`). | +| `to` | query | string | Required. Range end (`YYYY-MM-DD`). | +| `email_id` | query | string (uuid) | Optional. Limit to one email account. Invalid UUIDs are ignored. | + +### Response + +```json +{ + "email_account_id": "a0a1a2a3-0000-0000-0000-000000000003", + "email": "", + "date_range": { + "from": "2026-06-01T00:00:00Z", + "to": "2026-06-12T00:00:00Z" + }, + "summary": { + "total_sent": 210, + "total_replied": 84, + "average_daily": 17.5, + "reply_rate": 40.0, + "target_progress": 0, + "days_active": 12 + }, + "daily_stats": [ + { "date": "2026-06-01", "emails_sent": 12, "emails_replied": 5, "target_volume": 12 } + ] +} +``` + +`email_account_id` is the zero UUID when no `email_id` filter is supplied. + +## Get campaign analytics + +`GET /analytics/campaigns/:id` + +Returns a single campaign's performance summary plus per-sequence-step stats. The campaign must belong to the caller. Auth: **Scope** `READ_ANALYTICS` · **Org permission** `view_analytics`. + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `id` | path | string (uuid) | Campaign id. | + +### Response + +```json +{ + "campaign_id": "b1f2c3d4-0000-0000-0000-000000000001", + "name": "Q2 outbound", + "status": "active", + "date_range": { "from": "0001-01-01T00:00:00Z", "to": "0001-01-01T00:00:00Z" }, + "summary": { + "total_contacts": 500, + "emails_sent": 820, + "emails_pending": 60, + "unique_opens": 410, + "machine_opens": 52, + "unique_clicks": 99, + "replies": 41, + "bounces": 5, + "unsubscribes": 3, + "open_rate": 50.0, + "click_rate": 12.07, + "reply_rate": 5.0, + "bounce_rate": 0.61 + }, + "steps": [ + { + "step_id": "5e9e0001-0000-0000-0000-000000000004", + "name": "Intro", + "position": 1, + "emails_sent": 500, + "opens": 260, + "clicks": 61, + "replies": 28, + "bounces": 3 + } + ] +} +``` + +`machine_opens` is the subset of `unique_opens` from automated fetchers (Apple MPP prefetch, UA-less clients); human opens are `unique_opens` minus `machine_opens`. + +## Get campaign daily stats + +`GET /analytics/campaigns/:id/daily` + +Returns per-day send, open, click, and reply counts for one campaign over a date range. The campaign must belong to the caller. Auth: **Scope** `READ_ANALYTICS` · **Org permission** `view_analytics`. + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `id` | path | string (uuid) | Campaign id. | +| `from` | query | string | Required. Range start (`YYYY-MM-DD`). | +| `to` | query | string | Required. Range end (`YYYY-MM-DD`). | + +### Response + +The series is returned under a `data` envelope. + +```json +{ + "data": [ + { "date": "2026-06-05", "sent": 120, "opens": 61, "clicks": 14, "replies": 6 }, + { "date": "2026-06-06", "sent": 110, "opens": 58, "clicks": 12, "replies": 5 } + ] +} +``` + +## Get campaign hourly stats + +`GET /analytics/campaigns/:id/hourly` + +Returns per-hour stats for one campaign on a single day. The campaign must belong to the caller. Auth: **Scope** `READ_ANALYTICS` · **Org permission** `view_analytics`. + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `id` | path | string (uuid) | Campaign id. | +| `date` | query | string | Day to report (`YYYY-MM-DD`). Defaults to today. | + +### Response + +The series is returned under a `data` envelope, with the resolved `date` echoed back. Each item's `hour` is `0`-`23`. + +```json +{ + "data": [ + { "hour": 9, "sent": 22, "opens": 11, "clicks": 3, "replies": 1 }, + { "hour": 10, "sent": 30, "opens": 16, "clicks": 4, "replies": 2 } + ], + "date": "2026-06-11" +} +``` + +## Compare campaigns + +`GET /analytics/campaigns/compare` + +Returns side-by-side performance for up to 10 campaigns over a date range. Every requested campaign must belong to the caller. Auth: **Scope** `READ_ANALYTICS` · **Org permission** `view_analytics`. + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `ids` | query | string | Required. Comma-separated campaign UUIDs. Invalid entries are dropped; the list is capped at 10. At least one valid id is required. | +| `from` | query | string | Required. Range start (`YYYY-MM-DD`). | +| `to` | query | string | Required. Range end (`YYYY-MM-DD`). | + +### Response + +```json +{ + "campaigns": [ + { + "campaign_id": "b1f2c3d4-0000-0000-0000-000000000001", + "name": "Q2 outbound", + "status": "active", + "emails_sent": 820, + "open_rate": 50.0, + "click_rate": 12.07, + "reply_rate": 5.0, + "bounce_rate": 0.61 + }, + { + "campaign_id": "b1f2c3d4-0000-0000-0000-000000000005", + "name": "Reactivation", + "status": "paused", + "emails_sent": 410, + "open_rate": 44.1, + "click_rate": 9.8, + "reply_rate": 3.4, + "bounce_rate": 1.2 + } + ], + "period": { + "from": "2026-06-01T00:00:00Z", + "to": "2026-06-12T00:00:00Z" + } +} +``` + +## List account statuses + +`GET /analytics/accounts` + +Returns the health and usage status of every email account the caller owns. Accounts whose status fails to build are skipped. Auth: **Scope** `READ_ANALYTICS` · **Org permission** `view_analytics`. + +### Response + +The list is returned under a `data` envelope (no cursor; all of the caller's accounts are included). Each item has the same shape as [get account status](#get-account-status). + +```json +{ + "data": [ + { + "id": "a0a1a2a3-0000-0000-0000-000000000003", + "email": "sales@yourdomain.com", + "provider": "google", + "status": "active", + "last_synced_at": "2026-06-12T08:00:00Z", + "health": { "status": "healthy", "score": 100, "issues": [] }, + "errors": [], + "daily_usage": { + "date": "2026-06-12", + "campaign_sent": 18, + "campaign_limit": 50, + "warmup_sent": 22, + "warmup_limit": 40 + }, + "in_campaign": true + } + ] +} +``` + +## Get account status + +`GET /analytics/accounts/:id` + +Returns the detailed status for one email account: a combined health score (folding in warmup-pool reputation), active errors, today's usage, warmup status, and warmup-pool health. The account must belong to the caller. Auth: **Scope** `READ_ANALYTICS` · **Org permission** `view_analytics`. + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `id` | path | string (uuid) | Email account id. | + +### Response + +```json +{ + "id": "a0a1a2a3-0000-0000-0000-000000000003", + "email": "sales@yourdomain.com", + "provider": "google", + "status": "active", + "last_synced_at": "2026-06-12T08:00:00Z", + "health": { + "status": "warning", + "score": 90, + "issues": ["Warmup reputation needs watching"] + }, + "errors": [ + { + "id": "e1e1e1e1-0000-0000-0000-000000000006", + "error_code": "IMAP_AUTH", + "severity": "WARNING", + "title": "Mailbox reconnect recommended", + "message": "Token nearing expiry", + "created_at": "2026-06-11T22:14:00Z" + } + ], + "daily_usage": { + "date": "2026-06-12", + "campaign_sent": 18, + "campaign_limit": 50, + "warmup_sent": 22, + "warmup_limit": 40 + }, + "warmup_status": { + "enabled": true, + "paused": false, + "started_at": "2026-05-20T00:00:00Z", + "current_volume": 22, + "target_volume": 33, + "max_volume": 40, + "reply_rate": 35, + "days_active": 23 + }, + "warmup_health": { + "state": "watch", + "score": 78, + "spam_score": 6, + "evaluated_at": "2026-06-12T06:00:00Z" + }, + "in_campaign": true +} +``` + +`warmup_status` is present only when warmup has ever been enabled; `warmup_health` is present only when the mailbox is in a warmup pool. `in_campaign` reports whether the mailbox currently backs a live campaign. + +## Get usage overview + +`GET /analytics/usage` + +Returns account, campaign, contact, and API usage counters for the caller. Auth: **Scope** `READ_ANALYTICS` · **Org permission** `view_analytics`. + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `period` | query | string | One of `day`, `week`, `month`. Defaults to `day`; any other value falls back to `day`. | + +### Response + +```json +{ + "user_id": "11111111-0000-0000-0000-000000000007", + "period": "day", + "email_accounts": { "total": 12, "active": 11, "in_warmup": 8, "with_errors": 1 }, + "campaigns": { "total": 9, "active": 4, "paused": 2, "draft": 3, "emails_sent": 12400 }, + "contacts": { "total": 8200, "subscribed": 8050, "added_today": 120 }, + "api": { "total_calls": 0, "daily_limit": 50000, "top_endpoints": [] } +} +``` + +## List audit logs + +`GET /audit-logs` + +Returns the organization-wide activity trail for the caller's current organization ("who did what, when, from where"). The organization is always taken from the session and never from a client parameter, so one organization can never read another's trail. Auth: **Scope** `READ_AUDIT_LOGS` · **Org permission** `view_analytics`. + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `limit` | query | int | Page size. Defaults to `50`; must be between `10` and `200` or a `400` is returned. | +| `cursor` | query | string (uuid) | Opaque cursor from `pagination.next_cursor`. Invalid cursors return `400`. | +| `actor_id` | query | string (uuid) | Filter to a single acting member. | +| `entity_id` | query | string (uuid) | Filter to a single entity. | +| `entity_type` | query | string | Filter by entity type (for example `campaign`, `contact`, `email_account`, `api_key`, `webhook`). | +| `action` | query | string | Filter by action (for example `create`, `update`, `delete`, `send`, `revoke`). | +| `date` | query | string | Single-day filter (`YYYY-MM-DD`), expanded to that whole UTC day. | +| `start_date` | query | string | Range start. RFC 3339 or `YYYY-MM-DD`. Overrides `date`. | +| `end_date` | query | string | Range end. RFC 3339 or `YYYY-MM-DD`. Overrides `date`. | + +### Response + +A `data` plus `pagination` envelope with an opaque cursor. `actor` is null when the acting user has since been deleted; `entity_id`, `changes`, and `metadata` are omitted when empty. + +```json +{ + "data": [ + { + "id": "9c9c9c9c-0000-0000-0000-000000000008", + "org_id": "0a0a0a0a-0000-0000-0000-000000000009", + "user_id": "11111111-0000-0000-0000-000000000007", + "actor": { + "id": "11111111-0000-0000-0000-000000000007", + "first_name": "Ada", + "last_name": "Lovelace", + "email": "ada@yourdomain.com" + }, + "action_date": "2026-06-12T08:14:00Z", + "action": "update", + "entity_type": "campaign", + "entity_id": "b1f2c3d4-0000-0000-0000-000000000001", + "ip_address": "203.0.113.10", + "user_agent": "Mozilla/5.0", + "changes": { "status": "paused" }, + "timestamp": "2026-06-12T08:14:00Z" + } + ], + "pagination": { + "next_cursor": "c1_b3BhcXVlLWN1cnNvcg", + "has_more": true + } +} +``` + +Secret values (API key material, webhook secrets, passwords) are never recorded in `changes` or `metadata`; the trail records only that a field changed. diff --git a/docs/content/docs/api/reference/api-keys.mdx b/docs/content/docs/api/reference/api-keys.mdx new file mode 100644 index 00000000..b0ac0b6c --- /dev/null +++ b/docs/content/docs/api/reference/api-keys.mdx @@ -0,0 +1,391 @@ +--- +title: API keys +description: Create, inspect, rotate, and revoke programmatic API keys and read their usage analytics. +icon: KeyRound +--- + +API keys are how integrations authenticate to the Warmbly API. Each key belongs to an organization, carries a permission bitmask that scopes what it can do, and can be restricted to specific source IPs or specific mailboxes. This group is self-service: a key that holds the `API_KEYS` scope can manage its own organization's keys without going through the dashboard, so an integration can rotate its credentials programmatically. + +Every endpoint in this group requires both **Scope** `API_KEYS` (for API-key callers) and **Org permission** `manage_api_keys` (for session/JWT callers). All routes are organization-scoped and write rate-limited. + +## The permission bitmask + +A key's `permissions` field is a `uint64` bitmask. Each grant is a single bit, and a key is allowed to perform a request only when its mask contains every bit the route requires. Combine bits with bitwise OR. The full list of bit names and values, along with the `read_only` and `full_access` presets, is available from the [permissions endpoint](#list-available-permissions) below and documented in [API permissions](/api/permissions/). Unknown bits are rejected on create so a stale client cannot accidentally grant a future scope. + +## The plaintext secret is shown once + +When you create a key, the response includes a `secret` field containing the full plaintext key. This is the only time the secret is ever returned. Warmbly stores only a hash plus a short prefix and suffix for display, so the plaintext cannot be recovered later. Capture it at creation time and store it securely. If it is lost, revoke the key and create a new one. See [Authentication](/api/authentication/) for how to present the key on requests. + +## List API keys + +`GET /api-keys` + +Returns the organization's API keys, newest first, with the secret never included. + +Auth: **Scope** `API_KEYS` · **Org permission** `manage_api_keys` + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `cursor` | query | string | Opaque cursor from the previous page's `pagination.next_cursor`. Omit for the first page. | +| `limit` | query | integer | Page size, 1 to 100. Defaults to 50. Out-of-range or invalid values fall back to the default. | + +### Response + +A `data` plus `pagination` envelope. Each item is an API key without its secret. + +```json +{ + "data": [ + { + "id": "0b2d5e7a-1c3f-4a9b-8c2d-1e2f3a4b5c6d", + "user_id": "9f8e7d6c-5b4a-3210-fedc-ba9876543210", + "organization_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "name": "Zapier production", + "description": "Lead sync from the marketing site", + "key_prefix": "wmbly_3f", + "key_suffix": "9f3a", + "permissions": 8447, + "allowed_ips": ["203.0.113.10"], + "allowed_email_accounts": [], + "rate_limit_per_minute": 120, + "status": "active", + "last_used_at": "2026-06-11T18:42:10Z", + "last_request_ip": "203.0.113.10", + "expires_at": null, + "created_at": "2026-05-01T09:00:00Z", + "updated_at": "2026-06-11T18:42:10Z" + } + ], + "pagination": { + "total": null, + "next_cursor": "c1_b3BhcXVlLWN1cnNvcg", + "has_more": true + } +} +``` + +## Create an API key + +`POST /api-keys` + +Creates a new key and returns the plaintext secret exactly once (see the note above). + +Auth: **Scope** `API_KEYS` · **Org permission** `manage_api_keys` + +### Request body + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `name` | string | yes | Human-readable label, up to 255 characters. | +| `description` | string | no | Free-form note about the key's purpose. | +| `permissions` | integer (uint64) | yes | The permission bitmask. Must contain only defined bits; unknown bits are rejected. | +| `allowed_ips` | string array | no | If set, the key is usable only from these source IPs. Omit or leave empty to allow any IP. | +| `allowed_email_accounts` | uuid array | no | If set, mailbox-scoped routes accept only these email account ids. | +| `rate_limit_per_minute` | integer | no | Per-key sliding-window request cap. Omit or send `0` to use the default (60 r/m). | +| `expires_at` | string (RFC3339) | no | When the key should stop working. Omit for a non-expiring key. | + +```json +{ + "name": "Zapier production", + "description": "Lead sync from the marketing site", + "permissions": 8447, + "allowed_ips": ["203.0.113.10"], + "rate_limit_per_minute": 120, + "expires_at": "2027-01-01T00:00:00Z" +} +``` + +### Response + +`201 Created`. The full key object plus the one-time `secret`. Everything except `secret` matches the shape returned by the list and get endpoints. + +```json +{ + "id": "0b2d5e7a-1c3f-4a9b-8c2d-1e2f3a4b5c6d", + "user_id": "9f8e7d6c-5b4a-3210-fedc-ba9876543210", + "organization_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "name": "Zapier production", + "description": "Lead sync from the marketing site", + "key_prefix": "wmbly_3f", + "key_suffix": "9f3a", + "permissions": 8447, + "allowed_ips": ["203.0.113.10"], + "rate_limit_per_minute": 120, + "status": "active", + "expires_at": "2027-01-01T00:00:00Z", + "created_at": "2026-06-11T19:00:00Z", + "updated_at": "2026-06-11T19:00:00Z", + "secret": "wmbly_3f9a...the-only-time-you-see-this...9f3a" +} +``` + +This endpoint mutates state and supports [`Idempotency-Key`](/api/authentication/) for safe retries. + +## List available permissions + +`GET /api-keys/permissions` + +Returns the catalog of permission bits and the built-in presets, so a client can render a picker or grant a sane default without hard-coding values. + +Auth: **Scope** `API_KEYS` · **Org permission** `manage_api_keys` + +### Response + +An object with a `permissions` array (each entry carries its `name`, numeric `value`, `description`, and `category` of `read`, `write`, `bulk`, or `special`) and a `presets` object with the `read_only` and `full_access` masks. + +```json +{ + "permissions": [ + { + "name": "READ_EMAILS", + "value": 1, + "description": "View email accounts and settings", + "category": "read" + }, + { + "name": "WRITE_CAMPAIGNS", + "value": 64, + "description": "Create and modify campaigns and sequences", + "category": "write" + } + // ... one entry per defined permission bit + ], + "presets": { + "read_only": 4329731, + "full_access": 8388607 + } +} +``` + +## Get an API key + +`GET /api-keys/:id` + +Returns a single key by id. The secret is never included. + +Auth: **Scope** `API_KEYS` · **Org permission** `manage_api_keys` + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `id` | path | uuid | The API key id. | + +### Response + +The key object, identical in shape to one element of the list `data` array. + +```json +{ + "id": "0b2d5e7a-1c3f-4a9b-8c2d-1e2f3a4b5c6d", + "user_id": "9f8e7d6c-5b4a-3210-fedc-ba9876543210", + "organization_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "name": "Zapier production", + "description": "Lead sync from the marketing site", + "key_prefix": "wmbly_3f", + "key_suffix": "9f3a", + "permissions": 8447, + "allowed_ips": ["203.0.113.10"], + "rate_limit_per_minute": 120, + "status": "active", + "last_used_at": "2026-06-11T18:42:10Z", + "last_request_ip": "203.0.113.10", + "expires_at": null, + "created_at": "2026-05-01T09:00:00Z", + "updated_at": "2026-06-11T18:42:10Z" +} +``` + +## Update an API key + +`PATCH /api-keys/:id` + +Updates the mutable fields of a key. Every field is optional; only the fields you send are changed. You cannot rotate the secret here (create a new key and revoke the old one instead). + +Auth: **Scope** `API_KEYS` · **Org permission** `manage_api_keys` + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `id` | path | uuid | The API key id. | + +### Request body + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `name` | string | no | New label. | +| `description` | string | no | New description. | +| `permissions` | integer (uint64) | no | Replacement permission bitmask. | +| `allowed_ips` | string array | no | Replacement IP allowlist. | +| `allowed_email_accounts` | uuid array | no | Replacement mailbox allowlist. | +| `rate_limit_per_minute` | integer | no | New per-key rate cap (`0` means use the default). | + +```json +{ + "description": "Lead sync, now read-only", + "permissions": 4329731, + "rate_limit_per_minute": 60 +} +``` + +### Response + +The updated key object, same shape as get. + +```json +{ + "id": "0b2d5e7a-1c3f-4a9b-8c2d-1e2f3a4b5c6d", + "name": "Zapier production", + "description": "Lead sync, now read-only", + "permissions": 4329731, + "rate_limit_per_minute": 60, + "status": "active", + "updated_at": "2026-06-11T19:30:00Z" + // ... remaining key fields unchanged +} +``` + +## Revoke an API key + +`DELETE /api-keys/:id` + +Revokes a key immediately. The key stops authenticating right away; this is not reversible. + +Auth: **Scope** `API_KEYS` · **Org permission** `manage_api_keys` + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `id` | path | uuid | The API key id. | +| `reason` | query | string | Optional revocation note stored on the key. Defaults to `Revoked by user`. | + +### Response + +A small status envelope. + +```json +{ "status": "revoked" } +``` + +## Usage summary + +`GET /api-keys/usage/summary` + +Returns the organization-level usage strip: key counts by status plus a 24-hour request, error, and latency rollup. + +Auth: **Scope** `API_KEYS` · **Org permission** `manage_api_keys` + +### Response + +A single summary object. Counts under the `24h` fields cover the last 24 hours. + +```json +{ + "active_keys": 3, + "revoked_keys": 1, + "expired_keys": 0, + "requests_24h": 14820, + "errors_24h": 37, + "avg_latency_ms_24h": 42.6, + "last_call_at": "2026-06-11T18:42:10Z" +} +``` + +## Usage analytics + +`GET /api-keys/usage/analytics` +`GET /api-keys/:id/analytics` + +Returns a time-bucketed request series plus a per-endpoint breakdown. Both routes share one handler: the org-wide form lives at `/api-keys/usage/analytics`, and the per-key form is `/api-keys/:id/analytics`. You can also pass the literal `:id` value `all` on the per-key route to get the org-wide aggregate. + +Auth: **Scope** `API_KEYS` · **Org permission** `manage_api_keys` + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `id` | path | uuid | The API key id, or the literal `all` for the org-wide aggregate (per-key route only). | +| `from` | query | string (RFC3339) | Start of the window. Defaults to 24 hours before `to`. | +| `to` | query | string (RFC3339) | End of the window. Defaults to now. | +| `interval` | query | string | Bucket granularity: `minute`, `hour`, or `day`. | + +### Response + +An analytics object: `buckets` is the graph series, `endpoints` is the top-endpoints table, and `total` / `errors` are the window totals. For the org-wide aggregate, `api_key_id` is the all-zero UUID. + +```json +{ + "api_key_id": "0b2d5e7a-1c3f-4a9b-8c2d-1e2f3a4b5c6d", + "from": "2026-06-10T19:00:00Z", + "to": "2026-06-11T19:00:00Z", + "interval": "hour", + "buckets": [ + { + "bucket": "2026-06-11T18:00:00Z", + "total": 612, + "success": 605, + "client_errors": 6, + "server_errors": 1, + "avg_latency_ms": 41.2 + } + // ... one bucket per interval + ], + "endpoints": [ + { + "endpoint": "/api/v1/contacts", + "method": "POST", + "count": 980, + "error_count": 4, + "avg_latency_ms": 55.1 + } + ], + "total": 14820, + "errors": 37 +} +``` + +## List per-key usage logs + +`GET /api-keys/:id/logs` + +Returns the recent raw request entries for a single key, newest first. Useful for debugging which requests a key made and how they responded. + +Auth: **Scope** `API_KEYS` · **Org permission** `manage_api_keys` + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `id` | path | uuid | The API key id. | +| `cursor` | query | string | Opaque cursor from the previous page's `pagination.next_cursor`. | +| `limit` | query | integer | Page size, 1 to 200. Defaults to 50. | + +### Response + +A `data` plus `pagination` envelope. Each entry is one recorded request. + +```json +{ + "data": [ + { + "id": "7c1f9a2b-3d4e-5f60-7a8b-9c0d1e2f3a4b", + "api_key_id": "0b2d5e7a-1c3f-4a9b-8c2d-1e2f3a4b5c6d", + "endpoint": "/api/v1/contacts", + "method": "POST", + "ip_address": "203.0.113.10", + "user_agent": "warmbly-zapier/1.4", + "response_code": 201, + "response_time_ms": 48, + "created_at": "2026-06-11T18:42:10Z" + } + ], + "pagination": { + "total": null, + "next_cursor": "c1_b3BhcXVlLWN1cnNvcg", + "has_more": true + } +} +``` + +## Errors + +All endpoints use the shared error envelope with stable `code` and `request_id` fields. Common cases for this group: + +- `400` when no organization is selected, the request body is invalid, or a permission bitmask contains unknown bits. +- `401` when the caller is unauthenticated. +- `403` when the caller lacks the `API_KEYS` scope or the `manage_api_keys` org permission. +- `404` when the `:id` path value is not a valid UUID or the key does not belong to the organization. + +See [Error codes](/api/error-codes/) for the full list. diff --git a/docs/content/docs/api/reference/campaigns.mdx b/docs/content/docs/api/reference/campaigns.mdx new file mode 100644 index 00000000..6c0b9bd0 --- /dev/null +++ b/docs/content/docs/api/reference/campaigns.mdx @@ -0,0 +1,912 @@ +--- +title: Campaigns +description: Create campaigns and sequences, manage senders, A/B variants, attachments, ramp and tracking settings, preflight checks, and start or stop sends. +icon: Megaphone +--- + +Campaigns are the cold outreach unit in Warmbly. A campaign holds sending rules, a schedule, a sender pool, and an ordered list of sequence steps (email or action nodes). These endpoints cover campaign CRUD, the advanced outreach overrides, per-step A/B variants, attachments, the explicit sender pool, preflight and test sends, start and stop, activity logs, campaign-scoped tracking-domain verification, the nested sequence editor, the template preview helper, and the AI writing assistant. + +All errors follow the shared `{error, message, code, request_id}` envelope documented in [error codes](/api/error-codes/). Authentication and the scope model are covered in [authentication](/api/authentication/) and [permissions](/api/permissions/). + +## List campaigns + +`GET /campaigns` + +Search and page through the organization's campaigns. **Scope** `READ_CAMPAIGNS` · **Org permission** `view_campaigns`. + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `q` | query | string | Free-text filter on campaign name. Optional. | +| `folder` | query | string | Restrict to a single folder id. Optional. | +| `cursor` | query | string | Opaque cursor from the previous page's `pagination.next_cursor`. Optional. | +| `limit` | query | string | Page size. Optional. | + +### Response + +A `data` plus `pagination` envelope. `next_cursor` is the campaign id to resume from (`null` on the last page), and `total` is the unfiltered count. + +```json +{ + "data": [ + { + "id": "8f1d6b2e-2b7a-4c9e-9a1f-0e6d4c3b2a10", + "user_id": "a2c4...", + "organization_id": "11111111-2222-3333-4444-555555555555", + "name": "Q3 outbound", + "description": "", + "status": "active", + "stop_on_reply": true, + "open_tracking": true, + "link_tracking": true, + "text_only": false, + "daily_limit": 50, + "unsubscribe_header": true, + "risky_emails": false, + "cc": [], + "bcc": [], + "start_date": null, + "end_date": null, + "timezone": "UTC", + "days": 62, + "start_time": "09:00", + "end_time": "17:00", + "schedule_windows": [[],[{"start":540,"end":1020}],[],[],[],[],[]], + "email_tags": ["sales"], + "folders": [], + "contact_order_by": "created_at", + "contact_order_dir": "asc", + "sender_strategy": "tags", + "rotation_mode": "round_robin", + "ramp_enabled": false, + "ramp_start": 0, + "ramp_increment": 0, + "ramp_ceiling": 0, + "ramp_level": 0, + "esp_match_mode": "off", + "max_new_leads_per_day": 0, + "prioritize_new_leads": false, + "tracking_domain": "", + "tracking_domain_verified": false, + "updated_at": "2026-06-10T12:00:00Z", + "created_at": "2026-06-01T09:00:00Z" + } + ], + "pagination": { + "total": 12, + "next_cursor": "c1_b3BhcXVlLWN1cnNvcg", + "has_more": true + } +} +``` + +## Create a campaign + +`POST /campaigns` + +Create a campaign. Only `name` is required, every other field is optional and applied only when sent (the wizard sends everything at once, a simple modal can send just `{name, description}` and get sane defaults). **Scope** `WRITE_CAMPAIGNS` · **Org permission** `manage_campaigns`. + +### Request body + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `name` | string | yes | Campaign name. | +| `description` | string | no | Free-text description. | +| `stop_on_reply` | boolean | no | Stop sending to a contact once they reply. | +| `open_tracking` | boolean | no | Insert the open pixel. | +| `link_tracking` | boolean | no | Rewrite links through the tracking ticket service. | +| `text_only` | boolean | no | Send plain text only. | +| `daily_limit` | integer | no | Per-campaign daily send cap. | +| `unsubscribe_header` | boolean | no | Add the RFC 8058 one-click unsubscribe header. | +| `risky_emails` | boolean | no | Allow sending to risky/unverified addresses. | +| `cc` | string[] | no | Static CC list. | +| `bcc` | string[] | no | Static BCC list. | +| `start_date` | string (RFC 3339) | no | Earliest send time. | +| `end_date` | string (RFC 3339) | no | Latest send time. | +| `timezone` | string | no | IANA timezone for the schedule. | +| `days` | integer (0-127) | no | Legacy weekday bitmask (superseded by `schedule_windows`). | +| `start_time` | string | no | Legacy daily start (`HH:MM`). | +| `end_time` | string | no | Legacy daily end (`HH:MM`). | +| `email_tag_ids` | string[] | no | Mailbox tag ids that resolve the sender pool (tags strategy). | +| `folder_ids` | string[] | no | Folder ids to file the campaign under. | +| `sender_strategy` | string | no | `tags` (default) or `explicit`. | +| `rotation_mode` | string | no | How volume spreads across the chosen mailboxes. | +| `senders` | object[] | no | Explicit-strategy mailbox pool (see sender input below). | +| `ramp_enabled` | boolean | no | Enable per-campaign daily ramp-up. | +| `ramp_start` | integer | no | Ramp starting volume. | +| `ramp_increment` | integer | no | Daily ramp increment. | +| `ramp_ceiling` | integer | no | Ramp ceiling (never raises above the per-mailbox cap). | +| `esp_match_mode` | string | no | `off`, `prefer`, or `strict`. | +| `max_new_leads_per_day` | integer | no | New-lead throttle, `0` is unlimited. | +| `prioritize_new_leads` | boolean | no | Prefer new leads in each send window. | +| `tracking_domain` | string | no | Campaign-scoped tracking domain (honored only once verified). | +| `sequences` | object[] | no | Initial sequence steps in order (see create sequence input below). | +| `variants` | object[] | no | A/B variants for the first step (same shape as create A/B variant). | +| `advanced_overrides` | object | no | Advanced outreach overrides, see [advanced settings](#get-advanced-settings). | + +`schedule_windows` may also be supplied as a 7-element array (indexed by `time.Weekday`, Sunday = 0) of `{start, end}` minute intervals. When non-empty it supersedes `days`/`start_time`/`end_time`. + +```json +{ + "name": "Q3 outbound", + "description": "Founders in fintech", + "stop_on_reply": true, + "open_tracking": true, + "link_tracking": true, + "daily_limit": 40, + "unsubscribe_header": true, + "timezone": "America/New_York", + "email_tag_ids": ["3b0a...", "9d2c..."], + "sender_strategy": "tags", + "rotation_mode": "round_robin", + "ramp_enabled": true, + "ramp_start": 10, + "ramp_increment": 2, + "ramp_ceiling": 40, + "sequences": [ + { "name": "Step 1", "subject": "Quick question, {{first_name}}", "body_plain": "Hi {{first_name}}...", "wait_after": 0 } + ] +} +``` + +### Response + +The created `Campaign` object (same shape as one element of the [list](#list-campaigns) `data` array). + +## Get a campaign + +`GET /campaigns/:id` + +Fetch a single campaign by id. **Scope** `READ_CAMPAIGNS` · **Org permission** `view_campaigns`. + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `id` | path | uuid | Campaign id. | + +### Response + +A `Campaign` object (see the [list](#list-campaigns) shape). + +## Update a campaign + +`PATCH /campaigns/:id` + +Patch any subset of campaign fields. Omitted fields are left unchanged. The explicit sender list is edited through [replace senders](#replace-senders), only the `sender_strategy`/`rotation_mode` toggles ride this PATCH. **Scope** `WRITE_CAMPAIGNS` · **Org permission** `manage_campaigns`. + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `id` | path | uuid | Campaign id. | + +### Request body + +Every field is optional. Scalar fields use nullable pointers, so any field you send is applied. Notable fields: `name`, `description`, `status`, `stop_on_reply`, `open_tracking`, `link_tracking`, `text_only`, `daily_limit`, `unsubscribe_header`, `risky_emails`, `cc`, `bcc`, `start_date`, `end_date`, `timezone`, `days`, `start_time`, `end_time`, `schedule_windows`, `email_tags`, `folders`, `contact_order_by`, `contact_order_dir`, `contact_order_field`, `sender_strategy`, `rotation_mode`, `ramp_enabled`, `ramp_start`, `ramp_increment`, `ramp_ceiling`, `esp_match_mode`, `max_new_leads_per_day`, `prioritize_new_leads`, `tracking_domain`. + +```json +{ + "name": "Q3 outbound (renamed)", + "daily_limit": 35, + "stop_on_reply": true, + "schedule_windows": [[],[{"start":540,"end":1020}],[{"start":540,"end":1020}],[],[],[],[]] +} +``` + +### Response + +The updated `Campaign` object. + +## Delete a campaign + +`DELETE /campaigns/:id` + +Permanently delete a campaign. **Scope** `WRITE_CAMPAIGNS` · **Org permission** `manage_campaigns`. + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `id` | path | uuid | Campaign id. | + +### Response + +`204 No Content` with an empty body. + +## Get advanced settings + +`GET /campaigns/:id/advanced` + +Return the campaign's advanced outreach overrides (bounce pipeline, task reliability, A/B testing, reply intent, send-time optimization, preflight, dashboard). **Scope** `READ_CAMPAIGNS` · **Org permission** `view_campaigns`. + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `id` | path | uuid | Campaign id. | + +### Response + +A `CampaignAdvancedSettings` object: the campaign id, the `overrides` block, and `updated_at`. + +```json +{ + "campaign_id": "8f1d6b2e-2b7a-4c9e-9a1f-0e6d4c3b2a10", + "overrides": { + "bounce_pipeline": { + "enabled": true, + "auto_suppress_on_bounce": true, + "auto_suppress_on_complaint": true, + "auto_suppress_on_unsubscribe": true, + "auto_pause_campaign_on_spike": true, + "pause_bounce_rate_threshold": 8, + "pause_complaint_rate_threshold": 1.5 + }, + "task_reliability": { "enabled": true, "dlq_enabled": true, "max_attempts": 5, "execution_window_seconds": 300 }, + "ab_testing": { "enabled": true, "default_winning_rule": "reply_rate", "auto_promote_winner": false, "min_sample_size": 30 }, + "reply_intent": { + "enabled": true, + "positive_keywords": ["interested", "pricing"], + "negative_keywords": ["not interested", "unsubscribe"], + "out_of_office_keywords": ["out of office", "vacation"], + "question_keywords": ["?", "how", "price"], + "auto_create_crm_task": true, + "auto_pause_on_negative": false, + "auto_suppress_on_unsubscribe_keyword": true + }, + "send_time_optimization": { + "enabled": true, + "use_contact_timezone": true, + "default_contact_timezone": "UTC", + "preferred_hours": [9, 10, 11, 14, 15, 16], + "weekend_weight_multiplier": 0.5 + }, + "preflight": { + "enabled": true, + "check_tracking_domain": true, + "check_unsubscribe_header": true, + "check_ab_variant_configured": false, + "check_daily_limit": true, + "check_schedule_window": true + }, + "dashboard": { "enabled": true, "show_suppression_log": true, "show_intent_summary": true, "show_dlq_stats": true } + }, + "updated_at": "2026-06-10T12:00:00Z" +} +``` + +## Update advanced settings + +`PATCH /campaigns/:id/advanced` + +Replace the campaign's advanced overrides. **Scope** `WRITE_CAMPAIGNS` · **Org permission** `manage_settings`. + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `id` | path | uuid | Campaign id. | + +### Request body + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `settings` | object | yes | A full `AdvancedOutreachSettings` block (same shape as `overrides` above). | + +```json +{ + "settings": { + "bounce_pipeline": { "enabled": true, "auto_suppress_on_bounce": true, "auto_suppress_on_complaint": true, "auto_suppress_on_unsubscribe": true, "auto_pause_campaign_on_spike": true, "pause_bounce_rate_threshold": 8, "pause_complaint_rate_threshold": 1.5 }, + "task_reliability": { "enabled": true, "dlq_enabled": true, "max_attempts": 5, "execution_window_seconds": 300 }, + "ab_testing": { "enabled": true, "default_winning_rule": "reply_rate", "auto_promote_winner": false, "min_sample_size": 30 }, + "reply_intent": { "enabled": true, "positive_keywords": [], "negative_keywords": [], "out_of_office_keywords": [], "question_keywords": [], "auto_create_crm_task": true, "auto_pause_on_negative": false, "auto_suppress_on_unsubscribe_keyword": true }, + "send_time_optimization": { "enabled": true, "use_contact_timezone": true, "default_contact_timezone": "UTC", "preferred_hours": [9, 14], "weekend_weight_multiplier": 0.5 }, + "preflight": { "enabled": true, "check_tracking_domain": true, "check_unsubscribe_header": true, "check_ab_variant_configured": false, "check_daily_limit": true, "check_schedule_window": true }, + "dashboard": { "enabled": true, "show_suppression_log": true, "show_intent_summary": true, "show_dlq_stats": true } + } +} +``` + +### Response + +`204 No Content` with an empty body. + +## List A/B variants + +`GET /campaigns/:id/ab-variants` + +List the campaign's A/B variants. A variant scoped to a `step_id` applies to one step, a `null` sequence id is campaign-level. **Scope** `READ_CAMPAIGNS` · **Org permission** `view_campaigns`. + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `id` | path | uuid | Campaign id. | + +### Response + +A `data` array of `CampaignABVariant` objects (no pagination wrapper). + +```json +{ + "data": [ + { + "id": "c1a2...", + "campaign_id": "8f1d6b2e-2b7a-4c9e-9a1f-0e6d4c3b2a10", + "step_id": "7e3b...", + "name": "Subject B", + "weight": 50, + "subject": "Worth a look, {{first_name}}?", + "body_html": "

Hi {{first_name}}...

", + "body_plain": "Hi {{first_name}}...", + "is_control": false, + "is_active": true, + "created_at": "2026-06-02T10:00:00Z", + "updated_at": "2026-06-02T10:00:00Z" + } + ] +} +``` + +## Create an A/B variant + +`POST /campaigns/:id/ab-variants` + +Add a variant to the campaign (or to one step via `step_id`). **Scope** `WRITE_CAMPAIGNS` · **Org permission** `manage_settings`. + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `id` | path | uuid | Campaign id. | + +### Request body + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `name` | string | yes | Variant label. | +| `step_id` | uuid | no | Step to scope the variant to, omit for campaign-level. | +| `weight` | integer | no | Relative selection weight. | +| `subject` | string | no | Variant subject template. | +| `body_html` | string | no | Variant HTML body. | +| `body_plain` | string | no | Variant plain-text body. | +| `is_control` | boolean | no | Mark this as the control. | +| `is_active` | boolean | no | Whether the variant participates in the split. | +| `metadata` | object | no | Free-form metadata. | + +```json +{ + "name": "Subject B", + "step_id": "7e3b...", + "weight": 50, + "subject": "Worth a look, {{first_name}}?", + "body_plain": "Hi {{first_name}}...", + "is_control": false, + "is_active": true +} +``` + +### Response + +`201 Created` with the created `CampaignABVariant` object (see the [list variants](#list-ab-variants) shape). + +## Update an A/B variant + +`PATCH /campaigns/:id/ab-variants/:variantId` + +Patch a variant. Omitted fields are unchanged. **Scope** `WRITE_CAMPAIGNS` · **Org permission** `manage_settings`. + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `id` | path | uuid | Campaign id. | +| `variantId` | path | uuid | Variant id. | + +### Request body + +All fields optional: `name`, `weight`, `subject`, `body_html`, `body_plain`, `is_control`, `is_active`, `metadata`. + +```json +{ "weight": 70, "is_active": true } +``` + +### Response + +The updated `CampaignABVariant` object. + +## Delete an A/B variant + +`DELETE /campaigns/:id/ab-variants/:variantId` + +Remove a variant. **Scope** `WRITE_CAMPAIGNS` · **Org permission** `manage_settings`. + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `id` | path | uuid | Campaign id. | +| `variantId` | path | uuid | Variant id. | + +### Response + +`204 No Content` with an empty body. + +## Get A/B analysis + +`GET /campaigns/:id/ab-analysis` + +Return per-variant engagement stats plus the computed winner for the campaign. **Scope** `READ_ANALYTICS` · **Org permission** `view_analytics`. + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `id` | path | uuid | Campaign id. | + +### Response + +An `ABWinnerAnalysis` object. + +```json +{ + "campaign_id": "8f1d6b2e-2b7a-4c9e-9a1f-0e6d4c3b2a10", + "variants": [ + { + "variant_id": "c1a2...", + "variant_name": "Subject A", + "total_sent": 120, + "opened": 78, + "clicked": 21, + "replied": 9, + "bounced": 2, + "open_rate": 65.0, + "click_rate": 17.5, + "reply_rate": 7.5, + "bounce_rate": 1.7 + } + ], + "winner_id": "c1a2...", + "winner_name": "Subject A", + "winning_rule": "reply_rate", + "confidence": "low" +} +``` + +## List attachments + +`GET /campaigns/:id/attachments` + +List the campaign's attachments. Each entry carries a short-lived presigned download `url`. **Scope** `READ_CAMPAIGNS` · **Org permission** `view_campaigns`. + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `id` | path | uuid | Campaign id. | + +### Response + +A `data` array of attachment objects (no pagination wrapper). + +```json +{ + "data": [ + { + "id": "a9f0...", + "campaign_id": "8f1d6b2e-2b7a-4c9e-9a1f-0e6d4c3b2a10", + "step_id": null, + "filename": "one-pager.pdf", + "size": 248192, + "mime_type": "application/pdf", + "url": "https://storage.warmbly.com/...signed...", + "created_at": "2026-06-05T08:00:00Z" + } + ] +} +``` + +## Upload an attachment + +`POST /campaigns/:id/attachments` + +Upload a file to attach to the campaign (or one step). Sent as `multipart/form-data`, not JSON. **Scope** `WRITE_CAMPAIGNS` · **Org permission** `manage_campaigns`. + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `id` | path | uuid | Campaign id. | +| `file` | form (multipart) | file | Required. The file to upload (max 15 MB). Executable and script types are rejected. | +| `step_id` | form (multipart) | uuid | Optional. Scope the attachment to one sequence step. | + +### Response + +`201 Created` with the created attachment object (same shape as one element of [list attachments](#list-attachments)). + +## Delete an attachment + +`DELETE /campaigns/:id/attachments/:attachmentId` + +Delete a campaign attachment and its stored object. **Scope** `WRITE_CAMPAIGNS` · **Org permission** `manage_campaigns`. + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `id` | path | uuid | Campaign id. | +| `attachmentId` | path | uuid | Attachment id. | + +### Response + +`204 No Content` with an empty body. + +## Run preflight + +`POST /campaigns/:id/preflight` + +Run the campaign's preflight validation checks (tracking domain, unsubscribe header, daily limit, schedule window, A/B configuration, and more) and return a scored report. No mail is sent. **Scope** `SEND_CAMPAIGNS` · **Org permission** `send_campaigns`. + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `id` | path | uuid | Campaign id. | + +### Response + +A `PreflightReport` object. + +```json +{ + "id": "f2b1...", + "organization_id": "11111111-2222-3333-4444-555555555555", + "campaign_id": "8f1d6b2e-2b7a-4c9e-9a1f-0e6d4c3b2a10", + "passed": false, + "score": 80, + "checks": [ + { + "key": "tracking_domain", + "passed": false, + "severity": "warning", + "message": "Tracking domain is not verified.", + "remediation": "Verify the campaign tracking domain before sending." + } + ], + "recommendations": ["Verify your tracking domain to improve link attribution."], + "created_at": "2026-06-10T12:00:00Z" +} +``` + +## Send a test email + +`POST /campaigns/:id/test-email` + +Send a one-off preview of a sequence step to a chosen recipient through a chosen mailbox. Defaults to the first step when `step_id` is omitted. **Scope** `SEND_CAMPAIGNS` · **Org permission** `send_campaigns`. + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `id` | path | uuid | Campaign id. | + +### Request body + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `account_id` | uuid | yes | Sending mailbox id. | +| `recipient` | string (email) | yes | Where to send the test. | +| `step_id` | uuid | no | Step to render and send, defaults to the first step. | + +```json +{ + "account_id": "5c7d...", + "recipient": "me@example.com", + "step_id": "7e3b..." +} +``` + +### Response + +```json +{ + "message": "test email sent", + "recipient": "me@example.com", + "subject": "Quick question, Alex", + "account_id": "5c7d..." +} +``` + +## Start a campaign + +`POST /campaigns/:id/start` + +Start (activate) the campaign so it begins sending real mail. **Scope** `SEND_CAMPAIGNS` · **Org permission** `send_campaigns`. + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `id` | path | uuid | Campaign id. | + +### Response + +```json +{ "status": "started" } +``` + +## Stop a campaign + +`POST /campaigns/:id/stop` + +Stop (pause) an active campaign. **Scope** `SEND_CAMPAIGNS` · **Org permission** `send_campaigns`. + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `id` | path | uuid | Campaign id. | + +### Response + +```json +{ "status": "stopped" } +``` + +## Get campaign logs + +`GET /campaigns/:id/logs` + +Page through the campaign's activity log (status changes, send events, errors). **Scope** `READ_CAMPAIGNS` · **Org permission** `view_campaigns`. + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `id` | path | uuid | Campaign id. | +| `cursor` | query | string | Opaque cursor from the previous page. Optional. | +| `limit` | query | integer | Page size, 1 to 100 (default 50). Optional. | + +### Response + +A `data` plus `pagination` envelope. Here `pagination` carries only `next_cursor` (a string, `null` on the last page) and `has_more`. + +```json +{ + "data": [ + { + "id": "9b2c...", + "campaign_id": "8f1d6b2e-2b7a-4c9e-9a1f-0e6d4c3b2a10", + "event_type": "campaign_started", + "message": "Campaign started", + "metadata": {}, + "created_at": "2026-06-10T12:00:00Z" + } + ], + "pagination": { + "next_cursor": "9b2c...", + "has_more": true + } +} +``` + +## List campaign senders + +`GET /campaigns/:id/senders` + +Return the campaign's explicit sender pool (used when `sender_strategy` is `explicit`). **Scope** `READ_CAMPAIGNS` · **Org permission** `view_campaigns`. + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `id` | path | uuid | Campaign id. | + +### Response + +A `data` array of sender objects (no pagination wrapper). + +```json +{ + "data": [ + { + "email_account_id": "5c7d...", + "weight": 1, + "last_sent_at": "2026-06-10T11:55:00Z", + "enabled": true + } + ] +} +``` + +## Replace senders + +`PUT /campaigns/:id/senders` + +Atomically replace the campaign's explicit sender pool with the supplied list. **Scope** `WRITE_CAMPAIGNS` · **Org permission** `manage_campaigns`. + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `id` | path | uuid | Campaign id. | + +### Request body + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `senders` | object[] | yes | The full new sender pool. Each item: `email_account_id` (uuid, required), `weight` (integer, optional), `enabled` (boolean, optional). | + +```json +{ + "senders": [ + { "email_account_id": "5c7d...", "weight": 2, "enabled": true }, + { "email_account_id": "6d8e...", "weight": 1, "enabled": true } + ] +} +``` + +### Response + +A `data` array of the resulting sender objects (same shape as [list senders](#list-campaign-senders)). + +## Verify campaign tracking domain + +`POST /campaigns/:id/tracking-domain/verify` + +Resolve the campaign-scoped tracking domain's CNAME and flip `tracking_domain_verified` to `true` on success. **Scope** `WRITE_CAMPAIGNS` · **Org permission** `manage_campaigns`. + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `id` | path | uuid | Campaign id. | + +### Response + +A `TrackingDomainStatus` object. + +```json +{ + "tracking_domain": "track.acme.com", + "tracking_domain_verified": true, + "tracking_domain_verified_at": "2026-06-10T12:00:00Z" +} +``` + +## List sequences + +`GET /campaigns/:id/steps` + +Return the campaign's sequence steps in order. **Scope** `READ_CAMPAIGNS` · **Org permission** `view_campaigns`. + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `id` | path | uuid | Campaign id. | + +### Response + +A bare array of `Sequence` objects (no envelope). + +```json +[ + { + "id": "7e3b...", + "name": "Step 1", + "subject": "Quick question, {{first_name}}", + "body_plain": "Hi {{first_name}}...", + "body_html": "

Hi {{first_name}}...

", + "body_sync": true, + "body_code": false, + "wait_after": 0, + "position": 0, + "kind": "email", + "updated_at": "2026-06-02T10:00:00Z", + "created_at": "2026-06-02T10:00:00Z" + } +] +``` + +## Create a sequence + +`POST /campaigns/:id/steps` + +Append a new empty sequence step to the campaign. The step is created with defaults, then edited with PATCH. **Scope** `WRITE_CAMPAIGNS` · **Org permission** `manage_campaigns`. + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `id` | path | uuid | Campaign id. | + +This endpoint takes no request body. + +### Response + +The created `Sequence` object (see the [list sequences](#list-sequences) shape). + +## Update a sequence + +`PATCH /campaigns/:id/steps/:sid` + +Patch a sequence step: its copy, spacing, node kind, branching tree, or action config. Omitted fields are unchanged. **Scope** `WRITE_CAMPAIGNS` · **Org permission** `manage_campaigns`. + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `id` | path | uuid | Campaign id. | +| `sid` | path | uuid | Sequence (step) id. | + +### Request body + +All fields optional. + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `name` | string | no | Step label. | +| `subject` | string | no | Subject template. | +| `body_plain` | string | no | Plain-text body template. | +| `body_html` | string | no | HTML body template. | +| `body_sync` | boolean | no | Keep plain and HTML bodies in sync. | +| `body_code` | boolean | no | Treat the body as raw code (no auto-formatting). | +| `wait_after` | integer | no | Minutes to wait after this step before the next (the spacing model, there is no standalone wait node for email steps). | +| `conditions` | object | no | Branching tree (`{branches: [...]}`). Send `{}` or empty branches to clear branching and fall back to linear progression. | +| `kind` | string | no | `email` (default), `action`, or `wait`. | +| `action` | object | no | Typed config for non-email nodes. `type` is the switch (`wait`, `add_tag`, `remove_tag`, `unsubscribe`, `notify`, `create_task`, `create_deal`, `move_deal_stage`, `run_automation`, `end`), the remaining fields are type-scoped. | + +```json +{ + "name": "Step 2", + "subject": "Following up, {{first_name}}", + "body_plain": "Just bumping this...", + "wait_after": 2880, + "conditions": { + "branches": [ + { + "branch_id": "b1", + "target_step_id": null, + "conditions": [{ "field": "replied", "operator": "ever", "value": null }] + } + ] + } +} +``` + +### Response + +The updated `Sequence` object. + +## Delete a sequence + +`DELETE /campaigns/:id/steps/:sid` + +Delete a sequence step. **Scope** `WRITE_CAMPAIGNS` · **Org permission** `manage_campaigns`. + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `id` | path | uuid | Campaign id. | +| `sid` | path | uuid | Sequence (step) id. | + +### Response + +`200 OK` with an empty body. + +## Preview a template + +`POST /campaign-template-preview` + +Render subject and body templates against a sample (or supplied) contact exactly as the send path would, and report parse errors plus any unresolved `{{...}}` tokens. No side effects, no campaign id. **Scope** `READ_CAMPAIGNS` · **Org permission** `view_campaigns`. + +### Request body + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `subject` | string | no | Subject template. | +| `body_html` | string | no | HTML body template. | +| `body_plain` | string | no | Plain-text body template. | +| `contact` | object | no | Override fields on the built-in sample contact: `first_name`, `last_name`, `email`, `company`, `phone`, and a `custom_fields` map of string to string. | + +```json +{ + "subject": "Hi {{first_name}} at {{company}}", + "body_plain": "Hey {{first_name}}, I saw {{company}} is hiring. {{unknown_token}}", + "contact": { "first_name": "Sam", "company": "Globex" } +} +``` + +### Response + +A `TemplatePreview` object. `errors` lists template parse errors that would block sending, `unresolved` lists literal tokens left after render. Both are omitted when empty. + +```json +{ + "subject": "Hi Sam at Globex", + "body_html": "", + "body_plain": "Hey Sam, I saw Globex is hiring. {{unknown_token}}", + "unresolved": ["{{unknown_token}}"] +} +``` + +## Generate copy with the writing assistant + +`POST /generation/write` + +Generate outreach copy with the AI writing assistant. Gated to paid and free-trial organizations, and each call consumes one AI credit (refunded if the provider call fails). Supports `Idempotency-Key` so a retried request is not double-charged. **Scope** `WRITE_CAMPAIGNS` · **Org permission** `manage_campaigns`. + +### Request body + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `prompt` | string | yes | The instruction to generate from (max 8000 characters). | +| `tone` | string | no | Desired tone (for example `friendly`, `direct`). | + +```json +{ "prompt": "Write a 3-line cold intro to a fintech founder about our deliverability tooling.", "tone": "direct" } +``` + +### Response + +```json +{ + "text": "Hi {{first_name}},\n\nNoticed {{company}} is scaling outbound...\n\nWorth a quick chat?", + "credits_remaining": 248, + "model": "claude-..." +} +``` + +When the organization is out of credits the endpoint returns `402` with `code: "insufficient_credits"` and the standard envelope. A depleted balance is checked before any provider call, so no completion is ever burned on a `402`. diff --git a/docs/content/docs/api/reference/contacts.mdx b/docs/content/docs/api/reference/contacts.mdx new file mode 100644 index 00000000..3ee131c8 --- /dev/null +++ b/docs/content/docs/api/reference/contacts.mdx @@ -0,0 +1,802 @@ +--- +title: Contacts +description: Search, manage, import, export, and enrich contacts along with their notes, activities, timeline, and deals. +icon: Users +--- + +Contacts are the people you send to. This group covers the full lifecycle: searching and filtering, creating and editing (singly and in bulk), CSV/XLSX/JSON import and export, the hydrated contact 360 view, the per-contact email and activity feeds, and CRM notes, activities, and deals attached to a contact. List endpoints return a `data` array plus a `pagination` envelope; errors follow the standard `{error, message, code, request_id}` shape (see [error codes](/api/error-codes/)). + +## Search contacts + +`POST /contacts/search` + +Faceted, server-side contact search scoped to your organization. The request body holds the filters; pagination is via query params. + +Auth: **Scope** `READ_CONTACTS` · **Org permission** `view_contacts` + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `cursor` | query | string | Opaque pagination cursor from the previous page's `pagination.next_cursor`. | +| `limit` | query | string | Page size (numeric string). | +| `category` | query | string | Convenience filter for a single category ID. | + +### Request body + +Every field is optional; an empty body matches all contacts in the organization. + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `query` | string | No | Text search across core fields (name, email, company). | +| `custom_field_filters` | array | No | Per custom-field filters: `{ "name", "value", "type" }` where `type` is one of `equal`, `starts_with`, `ends_with`, `contains`. | +| `campaign_ids` | string[] | No | Contact must be in ALL of these campaigns. | +| `category_ids` | string[] | No | Contact must have ALL of these categories. | +| `min_campaigns` | integer | No | Minimum number of associated campaigns. | +| `max_campaigns` | integer | No | Maximum number of associated campaigns. | +| `subscribed` | boolean | No | Filter by subscription status. | +| `created_after` | string (RFC 3339) | No | Created on or after this time. | +| `created_before` | string (RFC 3339) | No | Created on or before this time. | +| `updated_after` | string (RFC 3339) | No | Updated on or after this time. | +| `updated_before` | string (RFC 3339) | No | Updated on or before this time. | +| `sort_by` | string | No | Sort column, e.g. `first_name`, `campaign_count`. | +| `reverse` | boolean | No | Descending when true. | + +```json +{ + "query": "acme", + "subscribed": true, + "category_ids": ["6f1c0b1e-0c2a-4b3a-9c1e-2d3f4a5b6c7d"], + "min_campaigns": 1, + "sort_by": "first_name", + "reverse": false +} +``` + +### Response + +Returns a `data` array of contacts plus a `pagination` envelope. + +```json +{ + "data": [ + { + "id": "1b2c3d4e-5f60-4a1b-8c2d-3e4f5a6b7c8d", + "first_name": "Dana", + "last_name": "Reyes", + "email": "dana@acme.com", + "company": "Acme", + "phone": "+15551234567", + "custom_fields": { "title": "VP Sales" }, + "subscribed": true, + "campaigns": [{ "id": "c1...", "name": "Q3 Outbound" }], + "categories": [{ "id": "6f1c...", "title": "VIP", "color": "#0ea5e9" }], + "verification_status": "valid", + "verification_reason": "", + "is_catch_all": false, + "esp_provider": "gmail", + "updated_at": "2026-06-10T12:00:00Z", + "created_at": "2026-05-01T09:30:00Z" + } + ], + "pagination": { + "total": 1280, + "next_cursor": "c1_b3BhcXVlLWN1cnNvcg", + "has_more": true + } +} +``` + +When the search filters by exactly one campaign, each contact additionally carries a `campaign_lead` object with its processing state inside that campaign (`status`, `sent`, `opened`, `clicked`, `replied`, `bounced`, `current_step`, `last_activity_at`). + +## Create contacts + +`POST /contacts` + +Creates one or more contacts. The body is a JSON array, so a single create is an array of length one. + +Auth: **Scope** `WRITE_CONTACTS` · **Org permission** `manage_contacts` + +### Request body + +A JSON array of contact objects (at least one, up to the per-request maximum; an empty array is a `400`). + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `email` | string | Yes | Contact email address. | +| `first_name` | string | No | First name. | +| `last_name` | string | No | Last name. | +| `company` | string | No | Company name. | +| `phone` | string | No | Phone number. | +| `campaigns` | string[] | No | Campaign IDs to add the contact to. | +| `categories` | string[] | No | Category IDs to assign. | +| `custom_fields` | object | No | Arbitrary string key/value custom fields. | + +```json +[ + { + "email": "lee@globex.com", + "first_name": "Lee", + "last_name": "Ng", + "company": "Globex", + "categories": ["6f1c0b1e-0c2a-4b3a-9c1e-2d3f4a5b6c7d"], + "custom_fields": { "title": "Head of Ops" } + } +] +``` + +### Response + +Returns the created contacts as a bare JSON array (same contact shape as search). + +```json +[ + { + "id": "2c3d4e5f-6071-4b2c-9d3e-4f5a6b7c8d9e", + "first_name": "Lee", + "last_name": "Ng", + "email": "lee@globex.com", + "company": "Globex", + "phone": "", + "custom_fields": { "title": "Head of Ops" }, + "subscribed": true, + "campaigns": [], + "categories": [{ "id": "6f1c...", "title": "VIP", "color": "#0ea5e9" }], + "verification_status": "unknown", + "esp_provider": "", + "updated_at": "2026-06-11T10:00:00Z", + "created_at": "2026-06-11T10:00:00Z" + } +] +``` + +## Bulk update contacts + +`PATCH /contacts` + +Applies one set of edits across a list of contacts: add/remove campaigns and categories, set custom-field operations, and toggle subscription. Up to 1000 contacts per batch. + +Auth: **Scope** `BULK_CONTACTS` · **Org permission** `manage_contacts` + +### Request body + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `contacts` | string[] | Yes | Contact IDs to edit (1 to 1000). | +| `add_campaigns` | string[] | No | Campaign IDs to add. | +| `remove_campaigns` | string[] | No | Campaign IDs to remove. | +| `add_categories` | string[] | No | Category IDs to add. | +| `remove_categories` | string[] | No | Category IDs to remove. | +| `fields` | array | No | Custom-field operations: `{ "type", "key", "value" }` where `type` is `ADD`, `EDIT`, `DELETE`, or `RENAME`. | +| `subscribe` | boolean | No | Set subscription status for all listed contacts. | + +```json +{ + "contacts": ["1b2c3d4e-5f60-4a1b-8c2d-3e4f5a6b7c8d", "2c3d4e5f-6071-4b2c-9d3e-4f5a6b7c8d9e"], + "add_categories": ["6f1c0b1e-0c2a-4b3a-9c1e-2d3f4a5b6c7d"], + "subscribe": false, + "fields": [{ "type": "EDIT", "key": "title", "value": "Decision Maker" }] +} +``` + +### Response + +Returns the updated contacts as a bare JSON array (contact shape as above). + +```json +[ + { + "id": "1b2c3d4e-5f60-4a1b-8c2d-3e4f5a6b7c8d", + "email": "dana@acme.com", + "subscribed": false, + "categories": [{ "id": "6f1c...", "title": "VIP", "color": "#0ea5e9" }], + "updated_at": "2026-06-11T10:05:00Z", + "created_at": "2026-05-01T09:30:00Z" + } +] +``` + +## Bulk delete contacts + +`DELETE /contacts` + +Deletes a list of contacts. Up to 1000 IDs per batch. + +Auth: **Scope** `BULK_CONTACTS` · **Org permission** `manage_contacts` + +### Request body + +A JSON array of contact ID strings (1 to 1000; an empty array is a `400`). + +```json +["1b2c3d4e-5f60-4a1b-8c2d-3e4f5a6b7c8d", "2c3d4e5f-6071-4b2c-9d3e-4f5a6b7c8d9e"] +``` + +### Response + +`204 No Content`. + +## Export contacts + +`POST /contacts/export` + +Exports contacts to CSV, XLSX, or JSON. The response is the file itself, not JSON. + +Auth: **Scope** `READ_CONTACTS` · **Org permission** `view_contacts` + +### Request body + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `format` | string | Yes | `csv`, `xlsx`, or `json`. | +| `scope` | string | Yes | `all`, `filtered`, or `selected`. | +| `contact_ids` | string[] | No | Contact IDs when `scope` is `selected`. | +| `filters` | object | No | A search-contacts filter body when `scope` is `filtered`. | +| `fields` | string[] | No | Column identifiers in display order (built-ins like `email`, `first_name`, or `custom:`). Empty uses the default columns. | +| `filename` | string | No | Filename without extension. Sanitized server-side; empty falls back to `contacts-`. | + +```json +{ + "format": "csv", + "scope": "filtered", + "filters": { "subscribed": true }, + "fields": ["email", "first_name", "last_name", "company", "custom:title"], + "filename": "subscribed-contacts" +} +``` + +### Response + +`200 OK` with the file as an attachment. The relevant headers are: + +| Header | Description | +| --- | --- | +| `Content-Type` | The export's MIME type (CSV, XLSX, or JSON). | +| `Content-Disposition` | `attachment; filename="..."`. | +| `X-Total-Rows` | Number of rows written. | + +Exports are capped at 50,000 rows. Larger sets should be split via filters or selection. + +## Preview an import + +`POST /contacts/import/preview` + +Uploads a CSV or XLSX file and returns detected columns plus a small sample so the client can build a column mapping before committing. + +Auth: **Scope** `WRITE_CONTACTS` · **Org permission** `manage_contacts` + +Send the file as `multipart/form-data` with a `file` form field. Uploads are capped at 50 MB. + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `file` | form-data | file | The CSV/XLSX upload. | + +### Response + +```json +{ + "filename": "leads.csv", + "format": "csv", + "total_rows": 1243, + "columns": ["Email", "First", "Last", "Company"], + "has_header": true, + "sample_rows": [ + ["dana@acme.com", "Dana", "Reyes", "Acme"] + ], + "suggested_mapping": [ + { "index": 0, "target": "email" }, + { "index": 1, "target": "first_name" }, + { "index": 2, "target": "last_name" }, + { "index": 3, "target": "company" } + ] +} +``` + +`sample_rows` is capped at 20 rows. `suggested_mapping` is a default the client may override. + +## Commit an import + +`POST /contacts/import/commit` + +Re-uploads the file with a mapping and dedup options, applies it, and returns per-row results. + +Auth: **Scope** `BULK_CONTACTS` · **Org permission** `manage_contacts` + +Send `multipart/form-data` with a `file` field and an `options` field containing the JSON below as a string. + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `file` | form-data | file | The CSV/XLSX upload (max 50 MB). | +| `options` | form-data | string | JSON-encoded commit options (below). | + +### `options` fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `mapping` | array | Yes | Column mappings: `{ "index", "target", "custom_key" }`. `target` is `ignore`, `email`, `first_name`, `last_name`, `company`, `phone`, `subscribed`, `categories`, or `custom:`. | +| `dedup` | string | Yes | `skip`, `update`, or `create_duplicate` for rows whose email matches an existing contact. | +| `has_header` | boolean | Yes | Whether the first row is a header. | +| `category_ids` | string[] | No | Categories to assign to imported contacts. | +| `campaign_ids` | string[] | No | Campaigns to add imported contacts to. | +| `subscribed_default` | boolean | No | Subscription state for new contacts when no subscribed column is mapped. Defaults to true. | + +```json +{ + "mapping": [ + { "index": 0, "target": "email" }, + { "index": 1, "target": "first_name" }, + { "index": 3, "target": "custom:company_size", "custom_key": "company_size" } + ], + "dedup": "update", + "has_header": true, + "category_ids": ["6f1c0b1e-0c2a-4b3a-9c1e-2d3f4a5b6c7d"], + "subscribed_default": true +} +``` + +### Response + +```json +{ + "total": 1243, + "imported": 1180, + "updated": 41, + "skipped": 18, + "failed": 4, + "started_at": "2026-06-11T10:10:00Z", + "ended_at": "2026-06-11T10:10:07Z", + "errors": [ + { "line": 57, "email": "not-an-email", "reason": "invalid email" } + ] +} +``` + +Imports are capped at 50,000 rows. + +## Look up a contact by email + +`GET /contacts/lookup` + +Resolves a sender address to a contact in your organization. Returns `200` with `{"contact": null}` when nothing matches, so unknown senders render a clean empty state rather than a `404`. A display-name wrapped address (`Name `) is accepted and unwrapped. + +Auth: **Scope** `READ_CONTACTS` · **Org permission** `view_contacts` + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `email` | query | string | The email address to resolve (required). | + +### Response + +```json +{ + "contact": { + "id": "1b2c3d4e-5f60-4a1b-8c2d-3e4f5a6b7c8d", + "first_name": "Dana", + "last_name": "Reyes", + "email": "dana@acme.com", + "company": "Acme", + "subscribed": true, + "campaigns": [], + "categories": [], + "verification_status": "valid", + "esp_provider": "gmail", + "updated_at": "2026-06-10T12:00:00Z", + "created_at": "2026-05-01T09:30:00Z" + } +} +``` + +## Get a contact + +`GET /contacts/:id` + +Returns the hydrated contact 360 payload: the contact plus an engagement summary and, when present, suppression state. Engagement and suppression counts are org-scoped; they are returned only when an organization is selected. + +Auth: **Scope** `READ_CONTACTS` · **Org permission** `view_contacts` + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `id` | path | UUID | Contact ID. | + +### Response + +```json +{ + "id": "1b2c3d4e-5f60-4a1b-8c2d-3e4f5a6b7c8d", + "first_name": "Dana", + "last_name": "Reyes", + "email": "dana@acme.com", + "company": "Acme", + "phone": "+15551234567", + "custom_fields": { "title": "VP Sales" }, + "subscribed": true, + "campaigns": [{ "id": "c1...", "name": "Q3 Outbound" }], + "categories": [{ "id": "6f1c...", "title": "VIP", "color": "#0ea5e9" }], + "verification_status": "valid", + "esp_provider": "gmail", + "updated_at": "2026-06-10T12:00:00Z", + "created_at": "2026-05-01T09:30:00Z", + "engagement": { + "total_sent": 4, + "total_opened": 3, + "total_clicked": 1, + "total_replied": 1, + "total_bounced": 0, + "total_complained": 0, + "last_sent_at": "2026-06-09T08:00:00Z", + "last_opened_at": "2026-06-09T08:14:00Z", + "last_replied_at": "2026-06-09T11:02:00Z" + }, + "suppression": null +} +``` + +When the contact is suppressed, `suppression` is an object: `{ "reason", "source", "expires_at", "created_at" }` where `source` is `bounce`, `complaint`, or `unsubscribe`. + +## Update a contact + +`PATCH /contacts/:id` + +Partially updates a single contact. Only the fields present are changed. Campaign and category lists can be set wholesale or adjusted with diff-style add/remove. + +Auth: **Scope** `WRITE_CONTACTS` · **Org permission** `manage_contacts` + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `id` | path | UUID | Contact ID. | + +### Request body + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `first_name` | string | No | First name. | +| `last_name` | string | No | Last name. | +| `company` | string | No | Company. | +| `phone` | string | No | Phone. | +| `custom_fields` | object | No | Replaces the custom-fields map. | +| `subscribed` | boolean | No | Subscription status. | +| `campaigns` | string[] | No | Set the full campaign membership (nil leaves as-is). | +| `categories` | string[] | No | Set the full category list (nil leaves as-is). | +| `add_categories` | string[] | No | Diff-style add (ignored when `categories` is set). | +| `remove_categories` | string[] | No | Diff-style remove (ignored when `categories` is set). | + +```json +{ + "company": "Acme Corp", + "subscribed": true, + "add_categories": ["6f1c0b1e-0c2a-4b3a-9c1e-2d3f4a5b6c7d"] +} +``` + +### Response + +Returns the updated contact as a bare object (contact shape as in search). + +```json +{ + "id": "1b2c3d4e-5f60-4a1b-8c2d-3e4f5a6b7c8d", + "first_name": "Dana", + "last_name": "Reyes", + "email": "dana@acme.com", + "company": "Acme Corp", + "subscribed": true, + "campaigns": [], + "categories": [{ "id": "6f1c...", "title": "VIP", "color": "#0ea5e9" }], + "updated_at": "2026-06-11T10:20:00Z", + "created_at": "2026-05-01T09:30:00Z" +} +``` + +## Delete a contact + +`DELETE /contacts/:id` + +Deletes a single contact. + +Auth: **Scope** `WRITE_CONTACTS` · **Org permission** `manage_contacts` + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `id` | path | UUID | Contact ID. | + +### Response + +`204 No Content`. + +## List emails sent to a contact + +`GET /contacts/:id/emails` + +Returns one row per email sent (or attempted) to the contact, newest first, with sender, campaign, sequence, and engagement timestamps. Pagination is keyed on the `(created_at, task_id)` of the last row. + +Auth: **Scope** `READ_CONTACTS` · **Org permission** `view_contacts` + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `id` | path | UUID | Contact ID. | +| `limit` | query | integer | Page size, 1 to 200 (default 50). | +| `before_at` | query | string (RFC 3339 nano) | `created_at` of the last row from the previous page. | +| `before_id` | query | UUID | `task_id` of the last row from the previous page. | + +Both `before_at` and `before_id` must be supplied together; otherwise the cursor is ignored and the first page is returned. + +### Response + +Returns a `data` array plus a `pagination` envelope. + +```json +{ + "data": [ + { + "task_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", + "status": "sent", + "message_id": "", + "subject": "Quick question", + "sent_at": "2026-06-09T08:00:00Z", + "email_account_id": "e1...", + "email_account_email": "rep@yourco.com", + "email_account_name": "Rep One", + "campaign_id": "c1...", + "campaign_name": "Q3 Outbound", + "step_id": "s1...", + "step_name": "Email 1", + "opened_at": "2026-06-09T08:14:00Z", + "replied_at": "2026-06-09T11:02:00Z" + } + ], + "pagination": { + "total": 4, + "next_cursor": null, + "has_more": false + } +} +``` + +## List a contact's timeline + +`GET /contacts/:id/timeline` + +Returns the merged activity feed for a contact: sends, opens, clicks, replies, bounces, deliverability and suppression events, notes, and meeting bookings. Requires a selected organization (org-scoped events would otherwise be hidden), so a request with no organization returns `400`. + +Auth: **Scope** `READ_CONTACTS` · **Org permission** `view_contacts` + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `id` | path | UUID | Contact ID. | +| `limit` | query | integer | Page size, 1 to 200 (default 50). | +| `before` | query | string (RFC 3339 nano) | The `at` timestamp of the oldest event from the previous page. | + +### Response + +Returns a `data` array and a `has_more` flag (not a cursor envelope). Paginate by passing the oldest event's `at` as `before`. + +```json +{ + "data": [ + { + "type": "email_replied", + "at": "2026-06-09T11:02:00Z", + "email_account_id": "e1...", + "email_account_email": "rep@yourco.com", + "campaign_id": "c1...", + "campaign_name": "Q3 Outbound", + "task_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", + "subject": "Quick question" + }, + { + "type": "note", + "at": "2026-06-08T16:30:00Z", + "content": "Met at the conference, wants a follow-up in July.", + "user_id": "u1..." + } + ], + "has_more": false +} +``` + +`type` is one of `email_sent`, `email_opened`, `email_clicked`, `email_replied`, `email_bounced`, `reply_received`, `deliverability`, `suppressed`, `note`, `meeting_booked`, `meeting_rescheduled`, or `meeting_canceled`. Fields not relevant to an event type are omitted. + +## List a contact's activities + +`GET /contacts/:id/activities` + +Returns the structured CRM activity log for a contact (note, deal, task, campaign, and engagement events recorded in the CRM activity table). Requires a selected organization. + +Auth: **Scope** `READ_CONTACTS` · **Org permission** `view_contacts` + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `id` | path | UUID | Contact ID. | +| `limit` | query | integer | Page size, 1 to 100 (default 50). | +| `cursor` | query | UUID | Opaque cursor from the previous page's `pagination.next_cursor`. | + +### Response + +```json +{ + "data": [ + { + "id": "ac1...", + "contact_id": "1b2c3d4e-5f60-4a1b-8c2d-3e4f5a6b7c8d", + "organization_id": "org1...", + "user_id": "u1...", + "activity_type": "note_added", + "metadata": { "note_id": "n1..." }, + "created_at": "2026-06-08T16:30:00Z" + } + ], + "pagination": { + "total": 12, + "next_cursor": "ac0...", + "has_more": true + } +} +``` + +`activity_type` is a closed enum including `email_sent`, `email_opened`, `email_clicked`, `email_replied`, `email_bounced`, `note_added`, `note_updated`, `deal_created`, `deal_stage_changed`, `deal_won`, `deal_lost`, `task_created`, `task_completed`, `contact_created`, `contact_updated`, `campaign_added`, and `campaign_removed`. + +## List a contact's notes + +`GET /contacts/:id/notes` + +Returns the CRM notes attached to a contact, newest first. Requires a selected organization. + +Auth: **Scope** `READ_CONTACTS` · **Org permission** `view_contacts` + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `id` | path | UUID | Contact ID. | +| `limit` | query | integer | Page size, 1 to 100 (default 50). | +| `cursor` | query | UUID | Opaque cursor from the previous page's `pagination.next_cursor`. | + +### Response + +```json +{ + "data": [ + { + "id": "n1b2c3d4-5f60-4a1b-8c2d-3e4f5a6b7c8d", + "contact_id": "1b2c3d4e-5f60-4a1b-8c2d-3e4f5a6b7c8d", + "organization_id": "org1...", + "user_id": "u1...", + "content": "Met at the conference, wants a follow-up in July.", + "created_at": "2026-06-08T16:30:00Z", + "updated_at": "2026-06-08T16:30:00Z", + "user": { "id": "u1...", "name": "Sam Rep" } + } + ], + "pagination": { + "total": 3, + "next_cursor": null, + "has_more": false + } +} +``` + +## Create a contact note + +`POST /contacts/:id/notes` + +Adds a note to a contact. Requires a selected organization. + +Auth: **Scope** `WRITE_CONTACTS` · **Org permission** `manage_contacts` + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `id` | path | UUID | Contact ID. | + +### Request body + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `content` | string | Yes | Note body (1 to 10,000 characters). | + +```json +{ "content": "Sent the proposal, following up Monday." } +``` + +### Response + +`201 Created` with the created note (same shape as a note in the list). + +```json +{ + "id": "n2c3d4e5-6071-4b2c-9d3e-4f5a6b7c8d9e", + "contact_id": "1b2c3d4e-5f60-4a1b-8c2d-3e4f5a6b7c8d", + "organization_id": "org1...", + "user_id": "u1...", + "content": "Sent the proposal, following up Monday.", + "created_at": "2026-06-11T10:30:00Z", + "updated_at": "2026-06-11T10:30:00Z" +} +``` + +## Update a contact note + +`PATCH /contacts/:id/notes/:noteId` + +Edits a note's content. Requires a selected organization. + +Auth: **Scope** `WRITE_CONTACTS` · **Org permission** `manage_contacts` + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `id` | path | UUID | Contact ID. | +| `noteId` | path | UUID | Note ID. | + +### Request body + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `content` | string | No | New note body. | + +```json +{ "content": "Sent the proposal, following up Tuesday." } +``` + +### Response + +Returns the updated note. + +```json +{ + "id": "n2c3d4e5-6071-4b2c-9d3e-4f5a6b7c8d9e", + "contact_id": "1b2c3d4e-5f60-4a1b-8c2d-3e4f5a6b7c8d", + "organization_id": "org1...", + "user_id": "u1...", + "content": "Sent the proposal, following up Tuesday.", + "created_at": "2026-06-11T10:30:00Z", + "updated_at": "2026-06-11T10:35:00Z" +} +``` + +## Delete a contact note + +`DELETE /contacts/:id/notes/:noteId` + +Deletes a note. Requires a selected organization. + +Auth: **Scope** `WRITE_CONTACTS` · **Org permission** `manage_contacts` + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `id` | path | UUID | Contact ID. | +| `noteId` | path | UUID | Note ID. | + +### Response + +`204 No Content`. + +## List a contact's deals + +`GET /contacts/:id/deals` + +Returns the CRM deals associated with a contact as a bare JSON array. + +Auth: **Scope** `READ_CRM` · **Org permission** `view_contacts` + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `id` | path | UUID | Contact ID. | + +### Response + +```json +[ + { + "id": "d1b2c3d4-5f60-4a1b-8c2d-3e4f5a6b7c8d", + "organization_id": "org1...", + "pipeline_id": "p1...", + "stage_id": "st1...", + "contact_id": "1b2c3d4e-5f60-4a1b-8c2d-3e4f5a6b7c8d", + "name": "Acme expansion", + "value": 12000, + "currency": "USD", + "status": "open", + "expected_close_date": "2026-07-15T00:00:00Z", + "campaign_id": "c1...", + "created_at": "2026-06-05T09:00:00Z", + "updated_at": "2026-06-10T12:00:00Z" + } +] +``` + +`status` is `open`, `won`, or `lost`. `value`, `expected_close_date`, `won_at`, `lost_at`, `lost_reason`, `assigned_to`, `campaign_id`, and `source_mailbox_id` are nullable and omitted when unset. diff --git a/docs/content/docs/api/reference/crm.mdx b/docs/content/docs/api/reference/crm.mdx new file mode 100644 index 00000000..111e04ed --- /dev/null +++ b/docs/content/docs/api/reference/crm.mdx @@ -0,0 +1,986 @@ +--- +title: CRM +description: Manage pipelines, stages, deals, task types, and CRM tasks for your organization. +icon: Briefcase +--- + +The CRM endpoints model your sales pipeline: deals move through stages of a pipeline, and CRM tasks track the follow-up work attached to contacts and deals. Every route is organization-scoped and requires an active organization on the session or API key. Read access uses the **CRM** read scope (`READ_CRM` / org permission `view_contacts`), and mutations use the **CRM** write scope (`WRITE_CRM` / org permission `manage_contacts`), except team membership operations which gate on `manage_team`. See [authentication](/api/authentication/) and [permissions](/api/permissions/) for how scopes map to API keys and member roles. + +Every list endpoint returns a `data` array plus the standard `pagination` envelope with an opaque `next_cursor`. The simple `GET` list endpoints use a keyset cursor; the faceted `POST .../search` endpoints paginate by offset under the hood (their nullable sort columns rule out a keyset cursor) but expose the same opaque `next_cursor`, and add an exact `total`. + +## List pipelines + +`GET /crm/pipelines` + +Return every pipeline in the organization, each with its ordered stages. + +Auth: **Scope** `READ_CRM` · **Org permission** `view_contacts` + +### Response + +Returns a bare array of pipeline objects (not wrapped in an envelope). + +```json +[ + { + "id": "5d8a2b1e-0c4f-4a9b-9f2e-1a2b3c4d5e6f", + "organization_id": "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9", + "name": "Sales", + "position": 0, + "stages": [ + { + "id": "7f3c1a90-2b4d-4e6f-8a01-b2c3d4e5f607", + "pipeline_id": "5d8a2b1e-0c4f-4a9b-9f2e-1a2b3c4d5e6f", + "name": "Lead", + "color": "#0ea5e9", + "position": 0, + "deal_count": 12, + "created_at": "2026-05-01T09:00:00Z", + "updated_at": "2026-05-01T09:00:00Z" + } + ], + "created_at": "2026-05-01T09:00:00Z", + "updated_at": "2026-05-01T09:00:00Z" + } +] +``` + +## Create pipeline + +`POST /crm/pipelines` + +Create a pipeline, optionally seeding it with an ordered set of stages. + +Auth: **Scope** `WRITE_CRM` · **Org permission** `manage_contacts` + +### Request body + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `name` | string | yes | Pipeline name (1 to 255 characters). | +| `stages` | array | no | Stages to create with the pipeline, in order. | +| `stages[].name` | string | yes | Stage name (1 to 255 characters). | +| `stages[].color` | string | yes | Stage color (hex). | + +```json +{ + "name": "Sales", + "stages": [ + { "name": "Lead", "color": "#0ea5e9" }, + { "name": "Qualified", "color": "#8b5cf6" }, + { "name": "Won", "color": "#22c55e" } + ] +} +``` + +### Response + +`201 Created` with the created pipeline object (same shape as in the list response, including its `stages`). + +```json +{ + "id": "5d8a2b1e-0c4f-4a9b-9f2e-1a2b3c4d5e6f", + "organization_id": "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9", + "name": "Sales", + "position": 0, + "stages": [ + { + "id": "7f3c1a90-2b4d-4e6f-8a01-b2c3d4e5f607", + "pipeline_id": "5d8a2b1e-0c4f-4a9b-9f2e-1a2b3c4d5e6f", + "name": "Lead", + "color": "#0ea5e9", + "position": 0, + "created_at": "2026-06-12T10:00:00Z", + "updated_at": "2026-06-12T10:00:00Z" + } + ], + "created_at": "2026-06-12T10:00:00Z", + "updated_at": "2026-06-12T10:00:00Z" +} +``` + +## Get pipeline + +`GET /crm/pipelines/:id` + +Fetch a single pipeline with its stages. + +Auth: **Scope** `READ_CRM` · **Org permission** `view_contacts` + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `id` | path | uuid | Pipeline ID. | + +### Response + +Returns the pipeline object (same shape as a list item). + +```json +{ + "id": "5d8a2b1e-0c4f-4a9b-9f2e-1a2b3c4d5e6f", + "organization_id": "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9", + "name": "Sales", + "position": 0, + "stages": [], + "created_at": "2026-05-01T09:00:00Z", + "updated_at": "2026-05-01T09:00:00Z" +} +``` + +## Update pipeline + +`PATCH /crm/pipelines/:id` + +Rename a pipeline. Only the name can be changed here; stages have their own endpoints. + +Auth: **Scope** `WRITE_CRM` · **Org permission** `manage_contacts` + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `id` | path | uuid | Pipeline ID. | + +### Request body + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `name` | string | no | New pipeline name. | + +```json +{ + "name": "Enterprise Sales" +} +``` + +### Response + +Returns the updated pipeline object. + +## Delete pipeline + +`DELETE /crm/pipelines/:id` + +Delete a pipeline and its stages. + +Auth: **Scope** `WRITE_CRM` · **Org permission** `manage_contacts` + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `id` | path | uuid | Pipeline ID. | + +### Response + +`204 No Content`. + +## Create stage + +`POST /crm/pipelines/:id/stages` + +Append a stage to a pipeline. + +Auth: **Scope** `WRITE_CRM` · **Org permission** `manage_contacts` + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `id` | path | uuid | Pipeline ID. | + +### Request body + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `name` | string | yes | Stage name (1 to 255 characters). | +| `color` | string | yes | Stage color (hex). | + +```json +{ + "name": "Negotiation", + "color": "#f59e0b" +} +``` + +### Response + +`201 Created` with the created stage object. + +```json +{ + "id": "9c0d1e2f-3a4b-4c5d-6e7f-8a9b0c1d2e3f", + "pipeline_id": "5d8a2b1e-0c4f-4a9b-9f2e-1a2b3c4d5e6f", + "name": "Negotiation", + "color": "#f59e0b", + "position": 3, + "created_at": "2026-06-12T10:05:00Z", + "updated_at": "2026-06-12T10:05:00Z" +} +``` + +## Update stage + +`PATCH /crm/pipelines/:id/stages/:stageId` + +Rename or recolor a stage. + +Auth: **Scope** `WRITE_CRM` · **Org permission** `manage_contacts` + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `id` | path | uuid | Pipeline ID. | +| `stageId` | path | uuid | Stage ID. | + +### Request body + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `name` | string | no | New stage name. | +| `color` | string | no | New stage color (hex). | + +```json +{ + "name": "Contract Sent", + "color": "#6366f1" +} +``` + +### Response + +Returns the updated stage object. + +## Delete stage + +`DELETE /crm/pipelines/:id/stages/:stageId` + +Remove a stage from a pipeline. + +Auth: **Scope** `WRITE_CRM` · **Org permission** `manage_contacts` + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `id` | path | uuid | Pipeline ID. | +| `stageId` | path | uuid | Stage ID. | + +### Response + +`204 No Content`. + +## List deals + +`GET /crm/deals` + +List deals with optional pipeline, stage, and status filters, keyset-paginated. + +Auth: **Scope** `READ_CRM` · **Org permission** `view_contacts` + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `pipeline_id` | query | uuid | Restrict to deals in this pipeline. | +| `stage_id` | query | uuid | Restrict to deals in this stage. | +| `status` | query | string | Restrict to `open`, `won`, or `lost`. | +| `cursor` | query | string | Opaque cursor from a previous page's `pagination.next_cursor`. | +| `limit` | query | int | Page size, 1 to 100 (default 50). | + +### Response + +A `data` array of deal objects plus a keyset `pagination` envelope. List rows may include joined `contact` and `stage` objects and a `campaign_name`. + +```json +{ + "data": [ + { + "id": "1f2e3d4c-5b6a-4789-90ab-cdef01234567", + "organization_id": "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9", + "pipeline_id": "5d8a2b1e-0c4f-4a9b-9f2e-1a2b3c4d5e6f", + "stage_id": "7f3c1a90-2b4d-4e6f-8a01-b2c3d4e5f607", + "contact_id": "aa11bb22-cc33-4dd4-95ee-66ff77008811", + "name": "Acme renewal", + "value": 12000, + "currency": "USD", + "status": "open", + "expected_close_date": "2026-07-15T00:00:00Z", + "assigned_to": "bb22cc33-dd44-4ee5-86ff-770011223344", + "campaign_id": "cc33dd44-ee55-4ff6-9700-112233445566", + "source_mailbox_id": "dd44ee55-ff66-4007-8811-223344556677", + "created_at": "2026-06-01T12:00:00Z", + "updated_at": "2026-06-10T09:30:00Z", + "campaign_name": "Q2 outbound" + } + ], + "pagination": { + "total": null, + "next_cursor": "c1_b3BhcXVlLWN1cnNvcg", + "has_more": true + } +} +``` + +## Create deal + +`POST /crm/deals` + +Create a deal in a pipeline stage, optionally linked to a contact and attributed to a campaign and source mailbox. + +Auth: **Scope** `WRITE_CRM` · **Org permission** `manage_contacts` + +### Request body + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `pipeline_id` | uuid | yes | Pipeline the deal belongs to. | +| `stage_id` | uuid | yes | Initial stage. | +| `contact_id` | uuid | no | Linked contact. | +| `name` | string | yes | Deal name (1 to 255 characters). | +| `value` | number | no | Monetary value. | +| `currency` | string | no | ISO currency code. | +| `expected_close_date` | string (date-time) | no | Expected close date. | +| `assigned_to` | uuid | no | Owner (org member user ID). | +| `campaign_id` | uuid | no | Attributed campaign. | +| `source_mailbox_id` | uuid | no | Sending mailbox that produced the originating reply. | + +```json +{ + "pipeline_id": "5d8a2b1e-0c4f-4a9b-9f2e-1a2b3c4d5e6f", + "stage_id": "7f3c1a90-2b4d-4e6f-8a01-b2c3d4e5f607", + "contact_id": "aa11bb22-cc33-4dd4-95ee-66ff77008811", + "name": "Acme renewal", + "value": 12000, + "currency": "USD", + "expected_close_date": "2026-07-15T00:00:00Z", + "assigned_to": "bb22cc33-dd44-4ee5-86ff-770011223344" +} +``` + +### Response + +`201 Created` with the created deal object. New deals default to `status: "open"`. + +```json +{ + "id": "1f2e3d4c-5b6a-4789-90ab-cdef01234567", + "organization_id": "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9", + "pipeline_id": "5d8a2b1e-0c4f-4a9b-9f2e-1a2b3c4d5e6f", + "stage_id": "7f3c1a90-2b4d-4e6f-8a01-b2c3d4e5f607", + "contact_id": "aa11bb22-cc33-4dd4-95ee-66ff77008811", + "name": "Acme renewal", + "value": 12000, + "currency": "USD", + "status": "open", + "expected_close_date": "2026-07-15T00:00:00Z", + "created_at": "2026-06-12T10:10:00Z", + "updated_at": "2026-06-12T10:10:00Z" +} +``` + +## Search deals + +`POST /crm/deals/search` + +Faceted, server-paginated deal search. Every filter is optional; an empty body matches every deal in the organization. Filters are sent in the JSON body, while `limit` and `cursor` are query params. + +Auth: **Scope** `READ_CRM` · **Org permission** `view_contacts` + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `limit` | query | int | Page size, 1 to 200 (default 50). | +| `cursor` | query | string | Opaque cursor from a previous page's `pagination.next_cursor`. Omit for the first page. | + +### Request body + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `query` | string | no | Case-insensitive match on deal name. | +| `statuses` | string[] | no | Any of `open`, `won`, `lost`. | +| `pipeline_ids` | string[] | no | Restrict to any of these pipelines. | +| `stage_ids` | string[] | no | Restrict to any of these stages. | +| `assigned_to` | string[] | no | Owner is any of these user IDs. | +| `campaign_ids` | string[] | no | Attributed campaign is any of these. | +| `min_value` | number | no | Value greater than or equal to. | +| `max_value` | number | no | Value less than or equal to. | +| `close_after` | string (date-time) | no | Expected close date on or after. | +| `close_before` | string (date-time) | no | Expected close date on or before. | +| `created_after` | string (date-time) | no | Created on or after. | +| `created_before` | string (date-time) | no | Created on or before. | +| `sort_by` | string | no | One of `created_at`, `updated_at`, `value`, `expected_close_date`, `name`. | +| `reverse` | boolean | no | `true` sorts ascending, `false` (default) descending. | + +```json +{ + "query": "renewal", + "statuses": ["open"], + "pipeline_ids": ["5d8a2b1e-0c4f-4a9b-9f2e-1a2b3c4d5e6f"], + "min_value": 1000, + "sort_by": "value", + "reverse": false +} +``` + +### Response + +A `data` array of deal objects (with joined `contact`, `stage`, and `campaign_name`) plus the standard `pagination` envelope (opaque `next_cursor`) with an exact `total`. + +```json +{ + "data": [ + { + "id": "1f2e3d4c-5b6a-4789-90ab-cdef01234567", + "organization_id": "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9", + "pipeline_id": "5d8a2b1e-0c4f-4a9b-9f2e-1a2b3c4d5e6f", + "stage_id": "7f3c1a90-2b4d-4e6f-8a01-b2c3d4e5f607", + "name": "Acme renewal", + "value": 12000, + "currency": "USD", + "status": "open", + "created_at": "2026-06-01T12:00:00Z", + "updated_at": "2026-06-10T09:30:00Z" + } + ], + "pagination": { + "total": 137, + "next_cursor": "o1_NTA", + "has_more": true + } +} +``` + +## Deals summary + +`POST /crm/deals/summary` + +Aggregate counts and value sums over the same filter body as deal search, so header totals and per-stage board column totals reflect the whole matching set rather than a single page. + +Auth: **Scope** `READ_CRM` · **Org permission** `view_contacts` + +### Request body + +Identical to [search deals](#search-deals). All facets are optional; an empty body summarizes every deal in the organization. + +```json +{ + "pipeline_ids": ["5d8a2b1e-0c4f-4a9b-9f2e-1a2b3c4d5e6f"], + "statuses": ["open", "won"] +} +``` + +### Response + +Returns the aggregate object. `stages` holds a per-stage count and open-deal value. `mixed_currency` is `true` when matching deals span more than one currency, in which case the top-level value sums should be treated as approximate. + +```json +{ + "total": 42, + "open_count": 30, + "open_value": 415000, + "won_count": 9, + "won_value": 220000, + "lost_count": 3, + "lost_value": 0, + "currency": "USD", + "stages": [ + { + "stage_id": "7f3c1a90-2b4d-4e6f-8a01-b2c3d4e5f607", + "count": 18, + "value": 240000 + } + ], + "mixed_currency": false +} +``` + +## Get deal + +`GET /crm/deals/:id` + +Fetch a single deal. + +Auth: **Scope** `READ_CRM` · **Org permission** `view_contacts` + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `id` | path | uuid | Deal ID. | + +### Response + +Returns the deal object. Joined `contact`, `stage`, and `campaign_name` are populated by the list and search queries, not by this single-row read. + +```json +{ + "id": "1f2e3d4c-5b6a-4789-90ab-cdef01234567", + "organization_id": "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9", + "pipeline_id": "5d8a2b1e-0c4f-4a9b-9f2e-1a2b3c4d5e6f", + "stage_id": "7f3c1a90-2b4d-4e6f-8a01-b2c3d4e5f607", + "contact_id": "aa11bb22-cc33-4dd4-95ee-66ff77008811", + "name": "Acme renewal", + "value": 12000, + "currency": "USD", + "status": "open", + "expected_close_date": "2026-07-15T00:00:00Z", + "created_at": "2026-06-01T12:00:00Z", + "updated_at": "2026-06-10T09:30:00Z" +} +``` + +## Update deal + +`PATCH /crm/deals/:id` + +Update a deal. Moving it to a different `stage_id` records a stage-change activity, and setting `status` to `won` or `lost` stamps the corresponding close timestamp. + +Auth: **Scope** `WRITE_CRM` · **Org permission** `manage_contacts` + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `id` | path | uuid | Deal ID. | + +### Request body + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `stage_id` | uuid | no | Move the deal to this stage. | +| `contact_id` | uuid | no | Linked contact. | +| `name` | string | no | Deal name. | +| `value` | number | no | Monetary value. | +| `currency` | string | no | ISO currency code. | +| `status` | string | no | One of `open`, `won`, `lost`. | +| `expected_close_date` | string (date-time) | no | Expected close date. | +| `lost_reason` | string | no | Reason recorded when marking lost. | +| `assigned_to` | uuid | no | Owner (org member user ID). | + +```json +{ + "stage_id": "9c0d1e2f-3a4b-4c5d-6e7f-8a9b0c1d2e3f", + "status": "won" +} +``` + +### Response + +Returns the updated deal object. + +## Delete deal + +`DELETE /crm/deals/:id` + +Delete a deal. + +Auth: **Scope** `WRITE_CRM` · **Org permission** `manage_contacts` + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `id` | path | uuid | Deal ID. | + +### Response + +`204 No Content`. + +## List task types + +`GET /crm/task-types` + +List the organization's CRM task types (the kinds of work a task represents, such as Call, Email, or Meeting). A default set is seeded the first time an org lists its types. + +Auth: **Scope** `READ_CRM` · **Org permission** `view_contacts` + +### Response + +Returns a `data` array of task type objects (no pagination envelope). + +```json +{ + "data": [ + { + "id": "e1d2c3b4-a5f6-4071-8293-a4b5c6d7e8f9", + "organization_id": "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9", + "name": "Call", + "color": "#8b5cf6", + "position": 0, + "created_at": "2026-05-01T09:00:00Z", + "updated_at": "2026-05-01T09:00:00Z" + } + ] +} +``` + +## Create task type + +`POST /crm/task-types` + +Create a CRM task type. + +Auth: **Scope** `WRITE_CRM` · **Org permission** `manage_contacts` + +### Request body + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `name` | string | yes | Type name (1 to 60 characters). | +| `color` | string | no | Type color (hex). | + +```json +{ + "name": "Demo", + "color": "#22c55e" +} +``` + +### Response + +`201 Created` with the created task type object. + +```json +{ + "id": "f0e1d2c3-b4a5-4607-8293-a4b5c6d7e8f9", + "organization_id": "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9", + "name": "Demo", + "color": "#22c55e", + "position": 3, + "created_at": "2026-06-12T10:20:00Z", + "updated_at": "2026-06-12T10:20:00Z" +} +``` + +## Update task type + +`PATCH /crm/task-types/:id` + +Rename, recolor, or reorder a task type. + +Auth: **Scope** `WRITE_CRM` · **Org permission** `manage_contacts` + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `id` | path | uuid | Task type ID. | + +### Request body + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `name` | string | no | New type name. | +| `color` | string | no | New type color (hex). | +| `position` | int | no | New ordering position. | + +```json +{ + "name": "Product demo", + "position": 1 +} +``` + +### Response + +Returns the updated task type object. + +## Delete task type + +`DELETE /crm/task-types/:id` + +Delete a task type. Tasks reference their type by name, so existing tasks keep their label and fall back to a neutral color rather than being orphaned. + +Auth: **Scope** `WRITE_CRM` · **Org permission** `manage_contacts` + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `id` | path | uuid | Task type ID. | + +### Response + +`204 No Content`. + +## List tasks + +`GET /crm/tasks` + +List CRM tasks with optional contact, deal, assignee, and status filters, keyset-paginated. + +Auth: **Scope** `READ_CRM` · **Org permission** `view_contacts` + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `contact_id` | query | uuid | Restrict to tasks linked to this contact. | +| `deal_id` | query | uuid | Restrict to tasks linked to this deal. | +| `assigned_to` | query | uuid | Restrict to tasks assigned to this user. | +| `status` | query | string | One of `pending`, `in_progress`, `completed`, `cancelled`. | +| `cursor` | query | string | Opaque cursor from a previous page's `pagination.next_cursor`. | +| `limit` | query | int | Page size, 1 to 100 (default 50). | + +### Response + +A `data` array of task objects plus a keyset `pagination` envelope. + +```json +{ + "data": [ + { + "id": "3b4c5d6e-7f80-4912-a3b4-c5d6e7f80912", + "organization_id": "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9", + "contact_id": "aa11bb22-cc33-4dd4-95ee-66ff77008811", + "deal_id": "1f2e3d4c-5b6a-4789-90ab-cdef01234567", + "assigned_to": "bb22cc33-dd44-4ee5-86ff-770011223344", + "created_by": "bb22cc33-dd44-4ee5-86ff-770011223344", + "title": "Send renewal quote", + "description": "Include the multi-year discount", + "due_date": "2026-06-20T17:00:00Z", + "priority": "high", + "type": "Email", + "status": "pending", + "created_at": "2026-06-12T08:00:00Z", + "updated_at": "2026-06-12T08:00:00Z" + } + ], + "pagination": { + "total": null, + "next_cursor": "c1_b3BhcXVlLWN1cnNvcg", + "has_more": false + } +} +``` + +## Create task + +`POST /crm/tasks` + +Create a CRM task, optionally linked to a contact and deal and assigned to a user or team. + +Auth: **Scope** `WRITE_CRM` · **Org permission** `manage_contacts` + +### Request body + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `title` | string | yes | Task title (1 to 255 characters). | +| `contact_id` | uuid | no | Linked contact. | +| `deal_id` | uuid | no | Linked deal. | +| `assigned_to` | uuid | no | Assignee user ID. | +| `assigned_team_id` | uuid | no | Assignee team ID. | +| `description` | string | no | Free-text description. | +| `due_date` | string (date-time) | no | Due date. | +| `priority` | string | no | One of `low`, `medium`, `high`, `urgent`. | +| `type` | string | no | Task type name (matches a configured task type). | + +```json +{ + "title": "Send renewal quote", + "contact_id": "aa11bb22-cc33-4dd4-95ee-66ff77008811", + "deal_id": "1f2e3d4c-5b6a-4789-90ab-cdef01234567", + "assigned_to": "bb22cc33-dd44-4ee5-86ff-770011223344", + "due_date": "2026-06-20T17:00:00Z", + "priority": "high", + "type": "Email" +} +``` + +### Response + +`201 Created` with the created task object. `created_by` is set to the authenticated user. + +```json +{ + "id": "3b4c5d6e-7f80-4912-a3b4-c5d6e7f80912", + "organization_id": "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9", + "contact_id": "aa11bb22-cc33-4dd4-95ee-66ff77008811", + "deal_id": "1f2e3d4c-5b6a-4789-90ab-cdef01234567", + "assigned_to": "bb22cc33-dd44-4ee5-86ff-770011223344", + "created_by": "bb22cc33-dd44-4ee5-86ff-770011223344", + "title": "Send renewal quote", + "due_date": "2026-06-20T17:00:00Z", + "priority": "high", + "type": "Email", + "status": "pending", + "created_at": "2026-06-12T10:25:00Z", + "updated_at": "2026-06-12T10:25:00Z" +} +``` + +## Search tasks + +`POST /crm/tasks/search` + +Faceted, server-paginated task search. Every filter is optional; an empty body matches every task in the organization. Filters are sent in the JSON body, while `limit` and `cursor` are query params. + +Auth: **Scope** `READ_CRM` · **Org permission** `view_contacts` + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `limit` | query | int | Page size, 1 to 200 (default 50). | +| `cursor` | query | string | Opaque cursor from a previous page's `pagination.next_cursor`. Omit for the first page. | + +### Request body + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `query` | string | no | Case-insensitive match on task title. | +| `statuses` | string[] | no | Any of `pending`, `in_progress`, `completed`, `cancelled`. | +| `priorities` | string[] | no | Any of `low`, `medium`, `high`, `urgent`. | +| `types` | string[] | no | Task type name is any of these. | +| `assigned_to` | string[] | no | Assignee user ID is any of these. | +| `team_ids` | uuid[] | no | Task team is any of these, or the assignee belongs to one. | +| `contact_id` | string | no | Linked contact. | +| `deal_id` | string | no | Linked deal. | +| `due_after` | string (date-time) | no | Due on or after. | +| `due_before` | string (date-time) | no | Due on or before. | +| `overdue` | boolean | no | Only tasks past due and not completed or cancelled. | +| `sort_by` | string | no | One of `created_at`, `due_date`, `priority`, `title`, `updated_at`. | +| `reverse` | boolean | no | `true` sorts ascending, `false` (default) descending. | + +```json +{ + "statuses": ["pending", "in_progress"], + "priorities": ["high", "urgent"], + "overdue": true, + "sort_by": "due_date", + "reverse": true +} +``` + +### Response + +A `data` array of task objects plus the standard `pagination` envelope (opaque `next_cursor`) with an exact `total`. + +```json +{ + "data": [ + { + "id": "3b4c5d6e-7f80-4912-a3b4-c5d6e7f80912", + "organization_id": "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9", + "created_by": "bb22cc33-dd44-4ee5-86ff-770011223344", + "title": "Send renewal quote", + "priority": "high", + "type": "Email", + "status": "pending", + "due_date": "2026-06-20T17:00:00Z", + "created_at": "2026-06-12T08:00:00Z", + "updated_at": "2026-06-12T08:00:00Z" + } + ], + "pagination": { + "total": 64, + "next_cursor": "o1_NTA", + "has_more": true + } +} +``` + +## Tasks summary + +`POST /crm/tasks/summary` + +Aggregate counts over the same filter body as task search, so header totals (by status, overdue, high priority) reflect the whole matching set rather than a single page. + +Auth: **Scope** `READ_CRM` · **Org permission** `view_contacts` + +### Request body + +Identical to [search tasks](#search-tasks). All facets are optional; an empty body summarizes every task in the organization. + +```json +{ + "assigned_to": ["bb22cc33-dd44-4ee5-86ff-770011223344"] +} +``` + +### Response + +Returns the aggregate counts. + +```json +{ + "total": 64, + "pending_count": 28, + "in_progress_count": 9, + "completed_count": 22, + "cancelled_count": 5, + "overdue_count": 7, + "high_priority_count": 11 +} +``` + +## Get task + +`GET /crm/tasks/:id` + +Fetch a single CRM task. + +Auth: **Scope** `READ_CRM` · **Org permission** `view_contacts` + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `id` | path | uuid | Task ID. | + +### Response + +Returns the task object (same shape as a list item). + +```json +{ + "id": "3b4c5d6e-7f80-4912-a3b4-c5d6e7f80912", + "organization_id": "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9", + "created_by": "bb22cc33-dd44-4ee5-86ff-770011223344", + "title": "Send renewal quote", + "priority": "high", + "type": "Email", + "status": "pending", + "due_date": "2026-06-20T17:00:00Z", + "created_at": "2026-06-12T08:00:00Z", + "updated_at": "2026-06-12T08:00:00Z" +} +``` + +## Update task + +`PATCH /crm/tasks/:id` + +Update a CRM task. Setting `status` to `completed` stamps the completion timestamp. + +Auth: **Scope** `WRITE_CRM` · **Org permission** `manage_contacts` + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `id` | path | uuid | Task ID. | + +### Request body + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `title` | string | no | Task title. | +| `assigned_to` | uuid | no | Assignee user ID. | +| `assigned_team_id` | uuid | no | Assignee team ID. | +| `description` | string | no | Free-text description. | +| `due_date` | string (date-time) | no | Due date. | +| `priority` | string | no | One of `low`, `medium`, `high`, `urgent`. | +| `type` | string | no | Task type name. | +| `status` | string | no | One of `pending`, `in_progress`, `completed`, `cancelled`. | + +```json +{ + "status": "completed" +} +``` + +### Response + +Returns the updated task object. + +## Delete task + +`DELETE /crm/tasks/:id` + +Delete a CRM task. + +Auth: **Scope** `WRITE_CRM` · **Org permission** `manage_contacts` + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `id` | path | uuid | Task ID. | + +### Response + +`204 No Content`. + +## Errors + +All endpoints return the standard error envelope on failure, for example a malformed UUID path param or an invalid request body. See [error codes](/api/error-codes/) for the full list. + +```json +{ + "error": "invalid_request", + "message": "invalid request body", + "code": "INVALID_REQUEST", + "request_id": "req_8f2c1a90b34d4e6f" +} +``` diff --git a/docs/content/docs/api/reference/deliverability-ops.mdx b/docs/content/docs/api/reference/deliverability-ops.mdx new file mode 100644 index 00000000..cc398adf --- /dev/null +++ b/docs/content/docs/api/reference/deliverability-ops.mdx @@ -0,0 +1,623 @@ +--- +title: Deliverability and ops +description: Ingest deliverability events, replay dead-lettered tasks, manage warmup routing rules, reply templates, and org-level outreach settings. +icon: ShieldCheck +--- + +This group covers the operational control surface for sending: posting deliverability signals (bounces, complaints, unsubscribes) back into the platform, listing and replaying dead-lettered tasks, defining premium-pool warmup routing preferences, managing reply templates, and reading or updating organization-wide advanced outreach settings. All routes are organization-scoped, so the caller must have an active organization selected (API keys are always bound to one organization). + +Errors follow the shared `{error, message, code, request_id}` envelope. See [error codes](/api/error-codes/) for the full list, and [authentication](/api/authentication/) for how scopes and organization permissions combine. + +## Get outreach settings + +`GET /outreach/settings` + +Returns the organization's advanced outreach settings: the bounce pipeline, task reliability, A/B testing, reply-intent, send-time optimization, preflight, and deliverability-dashboard configuration blocks. + +Auth: **Scope** `WRITE_CAMPAIGNS` · **Org permission** `manage_settings` + +### Response + +Returns the `AdvancedOutreachSettings` object directly (not wrapped in an envelope). + +```json +{ + "bounce_pipeline": { + "enabled": true, + "auto_suppress_on_bounce": true, + "auto_suppress_on_complaint": true, + "auto_suppress_on_unsubscribe": true, + "auto_pause_campaign_on_spike": true, + "pause_bounce_rate_threshold": 8, + "pause_complaint_rate_threshold": 1.5 + }, + "task_reliability": { + "enabled": true, + "dlq_enabled": true, + "max_attempts": 5, + "execution_window_seconds": 300 + }, + "ab_testing": { + "enabled": true, + "default_winning_rule": "reply_rate", + "auto_promote_winner": false, + "min_sample_size": 30 + }, + "reply_intent": { + "enabled": true, + "positive_keywords": ["interested", "demo", "pricing"], + "negative_keywords": ["not interested", "unsubscribe", "stop"], + "out_of_office_keywords": ["out of office", "ooo", "vacation"], + "question_keywords": ["?", "how", "price"], + "auto_create_crm_task": true, + "auto_pause_on_negative": false, + "auto_suppress_on_unsubscribe_keyword": true + }, + "send_time_optimization": { + "enabled": true, + "use_contact_timezone": true, + "default_contact_timezone": "UTC", + "preferred_hours": [9, 10, 11, 14, 15, 16], + "weekend_weight_multiplier": 0.5 + }, + "preflight": { + "enabled": true, + "check_tracking_domain": true, + "check_unsubscribe_header": true, + "check_ab_variant_configured": false, + "check_daily_limit": true, + "check_schedule_window": true + }, + "dashboard": { + "enabled": true, + "show_suppression_log": true, + "show_intent_summary": true, + "show_dlq_stats": true + }, + "custom": {} +} +``` + +## Update outreach settings + +`PATCH /outreach/settings` + +Replaces the organization's advanced outreach settings with the supplied object. Send the full settings block (the value is upserted, not deep-merged). Returns no body on success. + +Auth: **Scope** `WRITE_CAMPAIGNS` · **Org permission** `manage_settings` + +### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `settings` | object | Yes | The full `AdvancedOutreachSettings` object (same shape as the GET response). | + +```json +{ + "settings": { + "bounce_pipeline": { + "enabled": true, + "auto_suppress_on_bounce": true, + "auto_suppress_on_complaint": true, + "auto_suppress_on_unsubscribe": true, + "auto_pause_campaign_on_spike": true, + "pause_bounce_rate_threshold": 8, + "pause_complaint_rate_threshold": 1.5 + }, + "task_reliability": { + "enabled": true, + "dlq_enabled": true, + "max_attempts": 5, + "execution_window_seconds": 300 + }, + "ab_testing": { + "enabled": true, + "default_winning_rule": "reply_rate", + "auto_promote_winner": false, + "min_sample_size": 30 + } + } +} +``` + +### Response + +`204 No Content`. + +## Ingest a deliverability event + +`POST /deliverability/events` + +Posts a single deliverability signal (bounce, complaint, unsubscribe, open, click, or reply) into the platform. This is API-key callable so downstream pipelines (for example an SES bounce processor) can report events without a human in the loop. Depending on the org's bounce-pipeline settings, a bounce, complaint, or unsubscribe may auto-suppress the recipient. Supply an `idempotency_key` to make retries safe. + +Auth: **Scope** `WRITE_CAMPAIGNS` · **Org permission** `send_campaigns` + +### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `event_type` | string | Yes | One of `bounce`, `complaint`, `unsubscribe`, `open`, `click`, `reply`. | +| `recipient_email` | string | Yes | The recipient address the event is about. | +| `campaign_id` | uuid | No | Campaign the event is attributed to. | +| `task_id` | uuid | No | Send task the event is attributed to. | +| `contact_id` | uuid | No | Contact the event is attributed to. | +| `provider` | string | No | Source provider label (e.g. `ses`, `postmark`). | +| `reason` | string | No | Human-readable reason or diagnostic text. | +| `idempotency_key` | string | No | De-duplicates retried events. | +| `metadata` | object | No | Free-form JSON attached to the event. | + +```json +{ + "event_type": "bounce", + "recipient_email": "prospect@example.com", + "campaign_id": "8f1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9", + "provider": "ses", + "reason": "550 5.1.1 mailbox does not exist", + "idempotency_key": "ses-bounce-01hzx9q", + "metadata": { "bounce_type": "Permanent" } +} +``` + +### Response + +`202 Accepted` with no body. The event is queued for processing. + +## List task dead letters + +`GET /tasks/dlq` + +Lists tasks that exhausted their retry budget and landed in the dead-letter queue (failed sends, syncs, and other side-effectful work). Use this to inspect failures before replaying them. + +Auth: **Scope** `SEND_CAMPAIGNS` · **Org permission** `send_campaigns` + +| Parameter | In | Type | Description | +|-----------|----|------|-------------| +| `status` | query | string | Optional status filter (e.g. `pending`, `replayed`). | +| `limit` | query | integer | Max rows to return, 1 to 200 (default 100). | + +### Response + +Returns a `data` array of dead-letter records. This endpoint is not cursor-paginated; it returns up to `limit` rows in one response. + +```json +{ + "data": [ + { + "id": "1a2b3c4d-5e6f-7081-9293-a4b5c6d7e8f9", + "task_id": "9f8e7d6c-5b4a-3021-8f7e-6d5c4b3a2110", + "task_type": "send_campaign_email", + "payload": { "campaign_id": "8f1b2c3d-...", "email_account_id": "..." }, + "last_error": "smtp: 421 too many connections", + "attempts": 5, + "max_attempts": 5, + "status": "pending", + "next_retry_at": null, + "replayed_at": null, + "created_at": "2026-06-11T14:02:09Z", + "updated_at": "2026-06-11T14:31:50Z" + } + ] +} +``` + +## Replay a task dead letter + +`POST /tasks/dlq/:id/replay` + +Re-dispatches a dead-lettered task. Because a replay can transmit real mail, this requires the send permission rather than plain write access. + +Auth: **Scope** `SEND_CAMPAIGNS` · **Org permission** `send_campaigns` + +| Parameter | In | Type | Description | +|-----------|----|------|-------------| +| `id` | path | uuid | The dead-letter record ID (the `id` field from the DLQ list, not `task_id`). | + +### Response + +`200 OK`. + +```json +{ "status": "replayed" } +``` + +## List warmup routing rules + +`GET /warmup/routing` + +Returns every warmup routing rule for the organization, ordered by `priority` ascending (first to evaluate). Rules express premium-pool partner preferences, for example "only send to Gmail recipients from Google-classified senders". + +Auth: **Scope** `WARMUP_ROUTING` · **Org permission** `manage_settings` + +### Response + +Returns the rules under a `rules` key (always an array, never null). + +```json +{ + "rules": [ + { + "id": "c1d2e3f4-5061-7283-94a5-b6c7d8e9f0a1", + "organization_id": "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9", + "name": "Gmail to Gmail only", + "priority": 10, + "sender_match_type": "provider", + "sender_match_value": "google", + "recipient_match_type": "provider", + "recipient_match_value": "google", + "weight": 1.0, + "enabled": true, + "created_at": "2026-06-01T09:00:00Z", + "updated_at": "2026-06-01T09:00:00Z" + } + ] +} +``` + +## Create a warmup routing rule + +`POST /warmup/routing` + +Creates a routing rule for the organization. Both the sender and recipient side are matched; a rule applies only when both sides match. Match values are lowercased and trimmed on write. + +Auth: **Scope** `WARMUP_ROUTING` · **Org permission** `manage_settings` + +### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `name` | string | Yes | Display name for the rule. | +| `priority` | integer | No | Evaluation order, ascending. Lower runs first. | +| `sender_match_type` | string | Yes | One of `any`, `domain`, `tld`, `provider`. | +| `sender_match_value` | string | Conditional | Required unless `sender_match_type` is `any`. Domain (`acme.com`), TLD (`com`), or provider bucket (`google`, `microsoft`, `yahoo`, `apple`, `proton`, `zoho`, `custom`). | +| `recipient_match_type` | string | Yes | One of `any`, `domain`, `tld`, `provider`. | +| `recipient_match_value` | string | Conditional | Required unless `recipient_match_type` is `any`. Same value forms as the sender side. | +| `weight` | number | No | Selection weight, must be `>= 0`. | +| `enabled` | boolean | No | Whether the rule is active. | + +```json +{ + "name": "Gmail to Gmail only", + "priority": 10, + "sender_match_type": "provider", + "sender_match_value": "google", + "recipient_match_type": "provider", + "recipient_match_value": "google", + "weight": 1.0, + "enabled": true +} +``` + +### Response + +`201 Created` with the full rule object (same shape as a list item, with server-managed `id`, `organization_id`, `created_at`, `updated_at`). + +```json +{ + "id": "c1d2e3f4-5061-7283-94a5-b6c7d8e9f0a1", + "organization_id": "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9", + "name": "Gmail to Gmail only", + "priority": 10, + "sender_match_type": "provider", + "sender_match_value": "google", + "recipient_match_type": "provider", + "recipient_match_value": "google", + "weight": 1.0, + "enabled": true, + "created_at": "2026-06-11T16:20:00Z", + "updated_at": "2026-06-11T16:20:00Z" +} +``` + +## Update a warmup routing rule + +`PATCH /warmup/routing/:id` + +Replaces a rule by ID. The body is the same full payload as create (all fields are applied, not deep-merged), and the same validation rules apply. + +Auth: **Scope** `WARMUP_ROUTING` · **Org permission** `manage_settings` + +| Parameter | In | Type | Description | +|-----------|----|------|-------------| +| `id` | path | uuid | The rule ID. | + +### Request body + +Same fields as [create a warmup routing rule](#create-a-warmup-routing-rule). + +```json +{ + "name": "Gmail to Gmail only", + "priority": 5, + "sender_match_type": "provider", + "sender_match_value": "google", + "recipient_match_type": "provider", + "recipient_match_value": "google", + "weight": 2.0, + "enabled": true +} +``` + +### Response + +`200 OK` with the updated rule object. + +## Delete a warmup routing rule + +`DELETE /warmup/routing/:id` + +Removes a routing rule by ID. + +Auth: **Scope** `WARMUP_ROUTING` · **Org permission** `manage_settings` + +| Parameter | In | Type | Description | +|-----------|----|------|-------------| +| `id` | path | uuid | The rule ID. | + +### Response + +`204 No Content`. + +## List reply templates + +`GET /templates` + +Lists the organization's reply templates, ordered by position. An optional `q` filter matches against name and subject (case-insensitive). + +Auth: **Scope** `READ_TEMPLATES` · **Org permission** `view_campaigns` + +| Parameter | In | Type | Description | +|-----------|----|------|-------------| +| `q` | query | string | Optional case-insensitive search over name and subject. | + +### Response + +Returns templates under a `data` key (not cursor-paginated). + +```json +{ + "data": [ + { + "id": "11111111-2222-3333-4444-555555555555", + "organization_id": "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9", + "user_id": "99999999-8888-7777-6666-555555555555", + "name": "Pricing follow-up", + "subject": "Re: pricing for {{.Company}}", + "body_html": "

Hi {{.FirstName}},

", + "body_plain": "Hi {{.FirstName}},", + "position": 1, + "created_at": "2026-05-20T10:00:00Z", + "updated_at": "2026-05-20T10:00:00Z" + } + ] +} +``` + +## Create a reply template + +`POST /templates` + +Creates a reply template owned by the calling user, appended to the end of the org's list. + +Auth: **Scope** `WRITE_TEMPLATES` · **Org permission** `manage_campaigns` + +### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `name` | string | Yes | Template name, max 255 characters. | +| `subject` | string | No | Subject line (may contain `{{.Key}}` placeholders). | +| `body_html` | string | No | HTML body. | +| `body_plain` | string | No | Plain-text body. | + +```json +{ + "name": "Pricing follow-up", + "subject": "Re: pricing for {{.Company}}", + "body_html": "

Hi {{.FirstName}},

", + "body_plain": "Hi {{.FirstName}}," +} +``` + +### Response + +`200 OK` with the created `ReplyTemplate`. + +```json +{ + "id": "11111111-2222-3333-4444-555555555555", + "organization_id": "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9", + "user_id": "99999999-8888-7777-6666-555555555555", + "name": "Pricing follow-up", + "subject": "Re: pricing for {{.Company}}", + "body_html": "

Hi {{.FirstName}},

", + "body_plain": "Hi {{.FirstName}},", + "position": 3, + "created_at": "2026-06-11T17:00:00Z", + "updated_at": "2026-06-11T17:00:00Z" +} +``` + +## Reorder reply templates + +`PATCH /templates/reorder` + +Repositions templates to match the supplied ID order (1-indexed). IDs omitted from the list are left untouched. Returns the full reordered list. + +Auth: **Scope** `WRITE_TEMPLATES` · **Org permission** `manage_campaigns` + +### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `ids` | array of uuid | Yes | Template IDs in their new order. | + +```json +{ + "ids": [ + "33333333-3333-3333-3333-333333333333", + "11111111-2222-3333-4444-555555555555" + ] +} +``` + +### Response + +`200 OK` with the reordered list under `data` (same shape as [list reply templates](#list-reply-templates)). + +## Get a reply template + +`GET /templates/:id` + +Retrieves a single reply template by ID. + +Auth: **Scope** `READ_TEMPLATES` · **Org permission** `view_campaigns` + +| Parameter | In | Type | Description | +|-----------|----|------|-------------| +| `id` | path | uuid | The template ID. | + +### Response + +`200 OK` with the `ReplyTemplate` object (same shape as a list item). + +## Update a reply template + +`PATCH /templates/:id` + +Updates a reply template. All fields are optional; omitted fields are left unchanged. + +Auth: **Scope** `WRITE_TEMPLATES` · **Org permission** `manage_campaigns` + +| Parameter | In | Type | Description | +|-----------|----|------|-------------| +| `id` | path | uuid | The template ID. | + +### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `name` | string | No | New name. | +| `subject` | string | No | New subject. | +| `body_html` | string | No | New HTML body. | +| `body_plain` | string | No | New plain-text body. | + +```json +{ + "subject": "Re: updated pricing for {{.Company}}" +} +``` + +### Response + +`200 OK` with the updated `ReplyTemplate`. + +## Delete a reply template + +`DELETE /templates/:id` + +Deletes a reply template by ID. + +Auth: **Scope** `WRITE_TEMPLATES` · **Org permission** `manage_campaigns` + +| Parameter | In | Type | Description | +|-----------|----|------|-------------| +| `id` | path | uuid | The template ID. | + +### Response + +`204 No Content`. + +## Duplicate a reply template + +`POST /templates/:id/duplicate` + +Clones a template, appending " (copy)" to the name and placing the clone at the end of the org's list. + +Auth: **Scope** `WRITE_TEMPLATES` · **Org permission** `manage_campaigns` + +| Parameter | In | Type | Description | +|-----------|----|------|-------------| +| `id` | path | uuid | The source template ID. | + +### Response + +`200 OK` with the newly created `ReplyTemplate` (same shape as create). + +## Render a reply template + +`POST /templates/:id/render` + +Expands `{{.Key}}` placeholders in the template's subject and body using a caller-supplied variable map. Used to preview a reply before sending. The body is optional; an empty map renders all placeholders empty. + +Auth: **Scope** `READ_TEMPLATES` · **Org permission** `view_campaigns` + +| Parameter | In | Type | Description | +|-----------|----|------|-------------| +| `id` | path | uuid | The template ID. | + +### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `variables` | object (string to string) | No | Values substituted into `{{.Key}}` placeholders. | + +```json +{ + "variables": { + "FirstName": "Dana", + "Company": "Acme" + } +} +``` + +### Response + +`200 OK` with the rendered subject and body. + +```json +{ + "subject": "Re: pricing for Acme", + "body_html": "

Hi Dana,

", + "body_plain": "Hi Dana," +} +``` + +## Score template content + +`POST /templates/score` + +Returns an advisory deliverability content score (0 to 100, higher is safer) for a subject and body, plus the issues found. This is advisory only and never blocks sending. It does not read a stored template; it scores the content in the request body. + +Auth: **Scope** `READ_TEMPLATES` · **Org permission** `view_campaigns` + +### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `subject` | string | No | Subject line to score. | +| `body_html` | string | No | HTML body (used when `body_plain` is empty). | +| `body_plain` | string | No | Plain-text body, preferred over HTML when present. | + +```json +{ + "subject": "Quick question about {{.Company}}", + "body_plain": "Hi Dana, are you the right person to talk to about outreach?" +} +``` + +### Response + +`200 OK` with the score and any advisory issues. + +```json +{ + "score": 92, + "issues": [ + { + "severity": "warn", + "code": "too_many_links", + "message": "4 links found; keep cold-email link count low." + } + ] +} +``` diff --git a/docs/content/docs/api/reference/integrations.mdx b/docs/content/docs/api/reference/integrations.mdx new file mode 100644 index 00000000..c27c1016 --- /dev/null +++ b/docs/content/docs/api/reference/integrations.mdx @@ -0,0 +1,1274 @@ +--- +title: Integrations, automations, meetings +description: Connect third-party apps, build automation flows, capture booked meetings, and run on-demand Google Sheets lead syncs. +icon: Plug +--- + +These endpoints cover Warmbly's integration layer: the provider catalog and connection lifecycle, per-connection event subscriptions and field mappings, the visual automation flow builder, the booked-meetings list, and on-demand Google Sheets lead sync. Most reads are reachable by operational integration users (`use_integrations` / `INTEGRATIONS`), while connecting and configuring is a settings action (`manage_settings`). + +Integrations are a paid-plan feature. Browsing the catalog and listing connections is open so non-paid orgs see the upsell, but any mutating call (connect, authorize, configure, push, test, automations write) returns `403` with a "Integrations are available on paid plans" message when the organization is not on a paid plan. + +List shapes here are mostly ad-hoc envelopes (`{"connections": [...]}`, `{"automations": [...]}`, and so on) rather than the cursor-paginated `data` + `pagination` shape used elsewhere. The meetings search and lead-sync source list are the exceptions and are noted inline. Errors follow the standard `{error, message, code, request_id}` envelope (see [error codes](/api/error-codes/)). + +## List the integration catalog + +`GET /integrations/catalog` + +Returns the static metadata for every provider Warmbly supports, annotated with whether each OAuth provider has server-side credentials wired (`configured`). + +Auth: **Scope** `INTEGRATIONS` (or session with **Org permission** `manage_settings` or `use_integrations`). + +### Response + +A `catalog` array of provider entries. + +```json +{ + "catalog": [ + { + "provider": "hubspot", + "name": "HubSpot", + "tagline": "Sync contacts and deals to your CRM", + "category": "crm", + "auth_method": "oauth", + "beta": false, + "highlights": ["Push contacts on demand", "Auto-sync on positive reply"], + "scopes": ["crm.objects.contacts.write"], + "events": ["email.replied", "meeting.booked"], + "action_types": ["hubspot.upsert_contact"], + "supports_push": true, + "configured": true + } + // ... one entry per provider + ] +} +``` + +## List connections + +`GET /integrations/connections` + +Returns this org's connection rows. Secrets (access/refresh tokens, pasted API keys) are never serialized. + +Auth: **Scope** `INTEGRATIONS` (or session with **Org permission** `manage_settings` or `use_integrations`). + +### Response + +A `connections` array of `IntegrationConnection` objects. + +```json +{ + "connections": [ + { + "id": "9b2c1f7e-4d6a-4f2b-bb11-9d0e2a7c5f33", + "organization_id": "1a2b3c4d-0000-0000-0000-000000000001", + "provider": "hubspot", + "label": "HubSpot (Sales)", + "status": "connected", + "auth_method": "oauth", + "display_fields": {}, + "sync_direction": "push", + "external_account_name": "Acme Inc", + "granted_scopes": ["crm.objects.contacts.write"], + "health": "healthy", + "last_synced_at": "2026-06-10T14:03:00Z", + "created_at": "2026-05-01T09:00:00Z", + "updated_at": "2026-06-10T14:03:00Z" + } + ] +} +``` + +## Create a connection + +`POST /integrations/connections` + +Creates a credential-based connection for `api_key` / `webhook` providers (for example Close or Discord). OAuth providers are rejected here with a hint to start the authorize flow instead (`POST /integrations/oauth/start`, session only). + +Auth: **Scope** `INTEGRATIONS` · **Org permission** `manage_settings`. + +### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `provider` | string | Yes | A valid provider id (for example `close`, `discord`). | +| `label` | string | No | Friendly name shown on the connection card. | +| `config` | object | No | Provider-specific config (for example the pasted API key or webhook URL). | + +```json +{ + "provider": "close", + "label": "Close (Outbound)", + "config": { "api_key": "api_xxx" } +} +``` + +### Response + +`201 Created` with the created `IntegrationConnection` object (bare, not enveloped). For inbound providers (Calendly, Cal.com) the response includes `inbound_webhook_url` once. + +```json +{ + "id": "c0ffee00-0000-4000-8000-000000000abc", + "provider": "close", + "label": "Close (Outbound)", + "status": "connected", + "auth_method": "api_key", + "sync_direction": "push", + "health": "unknown", + "created_at": "2026-06-11T10:00:00Z", + "updated_at": "2026-06-11T10:00:00Z" +} +``` + +## Get a connection + +`GET /integrations/connections/:id` + +Returns one connection plus its event subscriptions and up to 20 recent sync runs (the detail drawer payload). + +Auth: **Scope** `INTEGRATIONS` (or session with **Org permission** `manage_settings` or `use_integrations`). + +| Parameter | In | Type | Description | +|-----------|-----|------|-------------| +| `id` | path | uuid | Connection id. | + +### Response + +```json +{ + "connection": { + "id": "9b2c1f7e-4d6a-4f2b-bb11-9d0e2a7c5f33", + "provider": "hubspot", + "label": "HubSpot (Sales)", + "status": "connected", + "auth_method": "oauth", + "sync_direction": "push", + "health": "healthy", + "created_at": "2026-05-01T09:00:00Z", + "updated_at": "2026-06-10T14:03:00Z" + }, + "events": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "connection_id": "9b2c1f7e-4d6a-4f2b-bb11-9d0e2a7c5f33", + "event_type": "email.replied", + "action": "hubspot.upsert_contact", + "config": {}, + "enabled": true, + "use_case": "crm_sync", + "created_at": "2026-05-01T09:05:00Z", + "updated_at": "2026-05-01T09:05:00Z" + } + ], + "runs": [ + { + "id": "22222222-2222-4222-8222-222222222222", + "connection_id": "9b2c1f7e-4d6a-4f2b-bb11-9d0e2a7c5f33", + "kind": "push", + "status": "success", + "detail": "pushed 12 contacts", + "records_processed": 12, + "started_at": "2026-06-10T14:02:00Z", + "finished_at": "2026-06-10T14:03:00Z" + } + ] +} +``` + +## Update connection config + +`PATCH /integrations/connections/:id/config` + +Saves a connection's onboarding/capability snapshot (selected objects, enabled use-cases) and its sync direction. + +Auth: **Scope** `INTEGRATIONS` · **Org permission** `manage_settings`. + +| Parameter | In | Type | Description | +|-----------|-----|------|-------------| +| `id` | path | uuid | Connection id. | + +### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `config_capabilities` | object | No | Per-connection capability snapshot (picker selections, enabled use-cases). | +| `sync_direction` | string | No | Data-flow direction: `push`, `pull`, or `both`. | + +```json +{ + "config_capabilities": { "objects": ["contact"], "use_cases": ["crm_sync"] }, + "sync_direction": "push" +} +``` + +### Response + +```json +{ + "connection": { + "id": "9b2c1f7e-4d6a-4f2b-bb11-9d0e2a7c5f33", + "provider": "hubspot", + "sync_direction": "push", + "status": "connected", + "health": "healthy", + "updated_at": "2026-06-11T11:00:00Z" + } +} +``` + +## Disconnect + +`DELETE /integrations/connections/:id` + +Removes a connection row. + +Auth: **Scope** `INTEGRATIONS` · **Org permission** `manage_settings`. + +| Parameter | In | Type | Description | +|-----------|-----|------|-------------| +| `id` | path | uuid | Connection id. | + +### Response + +`204 No Content`. + +## List event subscriptions + +`GET /integrations/connections/:id/events` + +Returns the event subscriptions (event-to-action routes) configured on a connection. + +Auth: **Scope** `INTEGRATIONS` (or session with **Org permission** `manage_settings` or `use_integrations`). + +| Parameter | In | Type | Description | +|-----------|-----|------|-------------| +| `id` | path | uuid | Connection id. | + +### Response + +```json +{ + "events": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "connection_id": "9b2c1f7e-4d6a-4f2b-bb11-9d0e2a7c5f33", + "event_type": "email.replied", + "action": "hubspot.upsert_contact", + "config": {}, + "enabled": true, + "use_case": "crm_sync", + "automation_id": null, + "created_at": "2026-05-01T09:05:00Z", + "updated_at": "2026-05-01T09:05:00Z" + } + ] +} +``` + +## Create an event subscription + +`POST /integrations/connections/:id/events` + +Routes a Warmbly event to a provider action on this connection. + +Auth: **Scope** `INTEGRATIONS` · **Org permission** `manage_settings`. + +| Parameter | In | Type | Description | +|-----------|-----|------|-------------| +| `id` | path | uuid | Connection id. | + +### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `event_type` | string | Yes | The Warmbly event to react to (for example `email.replied`). | +| `action` | string | Yes | Provider action id (for example `slack.notify`, `hubspot.upsert_contact`). | +| `config` | object | No | Action config (for example a Slack channel or message template). | +| `enabled` | boolean | No | Defaults to `true` when omitted. | + +```json +{ + "event_type": "email.replied", + "action": "slack.notify", + "config": { "channel": "#sales" }, + "enabled": true +} +``` + +### Response + +`201 Created` with the created `IntegrationEventSubscription` (bare object). + +```json +{ + "id": "33333333-3333-4333-8333-333333333333", + "connection_id": "9b2c1f7e-4d6a-4f2b-bb11-9d0e2a7c5f33", + "event_type": "email.replied", + "action": "slack.notify", + "config": { "channel": "#sales" }, + "enabled": true, + "use_case": "notify", + "created_at": "2026-06-11T12:00:00Z", + "updated_at": "2026-06-11T12:00:00Z" +} +``` + +## Delete an event subscription + +`DELETE /integrations/connections/:id/events/:eventId` + +Removes one event subscription. + +Auth: **Scope** `INTEGRATIONS` · **Org permission** `manage_settings`. + +| Parameter | In | Type | Description | +|-----------|-----|------|-------------| +| `id` | path | uuid | Connection id. | +| `eventId` | path | uuid | Event subscription id. | + +### Response + +`204 No Content`. + +## List field mappings + +`GET /integrations/connections/:id/field-mappings` + +Returns the Warmbly-field to provider-field maps configured for a connection. + +Auth: **Scope** `INTEGRATIONS` (or session with **Org permission** `manage_settings` or `use_integrations`). + +| Parameter | In | Type | Description | +|-----------|-----|------|-------------| +| `id` | path | uuid | Connection id. | + +### Response + +```json +{ + "mappings": [ + { + "id": "44444444-4444-4444-8444-444444444444", + "connection_id": "9b2c1f7e-4d6a-4f2b-bb11-9d0e2a7c5f33", + "direction": "push", + "object_name": "contact", + "warmbly_field": "email", + "external_field": "email", + "transform": "", + "static_value": "", + "is_default": true, + "created_at": "2026-05-01T09:06:00Z" + } + ] +} +``` + +## Replace field mappings + +`PUT /integrations/connections/:id/field-mappings` + +Swaps the connection-default field map for an object wholesale. A full replace is naturally idempotent, so retries are safe without an `Idempotency-Key`. + +Auth: **Scope** `INTEGRATIONS` · **Org permission** `manage_settings`. + +| Parameter | In | Type | Description | +|-----------|-----|------|-------------| +| `id` | path | uuid | Connection id. | + +### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `object` | string | No | The provider object the mappings apply to (for example `contact`). | +| `mappings` | array | Yes | The full set of mappings to store (replaces any existing). | +| `mappings[].external_field` | string | Yes | Destination field on the provider. Required for every mapping. | +| `mappings[].warmbly_field` | string | Conditional | Source Warmbly field. Required unless `transform` is `static`. | +| `mappings[].transform` | string | No | One of `` (none), `none`, `static`, `uppercase`, `lowercase`, `trim`. | +| `mappings[].static_value` | string | Conditional | Required when `transform` is `static`. | + +```json +{ + "object": "contact", + "mappings": [ + { "warmbly_field": "email", "external_field": "email", "transform": "" }, + { "warmbly_field": "first_name", "external_field": "firstname", "transform": "trim" }, + { "external_field": "lifecyclestage", "transform": "static", "static_value": "lead" } + ] +} +``` + +### Response + +The full mapping set after the replace (same shape as the list endpoint). + +```json +{ + "mappings": [ + { + "id": "44444444-4444-4444-8444-444444444444", + "connection_id": "9b2c1f7e-4d6a-4f2b-bb11-9d0e2a7c5f33", + "object_name": "contact", + "warmbly_field": "email", + "external_field": "email", + "transform": "", + "is_default": true, + "created_at": "2026-06-11T12:30:00Z" + } + ] +} +``` + +## List sync runs + +`GET /integrations/connections/:id/runs` + +Returns up to 50 recent observability records for a connection (connect, token refresh, event dispatch, manual push). + +Auth: **Scope** `INTEGRATIONS` (or session with **Org permission** `manage_settings` or `use_integrations`). + +| Parameter | In | Type | Description | +|-----------|-----|------|-------------| +| `id` | path | uuid | Connection id. | + +### Response + +```json +{ + "runs": [ + { + "id": "22222222-2222-4222-8222-222222222222", + "connection_id": "9b2c1f7e-4d6a-4f2b-bb11-9d0e2a7c5f33", + "kind": "push", + "status": "success", + "detail": "pushed 12 contacts", + "records_processed": 12, + "started_at": "2026-06-10T14:02:00Z", + "finished_at": "2026-06-10T14:03:00Z" + } + ] +} +``` + +## Get the connection webhook secret + +`GET /integrations/connections/:id/webhook-secret` + +Returns (generating on first call) the HMAC signing secret for an automation connection, so you can verify Warmbly's outbound webhook signatures. + +Auth: **Scope** `INTEGRATIONS` · **Org permission** `manage_settings`. + +| Parameter | In | Type | Description | +|-----------|-----|------|-------------| +| `id` | path | uuid | Connection id. | + +### Response + +```json +{ + "signing_secret": "whsec_live_...", + "signature_header": "X-Warmbly-Signature", + "scheme": "HMAC-SHA256 of \"{t}.{body}\", sent as t=,v1=" +} +``` + +## Test a connection + +`POST /integrations/connections/:id/test` + +Fires a synthetic event through the connection's notify/webhook automations so you can confirm the Zap, scenario, or channel is wired. + +Auth: **Scope** `INTEGRATIONS` · **Org permission** `manage_settings`. + +| Parameter | In | Type | Description | +|-----------|-----|------|-------------| +| `id` | path | uuid | Connection id. | + +### Response + +```json +{ "sent": true } +``` + +## Push contacts to a CRM + +`POST /integrations/connections/:id/push` + +Synchronously upserts the given org contacts into a connected CRM (HubSpot, Pipedrive, Salesforce, Close). Retries are naturally safe: every provider upsert is keyed by email, so a repeated push converges rather than duplicating records. No `Idempotency-Key` is required. Per-record results are returned. + +Auth: **Scope** `INTEGRATIONS` · **Org permission** `use_integrations`. + +| Parameter | In | Type | Description | +|-----------|-----|------|-------------| +| `id` | path | uuid | Connection id. | + +### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `contact_ids` | string[] | Yes | Contact ids to push. Deduplicated server-side. At least 1, at most 500. | + +```json +{ + "contact_ids": [ + "aaaaaaaa-0000-4000-8000-000000000001", + "aaaaaaaa-0000-4000-8000-000000000002" + ] +} +``` + +### Response + +```json +{ + "provider": "hubspot", + "pushed": 1, + "failed": 1, + "results": [ + { "contact_id": "aaaaaaaa-0000-4000-8000-000000000001", "email": "jane@acme.com", "ok": true }, + { "contact_id": "aaaaaaaa-0000-4000-8000-000000000002", "email": "bad", "ok": false, "error": "invalid email" } + ] +} +``` + +A connection whose token can no longer be refreshed returns `409 Conflict` with a "needs to be reconnected" message. + +## List meeting bookings (integrations view) + +`GET /integrations/bookings` + +Returns up to 50 recent booked meetings, surfaced on the integrations page. For the full Meetings page list with filters and pagination, use `GET /meetings`. + +Auth: **Scope** `INTEGRATIONS` (or session with **Org permission** `manage_settings` or `use_integrations`). + +### Response + +```json +{ + "bookings": [ + { + "id": "55555555-5555-4555-8555-555555555555", + "source": "calendly", + "status": "booked", + "invitee_email": "lead@acme.com", + "invitee_name": "Lead Person", + "event_name": "Intro call", + "scheduled_for": "2026-06-15T16:00:00Z", + "join_url": "https://meet.example/abc", + "contact_id": "aaaaaaaa-0000-4000-8000-000000000001", + "created_at": "2026-06-11T08:00:00Z", + "updated_at": "2026-06-11T08:00:00Z" + } + ] +} +``` + +## List automations + +`GET /automations` + +Returns this org's automation flows (the visual flow builder). + +Auth: **Scope** `INTEGRATIONS` (or session with **Org permission** `manage_settings` or `use_integrations`). + +### Response + +```json +{ + "automations": [ + { + "id": "66666666-6666-4666-8666-666666666666", + "organization_id": "1a2b3c4d-0000-0000-0000-000000000001", + "name": "Notify on positive reply", + "enabled": true, + "trigger_event": "email.replied", + "graph": { "nodes": [], "edges": [] }, + "created_at": "2026-06-01T09:00:00Z", + "updated_at": "2026-06-01T09:00:00Z" + } + ] +} +``` + +## Create an automation + +`POST /automations` + +Creates a new automation flow: a trigger event plus a graph of condition and action nodes. + +Auth: **Scope** `INTEGRATIONS` (or session with **Org permission** `manage_settings` or `use_integrations`). + +### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `name` | string | Yes | Display name. | +| `enabled` | boolean | No | Whether the automation runs on matching events. | +| `trigger_event` | string | Yes | The event that fires the flow (for example `email.replied`). | +| `filter` | object | No | Optional automation-wide gate (for example intents / `min_confidence`) applied to every action. | +| `graph` | object | Yes | The flow graph: `nodes` and `edges`. | +| `graph.nodes[]` | object | Yes | A node. `type` is `trigger` (exactly one, id `trigger`), `condition`, or `action`. Action nodes carry `action`, optional `connection_id`, and `config`; condition nodes carry `condition`. Each node has `x` / `y` canvas coordinates. | +| `graph.edges[]` | object | Yes | An edge: `source`, `target`, and `when` (`""` for plain edges, `true` / `false` for the two outgoing edges of a condition). | + +```json +{ + "name": "Notify on positive reply", + "enabled": true, + "trigger_event": "email.replied", + "graph": { + "nodes": [ + { "id": "trigger", "type": "trigger", "x": 0, "y": 0 }, + { + "id": "a1", + "type": "action", + "action": "slack.notify", + "connection_id": "9b2c1f7e-4d6a-4f2b-bb11-9d0e2a7c5f33", + "config": { "channel": "#sales" }, + "x": 240, + "y": 0 + } + ], + "edges": [ + { "id": "e1", "source": "trigger", "target": "a1", "when": "" } + ] + } +} +``` + +### Response + +`201 Created` with the created automation under an `automation` key. + +```json +{ + "automation": { + "id": "66666666-6666-4666-8666-666666666666", + "name": "Notify on positive reply", + "enabled": true, + "trigger_event": "email.replied", + "graph": { "nodes": [ /* ... */ ], "edges": [ /* ... */ ] }, + "created_at": "2026-06-11T13:00:00Z", + "updated_at": "2026-06-11T13:00:00Z" + } +} +``` + +## Get an automation + +`GET /automations/:id` + +Returns one automation with its full graph. + +Auth: **Scope** `INTEGRATIONS` (or session with **Org permission** `manage_settings` or `use_integrations`). + +| Parameter | In | Type | Description | +|-----------|-----|------|-------------| +| `id` | path | uuid | Automation id. | + +### Response + +```json +{ + "automation": { + "id": "66666666-6666-4666-8666-666666666666", + "name": "Notify on positive reply", + "enabled": true, + "trigger_event": "email.replied", + "graph": { "nodes": [ /* ... */ ], "edges": [ /* ... */ ] }, + "created_at": "2026-06-01T09:00:00Z", + "updated_at": "2026-06-01T09:00:00Z" + } +} +``` + +## Update an automation + +`PATCH /automations/:id` + +Replaces an automation's name, enabled state, trigger, filter, and graph. The body shape matches the create payload. + +Auth: **Scope** `INTEGRATIONS` (or session with **Org permission** `manage_settings` or `use_integrations`). + +| Parameter | In | Type | Description | +|-----------|-----|------|-------------| +| `id` | path | uuid | Automation id. | + +### Request body + +Same fields as [create an automation](#create-an-automation). + +```json +{ + "name": "Notify on positive reply", + "enabled": false, + "trigger_event": "email.replied", + "graph": { "nodes": [ /* ... */ ], "edges": [ /* ... */ ] } +} +``` + +### Response + +```json +{ + "automation": { + "id": "66666666-6666-4666-8666-666666666666", + "name": "Notify on positive reply", + "enabled": false, + "trigger_event": "email.replied", + "graph": { "nodes": [ /* ... */ ], "edges": [ /* ... */ ] }, + "updated_at": "2026-06-11T13:30:00Z" + } +} +``` + +## Delete an automation + +`DELETE /automations/:id` + +Removes an automation. Returns `409 Conflict` when the automation is still referenced by campaign steps. + +Auth: **Scope** `INTEGRATIONS` (or session with **Org permission** `manage_settings` or `use_integrations`). + +| Parameter | In | Type | Description | +|-----------|-----|------|-------------| +| `id` | path | uuid | Automation id. | + +### Response + +```json +{ "deleted": true } +``` + +## Test an automation + +`POST /automations/:id/test` + +Runs the automation against sample (or provided) data without side effects and returns the walked trace plus per-action previews (the builder's "Test" button). + +Auth: **Scope** `INTEGRATIONS` (or session with **Org permission** `manage_settings` or `use_integrations`). + +| Parameter | In | Type | Description | +|-----------|-----|------|-------------| +| `id` | path | uuid | Automation id. | + +### Request body + +The body is optional. When omitted, the server builds a sample event from the trigger. + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `data` | object | No | Sample event payload to evaluate the flow against. | + +```json +{ + "data": { "email": "lead@acme.com", "intent": "positive" } +} +``` + +### Response + +A dry-run trace plus the resolved event data. + +```json +{ + "trace": [ + { "node_id": "trigger", "type": "trigger", "status": "success" }, + { + "node_id": "a1", + "type": "action", + "action": "slack.notify", + "label": "Slack · #sales", + "status": "success", + "preview": { "channel": "#sales", "text": "Lead replied: lead@acme.com" } + } + ], + "data": { "email": "lead@acme.com", "intent": "positive" } +} +``` + +## List automation runs + +`GET /automations/:id/runs` + +Returns recent run history for an automation (per fired event or manual launch), with per-node outcomes. + +Auth: **Scope** `INTEGRATIONS` (or session with **Org permission** `manage_settings` or `use_integrations`). + +| Parameter | In | Type | Description | +|-----------|-----|------|-------------| +| `id` | path | uuid | Automation id. | +| `limit` | query | int | Max runs to return. Defaults to 50. | + +### Response + +```json +{ + "runs": [ + { + "id": "77777777-7777-4777-8777-777777777777", + "automation_id": "66666666-6666-4666-8666-666666666666", + "trigger_event": "email.replied", + "status": "success", + "node_results": [ + { "node_id": "a1", "type": "action", "action": "slack.notify", "status": "success" } + ], + "started_at": "2026-06-10T15:00:00Z", + "finished_at": "2026-06-10T15:00:01Z" + } + ] +} +``` + +## Search meetings + +`GET /meetings` + +The Meetings page list: booked calls from connected scheduling providers (Calendly, Cal.com) plus manually logged meetings, filtered by timeframe, status, and text, with offset pagination. + +Auth: **Scope** `READ_CONTACTS` · **Org permission** `view_contacts`. + +| Parameter | In | Type | Description | +|-----------|-----|------|-------------| +| `timeframe` | query | string | One of `upcoming`, `past`, or empty for all. Anything else returns `400`. | +| `status` | query | string | Exact status filter (`booked`, `rescheduled`, `canceled`, `completed`, `no_show`), or empty for any. | +| `q` | query | string | Matches invitee name/email or event name. | +| `limit` | query | int | Page size (clamped 1..200 server-side). | +| `cursor` | query | string | Opaque cursor from a previous page's `pagination.next_cursor`. Omit for the first page. | + +### Response + +A `data` array plus the standard `pagination` envelope with an opaque `next_cursor` and an exact `total` (offset-paginated under the hood, identical shape to every other list). + +```json +{ + "data": [ + { + "id": "55555555-5555-4555-8555-555555555555", + "source": "calendly", + "status": "booked", + "invitee_email": "lead@acme.com", + "invitee_name": "Lead Person", + "event_name": "Intro call", + "scheduled_for": "2026-06-15T16:00:00Z", + "join_url": "https://meet.example/abc", + "contact_id": "aaaaaaaa-0000-4000-8000-000000000001", + "contact_name": "Lead Person", + "created_at": "2026-06-11T08:00:00Z", + "updated_at": "2026-06-11T08:00:00Z" + } + ], + "pagination": { + "total": 42, + "next_cursor": null, + "has_more": false + } +} +``` + +## Meetings summary + +`GET /meetings/summary` + +Returns the counts the Meetings page header and sidebar show. + +Auth: **Scope** `READ_CONTACTS` · **Org permission** `view_contacts`. + +### Response + +```json +{ + "upcoming": 7, + "today": 2, + "total": 42, + "canceled": 3 +} +``` + +## Create a meeting + +`POST /meetings` + +Logs a meeting by hand (source `manual`). It lives alongside auto-captured Calendly/Cal.com bookings on the Meetings page and the contact timeline. The contact is attributed by an explicit (verified) id or by an org-scoped email match. + +Auth: **Scope** `WRITE_CONTACTS` · **Org permission** `manage_contacts`. + +### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `title` | string | No | Meeting title. Defaults to `Call` when empty. | +| `invitee_name` | string | Conditional | A name or email is required. | +| `invitee_email` | string | Conditional | A name or email is required. Lowercased server-side. | +| `scheduled_for` | string | Yes | RFC 3339 date-time. Required and validated. | +| `duration_minutes` | int | No | When > 0, sets the end time. | +| `location` | string | No | Free-text location. | +| `join_url` | string | No | Video/join link. | +| `contact_id` | string | No | Explicit contact id to link (verified against the org). | + +```json +{ + "title": "Intro call", + "invitee_name": "Lead Person", + "invitee_email": "lead@acme.com", + "scheduled_for": "2026-06-15T16:00:00Z", + "duration_minutes": 30, + "join_url": "https://meet.example/abc" +} +``` + +### Response + +`201 Created` with the created booking under a `meeting` key. + +```json +{ + "meeting": { + "id": "55555555-5555-4555-8555-555555555555", + "source": "manual", + "status": "booked", + "invitee_email": "lead@acme.com", + "invitee_name": "Lead Person", + "event_name": "Intro call", + "scheduled_for": "2026-06-15T16:00:00Z", + "end_time": "2026-06-15T16:30:00Z", + "contact_id": "aaaaaaaa-0000-4000-8000-000000000001", + "created_at": "2026-06-11T14:00:00Z", + "updated_at": "2026-06-11T14:00:00Z" + } +} +``` + +## Delete a meeting + +`DELETE /meetings/:id` + +Removes a meeting booking (used for manually created ones). + +Auth: **Scope** `WRITE_CONTACTS` · **Org permission** `manage_contacts`. + +| Parameter | In | Type | Description | +|-----------|-----|------|-------------| +| `id` | path | uuid | Meeting booking id. | + +### Response + +```json +{ "deleted": true } +``` + +## Get the lead-sync Google connection + +`GET /lead-sync/google/connection` + +Reports whether the org has a connected Google account usable for lead sync (the hidden `google_sheets` OAuth connection). + +Auth: **Scope** `WRITE_CONTACTS` · **Org permission** `manage_contacts`. + +### Response + +```json +{ + "connected": true, + "connection": { + "id": "88888888-8888-4888-8888-888888888888", + "external_account_name": "ops@acme.com", + "status": "connected" + } +} +``` + +When no Google account is connected, `connected` is `false` and `connection` is `null`. + +## Get spreadsheet metadata + +`POST /lead-sync/google/spreadsheet` + +Returns a sheet's title and tabs so the UI can render a tab picker before mapping columns. + +Auth: **Scope** `WRITE_CONTACTS` · **Org permission** `manage_contacts`. + +### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `connection_id` | string | Yes | The `google_sheets` connection id. | +| `sheet_id` | string | Yes | The Google spreadsheet id. | + +```json +{ + "connection_id": "88888888-8888-4888-8888-888888888888", + "sheet_id": "1AbCdEfGhIjKlMnOpQrStUvWxYz" +} +``` + +### Response + +```json +{ + "sheet_id": "1AbCdEfGhIjKlMnOpQrStUvWxYz", + "title": "Q2 Leads", + "tabs": [ + { "title": "Inbound", "index": 0 }, + { "title": "Outbound", "index": 1 } + ] +} +``` + +## Preview a lead sync + +`POST /lead-sync/google/preview` + +Returns an import-preview-shaped payload (columns, sample rows, total rows, header detection, suggested mapping) so the frontend reuses its contact-import column mapper verbatim. + +Auth: **Scope** `WRITE_CONTACTS` · **Org permission** `manage_contacts`. + +### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `connection_id` | string | Yes | The `google_sheets` connection id. | +| `sheet_id` | string | Yes | The Google spreadsheet id. | +| `tab_title` | string | No | The tab to read. Defaults to the first tab. | + +```json +{ + "connection_id": "88888888-8888-4888-8888-888888888888", + "sheet_id": "1AbCdEfGhIjKlMnOpQrStUvWxYz", + "tab_title": "Inbound" +} +``` + +### Response + +```json +{ + "filename": "Q2 Leads / Inbound", + "format": "google_sheets", + "total_rows": 124, + "columns": ["Email", "First name", "Company"], + "has_header": true, + "sample_rows": [ + ["jane@acme.com", "Jane", "Acme"] + ], + "suggested_mapping": [ + { "index": 0, "target": "email" }, + { "index": 1, "target": "first_name" }, + { "index": 2, "target": "company" } + ] +} +``` + +## List lead-sync sources + +`GET /lead-sync/sources` + +Lists this org's saved sync sources, optionally filtered to a campaign. + +Auth: **Scope** `WRITE_CONTACTS` · **Org permission** `manage_contacts`. + +| Parameter | In | Type | Description | +|-----------|-----|------|-------------| +| `campaign_id` | query | uuid | Optional. Restricts to sources targeting that campaign. Invalid ids return `400`. | + +### Response + +A `data` array (no pagination envelope on this list). + +```json +{ + "data": [ + { + "id": "99999999-9999-4999-8999-999999999999", + "provider": "google_sheets", + "connection_id": "88888888-8888-4888-8888-888888888888", + "sheet_id": "1AbCdEfGhIjKlMnOpQrStUvWxYz", + "sheet_title": "Q2 Leads", + "tab_title": "Inbound", + "has_header": true, + "column_mapping": [ + { "index": 0, "target": "email" }, + { "index": 1, "target": "first_name" } + ], + "dedup": "update", + "category_ids": [], + "subscribed_default": true, + "status": "idle", + "last_synced_at": "2026-06-10T12:00:00Z", + "created_at": "2026-06-01T09:00:00Z", + "updated_at": "2026-06-10T12:00:00Z" + } + ] +} +``` + +## Create a lead-sync source + +`POST /lead-sync/sources` + +Saves a new on-demand sync source binding a Google Sheet tab to Warmbly's contact importer. + +Auth: **Scope** `WRITE_CONTACTS` · **Org permission** `manage_contacts`. + +### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `connection_id` | uuid | Yes | The org's `google_sheets` OAuth connection. | +| `sheet_id` | string | Yes | The Google spreadsheet id. | +| `sheet_title` | string | No | Spreadsheet title (for display). | +| `tab_title` | string | No | Tab to read. | +| `has_header` | boolean | No | Whether the first row is a header. | +| `column_mapping` | array | Yes | Column-to-target mappings (same shape as contact import). | +| `dedup` | string | No | Collision strategy: `skip`, `update`, or `create_duplicate`. | +| `target_campaign_id` | uuid | No | Enrol new/updated leads into this campaign on each sync. | +| `category_ids` | string[] | No | Categories to assign to synced leads. | +| `subscribed_default` | boolean | No | Default subscription state for new contacts. | +| `label` | string | No | Friendly name. | + +```json +{ + "connection_id": "88888888-8888-4888-8888-888888888888", + "sheet_id": "1AbCdEfGhIjKlMnOpQrStUvWxYz", + "sheet_title": "Q2 Leads", + "tab_title": "Inbound", + "has_header": true, + "column_mapping": [ + { "index": 0, "target": "email" }, + { "index": 1, "target": "first_name" } + ], + "dedup": "update", + "target_campaign_id": null, + "category_ids": [], + "subscribed_default": true, + "label": "Q2 inbound leads" +} +``` + +### Response + +`201 Created` with the created `LeadSyncSource` (bare object, same shape as a list item). + +```json +{ + "id": "99999999-9999-4999-8999-999999999999", + "provider": "google_sheets", + "connection_id": "88888888-8888-4888-8888-888888888888", + "sheet_id": "1AbCdEfGhIjKlMnOpQrStUvWxYz", + "sheet_title": "Q2 Leads", + "tab_title": "Inbound", + "has_header": true, + "column_mapping": [ + { "index": 0, "target": "email" }, + { "index": 1, "target": "first_name" } + ], + "dedup": "update", + "category_ids": [], + "subscribed_default": true, + "status": "idle", + "created_at": "2026-06-11T15:00:00Z", + "updated_at": "2026-06-11T15:00:00Z" +} +``` + +## Get a lead-sync source + +`GET /lead-sync/sources/:id` + +Returns one saved source. + +Auth: **Scope** `WRITE_CONTACTS` · **Org permission** `manage_contacts`. + +| Parameter | In | Type | Description | +|-----------|-----|------|-------------| +| `id` | path | uuid | Source id. | + +### Response + +A bare `LeadSyncSource` object (same shape as a list item). + +## Update a lead-sync source + +`PATCH /lead-sync/sources/:id` + +Edits a saved source. All fields are optional; omitting a field leaves the stored value untouched. + +Auth: **Scope** `WRITE_CONTACTS` · **Org permission** `manage_contacts`. + +| Parameter | In | Type | Description | +|-----------|-----|------|-------------| +| `id` | path | uuid | Source id. | + +### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `sheet_id` | string | No | New spreadsheet id. | +| `sheet_title` | string | No | New title. | +| `tab_title` | string | No | New tab. | +| `has_header` | boolean | No | Header toggle. | +| `column_mapping` | array | No | Replacement mappings. | +| `dedup` | string | No | Collision strategy. | +| `target_campaign_id` | uuid | No | New target campaign. | +| `clear_campaign` | boolean | No | When `true`, unsets the target campaign. | +| `category_ids` | string[] | No | Replacement category set. | +| `subscribed_default` | boolean | No | Default subscription state. | +| `label` | string | No | New label. | + +```json +{ + "label": "Q2 inbound (paused)", + "dedup": "skip", + "clear_campaign": true +} +``` + +### Response + +The updated `LeadSyncSource` (bare object). + +## Delete a lead-sync source + +`DELETE /lead-sync/sources/:id` + +Removes a saved source. + +Auth: **Scope** `WRITE_CONTACTS` · **Org permission** `manage_contacts`. + +| Parameter | In | Type | Description | +|-----------|-----|------|-------------| +| `id` | path | uuid | Source id. | + +### Response + +`204 No Content`. + +## Run a lead-sync source now + +`POST /lead-sync/sources/:id/sync` + +Runs the source on demand: reads the sheet and upserts contacts through the contact importer. New rows create contacts; rows matching an existing contact by email are updated. Naturally idempotent (email upsert), so retries are safe without an `Idempotency-Key`. + +Auth: **Scope** `WRITE_CONTACTS` · **Org permission** `manage_contacts`. + +| Parameter | In | Type | Description | +|-----------|-----|------|-------------| +| `id` | path | uuid | Source id. | + +### Response + +The source id plus the underlying contact-import counts. + +```json +{ + "source_id": "99999999-9999-4999-8999-999999999999", + "result": { + "total": 124, + "imported": 18, + "updated": 100, + "skipped": 6, + "failed": 0, + "started_at": "2026-06-11T16:00:00Z", + "ended_at": "2026-06-11T16:00:04Z" + } +} +``` diff --git a/docs/content/docs/api/reference/mailboxes.mdx b/docs/content/docs/api/reference/mailboxes.mdx new file mode 100644 index 00000000..9e7dd1ba --- /dev/null +++ b/docs/content/docs/api/reference/mailboxes.mdx @@ -0,0 +1,558 @@ +--- +title: Mailboxes +description: Connect, configure, warm up, verify, and send from sender mailboxes (email accounts). +icon: Mail +--- + +Mailboxes are the sender accounts Warmbly sends campaign and warmup mail from. These endpoints live under `/emails` and let you list and inspect connected mailboxes, update their sending and warmup settings, point a custom tracking domain at a mailbox, drive the warmup lifecycle, check authentication and ban status, verify addresses before sending, and send a one-off message from a specific mailbox. + +Most read routes require the **Read emails** scope and write routes require the **Write emails** scope. The mailbox connection (onboarding) routes are session only because they write user-encrypted refresh tokens through the SPA popup flow, and the send route requires the **Send campaigns** scope because it transmits real mail. When an API key is scoped to specific mailboxes, every `/:id` route is additionally gated to keys allowed to act on that mailbox. + +## List mailboxes + +`GET /emails` + +Returns the organization's connected mailboxes, newest first, with cursor pagination. + +Auth: **Scope** `READ_EMAILS` · **Org permission** `view_campaigns` + +| Parameter | In | Type | Description | +|-----------|----|------|-------------| +| `q` | query | string | Optional free-text search over mailbox address and name. | +| `tag` | query | string (UUID) | Optional tag id to filter by. Must be a valid UUID. | +| `cursor` | query | string (UUID) | Opaque cursor from a previous `pagination.next_cursor`. | +| `limit` | query | integer | Page size. Defaults to `50`. Invalid limits return `400`. | + +### Response + +A `data` array of mailbox objects plus a `pagination` envelope. + +```json +{ + "data": [ + { + "id": "0c0f1a2b-3c4d-5e6f-7a8b-9c0d1e2f3a4b", + "user_id": "a1b2c3d4-...", + "organization_id": "f9e8d7c6-...", + "worker_id": "7b6a5c4d-...", + "email": "sales@acme.com", + "name": "Acme Sales", + "signature_plain": "", + "signature_html": "", + "signature_sync": false, + "signature_code": false, + "provider": "gmail", + "status": "active", + "last_synced_at": "2026-06-11T09:14:00Z", + "last_id": 184213, + "campaign_limit": 50, + "min_wait_time": 600, + "reply_to": "", + "tracking_domain": "t.acme.com", + "tracking_domain_verified": true, + "tracking_domain_verified_at": "2026-06-01T12:00:00Z", + "warmup": "2026-05-20T00:00:00Z", + "warmup_paused_at": null, + "warmup_base": 10, + "warmup_max": 40, + "warmup_increase": 1, + "warmup_reply_rate": 30, + "warmup_tag": "", + "warmup_pool_type": "premium", + "warmup_start_time": "09:00", + "warmup_end_time": "17:00", + "warmup_days": 5, + "timezone": "America/New_York", + "tags": ["outbound"], + "created_at": "2026-05-19T18:00:00Z", + "updated_at": "2026-06-11T09:14:00Z" + } + ], + "pagination": { + "total": 12, + "next_cursor": "c1_b3BhcXVlLWN1cnNvcg", + "has_more": true + } +} +``` + +`provider` is one of `gmail`, `outlook`, or `smtp_imap`. `status` is one of `active`, `inactive`, or `revoked`. `warmup` is the warmup anchor timestamp (null when warmup has never been enabled); a non-null `warmup_paused_at` means warmup is enabled but paused. `total` and `next_cursor` may be null when not applicable. + +## Get a mailbox + +`GET /emails/:id` + +Returns a single mailbox by id. + +Auth: **Scope** `READ_EMAILS` · **Org permission** `view_campaigns` + +| Parameter | In | Type | Description | +|-----------|----|------|-------------| +| `id` | path | string (UUID) | The mailbox (email account) id. | + +### Response + +A bare mailbox object, same shape as one element of the list `data` array. + +```json +{ + "id": "0c0f1a2b-3c4d-5e6f-7a8b-9c0d1e2f3a4b", + "email": "sales@acme.com", + "name": "Acme Sales", + "provider": "gmail", + "status": "active", + "campaign_limit": 50, + "min_wait_time": 600, + "warmup": "2026-05-20T00:00:00Z", + "warmup_paused_at": null, + "warmup_pool_type": "premium", + "tracking_domain": "t.acme.com", + "tracking_domain_verified": true, + "tags": ["outbound"], + "created_at": "2026-05-19T18:00:00Z", + "updated_at": "2026-06-11T09:14:00Z" +} +``` + +## Update a mailbox + +`PATCH /emails/:id` + +Updates mailbox settings: display name, signature, status, sending caps, reply-to, warmup configuration, and tags. All fields are optional; only present fields are applied. + +Auth: **Scope** `WRITE_EMAILS` · **Org permission** `manage_emails` + +| Parameter | In | Type | Description | +|-----------|----|------|-------------| +| `id` | path | string (UUID) | The mailbox id. | + +### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `name` | string | no | Display name on outgoing mail. | +| `signature_plain` | string | no | Plain-text signature. | +| `signature_html` | string | no | HTML signature. | +| `signature_sync` | boolean | no | Keep the signature synced from the provider. | +| `signature_code` | boolean | no | Treat the HTML signature as raw code. | +| `status` | string | no | `active`, `inactive`, or `revoked`. | +| `campaign_limit` | integer | no | Daily cold-campaign cap for this mailbox (validated up to `100`). | +| `min_wait_time` | integer | no | Minimum seconds between sends. | +| `reply_to` | string | no | Reply-to address. | +| `warmup` | boolean | no | Enable or disable warmup. | +| `warmup_base` | integer | no | Warmup starting volume per day. | +| `warmup_max` | integer | no | Warmup daily ceiling. | +| `warmup_increase` | integer | no | Per-day warmup ramp increment. | +| `warmup_reply_rate` | integer | no | Percentage of warmup threads to reply to. | +| `warmup_tag` | string | no | Tag applied to warmup threads. | +| `warmup_start_time` | string | no | Daily warmup window start, `HH:MM`. | +| `warmup_end_time` | string | no | Daily warmup window end, `HH:MM`. | +| `warmup_days` | integer | no | Number of active warmup days per week. | +| `tags` | string[] | no | Tag ids assigned to the mailbox. | + +```json +{ + "name": "Acme Sales (US)", + "status": "active", + "campaign_limit": 40, + "min_wait_time": 720, + "reply_to": "replies@acme.com", + "warmup_max": 35, + "tags": ["outbound", "us"] +} +``` + +### Response + +The updated mailbox object (same shape as Get a mailbox). + +## Update the tracking domain + +`PATCH /emails/:id/track` + +Sets or clears the custom open/click tracking domain for a mailbox. The backend resolves the CNAME on save and marks it verified once the customer subdomain points at the shared tracking host (`t.warmbly.com`). DNS can lag a freshly added record, so a miss is reported as unverified (pending), not an error. Send an empty domain to clear the custom domain and fall back to the shared default. + +Auth: **Scope** `WRITE_EMAILS` · **Org permission** `manage_emails` + +| Parameter | In | Type | Description | +|-----------|----|------|-------------| +| `id` | path | string (UUID) | The mailbox id. | +| `domain` | query | string | The custom tracking subdomain (for example `t.acme.com`). Empty clears it. | + +### Response + +```json +{ + "tracking_domain": "t.acme.com", + "tracking_domain_verified": true, + "tracking_domain_verified_at": "2026-06-11T09:20:00Z" +} +``` + +`tracking_domain_verified_at` is null until the CNAME resolves to the tracking host. + +## Start warmup + +`POST /emails/:id/warmup/start` + +Enables warmup for a mailbox. When resuming from a paused state it preserves ramp progress and seeds the warmup task chain immediately rather than waiting for the next reconciler pass. + +Auth: **Scope** `WRITE_EMAILS` · **Org permission** `manage_emails` + +| Parameter | In | Type | Description | +|-----------|----|------|-------------| +| `id` | path | string (UUID) | The mailbox id. | + +### Response + +The updated mailbox object, reflecting the new warmup state. + +```json +{ + "id": "0c0f1a2b-3c4d-5e6f-7a8b-9c0d1e2f3a4b", + "email": "sales@acme.com", + "warmup": "2026-06-11T09:25:00Z", + "warmup_paused_at": null, + "warmup_pool_type": "premium" +} +``` + +## Pause warmup + +`POST /emails/:id/warmup/pause` + +Pauses warmup without losing ramp progress. A later start continues from the same daily volume. + +Auth: **Scope** `WRITE_EMAILS` · **Org permission** `manage_emails` + +| Parameter | In | Type | Description | +|-----------|----|------|-------------| +| `id` | path | string (UUID) | The mailbox id. | + +### Response + +The updated mailbox object. A paused mailbox has a non-null `warmup_paused_at`. + +```json +{ + "id": "0c0f1a2b-3c4d-5e6f-7a8b-9c0d1e2f3a4b", + "warmup": "2026-06-11T09:25:00Z", + "warmup_paused_at": "2026-06-11T11:00:00Z" +} +``` + +## Resume warmup + +`POST /emails/:id/warmup/resume` + +Resumes a paused warmup, shifting the ramp anchor forward so progress continues where it left off, and re-seeds the warmup task chain immediately. + +Auth: **Scope** `WRITE_EMAILS` · **Org permission** `manage_emails` + +| Parameter | In | Type | Description | +|-----------|----|------|-------------| +| `id` | path | string (UUID) | The mailbox id. | + +### Response + +The updated mailbox object, with `warmup_paused_at` cleared. + +## Stop warmup + +`POST /emails/:id/warmup/stop` + +Disables warmup entirely and clears ramp progress. Distinct from pause: a later start begins a fresh ramp. + +Auth: **Scope** `WRITE_EMAILS` · **Org permission** `manage_emails` + +| Parameter | In | Type | Description | +|-----------|----|------|-------------| +| `id` | path | string (UUID) | The mailbox id. | + +### Response + +The updated mailbox object, with warmup disabled. + +```json +{ + "id": "0c0f1a2b-3c4d-5e6f-7a8b-9c0d1e2f3a4b", + "warmup": null, + "warmup_paused_at": null +} +``` + +## Check domain authentication + +`GET /emails/:id/auth-check` + +Validates SPF, DKIM, and DMARC for the mailbox's sending domain on demand. Authentication alignment is a hard bulk-sender requirement and a common silent deliverability failure, so this confirms the domain is configured correctly without leaving the dashboard. + +Auth: **Scope** `READ_EMAILS` · **Org permission** `view_campaigns` + +| Parameter | In | Type | Description | +|-----------|----|------|-------------| +| `id` | path | string (UUID) | The mailbox id. The domain is derived from the mailbox address. | + +### Response + +```json +{ + "domain": "acme.com", + "spf_found": true, + "spf_record": "v=spf1 include:_spf.google.com ~all", + "dkim_found": true, + "dkim_selectors": ["google"], + "dmarc_found": true, + "dmarc_policy": "quarantine", + "all_aligned": true, + "summary": "SPF, DKIM, and DMARC are all present and aligned." +} +``` + +`spf_record`, `dkim_selectors`, and `dmarc_policy` are omitted when the corresponding record is not found. + +## Verify an email address + +`POST /emails/verify` + +Verifies a single email address on demand (syntax, then MX, then an SMTP RCPT probe, then catch-all detection). This is pre-send verification: confirm an address is deliverable before a worker ever sends to it, instead of learning from a hard bounce. The probe runs from the backend (a non-sending IP), never from worker IPs. + +Auth: **Scope** `READ_EMAILS` · **Org permission** `view_campaigns` + +The address may be supplied in the JSON body or as the `email` query param; the body takes precedence. + +### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `email` | string | no | The address to verify. Required if the `email` query param is not set. | + +```json +{ + "email": "jane.doe@example.com" +} +``` + +### Response + +```json +{ + "email": "jane.doe@example.com", + "status": "valid", + "reason": "accepted by recipient mail server", + "is_catch_all": false, + "has_mx": true, + "checked_at": "2026-06-11T09:30:00Z" +} +``` + +`status` is one of `valid`, `risky`, `invalid`, or `unknown`. A missing or empty address returns a `400` error envelope. + +## Get warmup ban status + +`GET /emails/:id/warmup/ban-status` + +Returns whether a mailbox is blocked from the shared warmup pool, why, and whether the owner can appeal. Powers the dashboard ban banner. + +Auth: **Scope** `READ_EMAILS` · **Org permission** `view_campaigns` + +| Parameter | In | Type | Description | +|-----------|----|------|-------------| +| `id` | path | string (UUID) | The mailbox id. | + +### Response + +```json +{ + "email_account_id": "0c0f1a2b-3c4d-5e6f-7a8b-9c0d1e2f3a4b", + "blocked": true, + "health_state": "quarantined", + "reason": "spam-folder placement above threshold", + "blocked_at": "2026-06-09T14:00:00Z", + "blocked_until": "2026-06-16T14:00:00Z", + "can_appeal": true, + "pending_appeal": false +} +``` + +`reason`, `blocked_at`, and `blocked_until` are omitted when the mailbox is not blocked. `health_state` reflects the mailbox's rolling warmup health (for example `healthy`, `watch`, `throttled`, `quarantined`, or `blocked`). + +## Submit a warmup appeal + +`POST /emails/:id/warmup/appeal` + +Lets the mailbox owner appeal a warmup ban with a reason. + +Auth: **Scope** `WRITE_EMAILS` · **Org permission** `manage_emails` + +| Parameter | In | Type | Description | +|-----------|----|------|-------------| +| `id` | path | string (UUID) | The mailbox id. | + +### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `reason` | string | no | The owner's explanation for the appeal. | + +```json +{ + "reason": "Authentication is fixed and the high-bounce list has been removed." +} +``` + +### Response + +```json +{ + "appeal_id": "5e6f7a8b-9c0d-1e2f-3a4b-5c6d7e8f9a0b" +} +``` + +## Delete a mailbox + +`DELETE /emails/:id` + +Disconnects and deletes a mailbox. It is removed from all warmup pools and an account-disconnected event fans out. + +Auth: **Scope** `WRITE_EMAILS` · **Org permission** `manage_emails` + +| Parameter | In | Type | Description | +|-----------|----|------|-------------| +| `id` | path | string (UUID) | The mailbox id. | + +### Response + +`204 No Content` with an empty body. + +## Send from a mailbox + +`POST /emails/:id/send` + +Sends a one-off email from a specific mailbox. The send is scheduled and dispatched through the mailbox's assigned worker. Choose how it is scheduled with `send_mode`. + +Auth: **Scope** `SEND_CAMPAIGNS` · **Org permission** `send_campaigns`. Requires an active organization. + +| Parameter | In | Type | Description | +|-----------|----|------|-------------| +| `id` | path | string (UUID) | The sending mailbox id. | + +### Request body + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `to` | string[] | yes | Recipient addresses. | +| `cc` | string[] | no | CC addresses. | +| `bcc` | string[] | no | BCC addresses. | +| `subject` | string | yes | Email subject. | +| `body_html` | string | no | HTML body. | +| `body_plain` | string | no | Plain-text body. | +| `in_reply_to` | string[] | no | Message ids this email replies to. | +| `thread_id` | string | no | Thread id to attach the message to. | +| `send_mode` | string | no | `instant` (default), `smart` (next per-mailbox scheduler gap), or `scheduled` (use `scheduled_at`). | +| `scheduled_at` | string (RFC 3339) | no | Required when `send_mode` is `scheduled`. Must be in the future. | + +```json +{ + "to": ["jane.doe@example.com"], + "subject": "Quick question about your rollout", + "body_html": "

Hi Jane, ...

", + "body_plain": "Hi Jane, ...", + "send_mode": "smart" +} +``` + +### Response + +```json +{ + "task_id": "9a0b1c2d-3e4f-5a6b-7c8d-9e0f1a2b3c4d", + "scheduled_at": "2026-06-11T09:45:00Z", + "send_mode": "smart" +} +``` + +`task_id` identifies the queued send task. `scheduled_at` is the resolved dispatch time (immediate for `instant`, the next gap for `smart`, or the requested time for `scheduled`). + +## Connect a mailbox (onboarding) + +The three onboarding routes connect a new mailbox. They are **session only (not available to API keys)** because they write user-encrypted provider refresh tokens through the SPA popup flow. + +### Start OAuth + +`POST /emails/onboarding/oauth/start` + +Begins an OAuth round trip for a Gmail or Outlook mailbox and returns the provider authorization URL plus an opaque `state` to round-trip back. + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `provider` | string | yes | `gmail` or `outlook`. | + +```json +{ + "provider": "gmail" +} +``` + +Response: + +```json +{ + "url": "https://accounts.google.com/o/oauth2/auth?...", + "state": "n0nc3-opaque-state" +} +``` + +### Finish OAuth + +`POST /emails/onboarding/oauth/finish` + +Completes the OAuth round trip with the authorization code and state from the provider, then creates the mailbox. + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `code` | string | yes | Authorization code from the provider. | +| `state` | string | yes | The `state` returned from start. | + +```json +{ + "code": "4/0Ax...", + "state": "n0nc3-opaque-state" +} +``` + +Response: `201 Created` with the new mailbox object (same shape as Get a mailbox). + +### Connect SMTP/IMAP + +`POST /emails/onboarding/smtp-imap` + +Connects an SMTP/IMAP mailbox in a single call. + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `email` | string | yes | The mailbox address. | +| `name` | string | no | Display name. | +| `smtp` | object | yes | SMTP credentials: `username`, `password`, `host`, `port`. | +| `imap` | object | yes | IMAP credentials: `username`, `password`, `host`, `port`. | + +```json +{ + "email": "sales@acme.com", + "name": "Acme Sales", + "smtp": { + "username": "sales@acme.com", + "password": "app-specific-password", + "host": "smtp.acme.com", + "port": 587 + }, + "imap": { + "username": "sales@acme.com", + "password": "app-specific-password", + "host": "imap.acme.com", + "port": 993 + } +} +``` + +Response: `201 Created` with the new mailbox object. diff --git a/docs/content/docs/api/reference/meta.json b/docs/content/docs/api/reference/meta.json new file mode 100644 index 00000000..16595711 --- /dev/null +++ b/docs/content/docs/api/reference/meta.json @@ -0,0 +1,17 @@ +{ + "title": "Endpoint reference", + "icon": "Braces", + "pages": [ + "mailboxes", + "campaigns", + "contacts", + "unibox", + "crm", + "analytics", + "api-keys", + "webhooks", + "integrations", + "deliverability-ops", + "account-org" + ] +} diff --git a/docs/content/docs/api/reference/unibox.mdx b/docs/content/docs/api/reference/unibox.mdx new file mode 100644 index 00000000..45738108 --- /dev/null +++ b/docs/content/docs/api/reference/unibox.mdx @@ -0,0 +1,496 @@ +--- +title: Unified inbox +description: Read, search, and triage incoming mail across every mailbox, then reply, label, snooze, and manage scheduled sends. +icon: Inbox +--- + +The unified inbox (unibox) is the org-wide view of everything that lands in your connected mailboxes. These endpoints power the dashboard's inbox list, thread view, scope rail, conversation labels, snoozes, and scheduled-send queue. Every route is gated on the unified-inbox feature, so the calling organization needs an active trial or paid subscription; without it the endpoint returns `403`. + +The list, thread, overview, and snooze data is org-scoped, not per-user. Two members of the same organization see the same inbox, the same unread badge, and the same threads. Snoozes and conversation labels are attached to the calling user. + +## List incoming mail + +`GET /unibox` + +Returns the inbox list, collapsed to one row per thread (the newest message), with filtering and cursor pagination. Auth: **Scope** `READ_UNIBOX` · **Org permission** `access_unibox`. + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `cursor` | query | string | Opaque pagination cursor from a previous response. | +| `limit` | query | integer | Page size. Clamped to the server's min/max. | +| `from` | query | string | Filter by sender address (substring). | +| `subject` | query | string | Filter by subject (substring). | +| `unseen` | query | boolean | `true` returns only threads with unread messages. | +| `awaiting_reply` | query | boolean | `true` returns only threads where the latest message was sent by you (recipient has not replied). | +| `snoozed` | query | string | `true` returns only snoozed threads. Omit to exclude snoozed threads (default). | +| `since` | query | string | Lower bound on date, `YYYY-MM-DD`. | +| `until` | query | string | Upper bound on date, `YYYY-MM-DD`. | +| `email_id` | query | string | Restrict to a single mailbox by UUID. | +| `email_ids` | query | string | Comma-separated mailbox UUIDs. A thread matches if it landed in any of them. Invalid UUIDs are dropped. | +| `category_ids` | query | string | Comma-separated conversation-label UUIDs. A thread matches if it carries any of them. | + +The response is a `data` plus `pagination` envelope. Each row summarises the whole thread behind it (`message_count`, `has_unread`) plus the conversation's labels. + +### Response + +```json +{ + "data": [ + { + "id": "9b6f0e2a-3c4d-4f1a-8b2e-1a2b3c4d5e6f", + "email_id": "2a1b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d", + "thread_id": "thread-af83b21", + "from_addr": ["Jane Doe "], + "to_addr": ["sales@yourco.com"], + "subject": "Re: Following up on pricing", + "snippet": "Thanks for the details, this looks great...", + "internal_date": "2026-06-11T14:22:09Z", + "seen": false, + "message_count": 4, + "has_unread": true, + "labels": [ + { "id": "c0ffee00-0000-4000-8000-000000000001", "title": "Interested", "color": "#16a34a" } + ] + } + ], + "pagination": { + "next_cursor": "eyJpZCI6Ii4uLiJ9", + "has_more": true + } +} +``` + +## Get unread count + +`GET /unibox/count` + +Returns the org-wide unread message count, optionally scoped to one mailbox. Backs the inbox badge. Auth: **Scope** `READ_UNIBOX` · **Org permission** `access_unibox`. + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `email_id` | query | string | Optional mailbox UUID to count unread for a single mailbox. | + +### Response + +```json +{ + "count": 37 +} +``` + +## Get inbox overview + +`GET /unibox/overview` + +Rolls up the scope rail and top metric strip in one call: unread, today, week, snoozed, awaiting-reply, and pending-scheduled counts, plus per-mailbox, per-tag, and per-conversation-label breakdowns. Auth: **Scope** `READ_UNIBOX` · **Org permission** `access_unibox`. + +### Response + +```json +{ + "total": 1284, + "unread": 37, + "today": 12, + "week": 88, + "snoozed": 3, + "awaiting_reply": 9, + "scheduled_pending": 2, + "scheduled_pending_max": 50, + "mailboxes": [ + { + "id": "2a1b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d", + "email": "sales@yourco.com", + "name": "Sales", + "unread": 21, + "total": 640 + } + ], + "tags": [ + { + "id": "1f2e3d4c-5b6a-7980-a1b2-c3d4e5f60718", + "title": "Outbound", + "color": "#2563eb", + "unread": 14, + "total": 410 + } + ], + "categories": [ + { + "id": "c0ffee00-0000-4000-8000-000000000001", + "title": "Interested", + "color": "#16a34a", + "unread": 5, + "total": 62 + } + ], + "generated_at": "2026-06-11T14:25:00Z", + "window_today_start": "2026-06-11T00:00:00Z", + "window_week_start": "2026-06-05T00:00:00Z" +} +``` + +## Get a thread + +`GET /unibox/thread` + +Returns every message in a single conversation, oldest-first style message rows, with cursor pagination. The mailbox filter is optional: with no `email_id` the thread is read across every mailbox in the organization (the natural unified view). Auth: **Scope** `READ_UNIBOX` · **Org permission** `access_unibox`. + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `thread_id` | query | string | Required. The thread to read. Also accepted as `id`. | +| `email_id` | query | string | Optional mailbox UUID to scope the thread to one mailbox. Also accepted as `email`. | +| `cursor` | query | string | Opaque pagination cursor. | +| `limit` | query | integer | Page size. Out-of-range values return `400`. | + +The response is a `data` plus `pagination` envelope. Each item is a full message (envelope plus body). + +### Response + +```json +{ + "data": [ + { + "id": "9b6f0e2a-3c4d-4f1a-8b2e-1a2b3c4d5e6f", + "email_id": "2a1b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d", + "mailbox": 1, + "thread_id": "thread-af83b21", + "message_id": "", + "gmail_id": "18f0c2a9b7d4e5f6", + "parent_id": "", + "uid": 4821, + "mod_seq": 90210, + "flags": ["\\Seen"], + "bcc": [], + "cc": [], + "from_addr": ["Jane Doe "], + "in_reply_to": [""], + "reply_to": [], + "to_addr": ["sales@yourco.com"], + "subject": "Re: Following up on pricing", + "size": 18422, + "internal_date": "2026-06-11T14:22:09Z", + "sent_date": "2026-06-11T14:22:00Z", + "snippet": "Thanks for the details, this looks great...", + "seen": true, + "body_plain": "Thanks for the details...", + "body_html": "

Thanks for the details...

", + "updated_at": "2026-06-11T14:22:10Z", + "created_at": "2026-06-11T14:22:10Z" + } + ], + "pagination": { + "next_cursor": null, + "has_more": false + } +} +``` + +## Get thread labels + +`GET /unibox/thread/labels` + +Returns the conversation labels (your categories) attached to a thread. Auth: **Scope** `READ_UNIBOX` · **Org permission** `access_unibox`. + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `thread_id` | query | string | Required. The thread to read labels for. Also accepted as `id`. | + +The response wraps the labels in a `data` array. + +### Response + +```json +{ + "data": [ + { "id": "c0ffee00-0000-4000-8000-000000000001", "title": "Interested", "color": "#16a34a" } + ] +} +``` + +## Set thread labels + +`PUT /unibox/thread/labels` + +Replaces the full conversation-label set on a thread. The body's `category_ids` is the desired set, so the call is idempotent and retries are naturally safe. Only your own categories are attached. Auth: **Scope** `WRITE_UNIBOX` · **Org permission** `access_unibox`. + +### Request body + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `thread_id` | string | Yes | The thread to label. | +| `category_ids` | string[] | No | The full desired set of category UUIDs. An empty array clears all labels. | + +```json +{ + "thread_id": "thread-af83b21", + "category_ids": [ + "c0ffee00-0000-4000-8000-000000000001", + "c0ffee00-0000-4000-8000-000000000002" + ] +} +``` + +### Response + +Returns the resulting label set in a `data` array. + +```json +{ + "data": [ + { "id": "c0ffee00-0000-4000-8000-000000000001", "title": "Interested", "color": "#16a34a" }, + { "id": "c0ffee00-0000-4000-8000-000000000002", "title": "Demo booked", "color": "#7c3aed" } + ] +} +``` + +## Mark messages seen + +`PATCH /unibox/seen` + +Marks a batch of messages as read or unread, org-wide. Up to 500 ids per call. Auth: **Scope** `WRITE_UNIBOX` · **Org permission** `access_unibox`. + +### Request body + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `email_ids` | string[] | Yes | Message UUIDs to update (max 500). | +| `seen` | boolean | No | `true` marks as read, `false` marks as unread. | + +```json +{ + "email_ids": [ + "9b6f0e2a-3c4d-4f1a-8b2e-1a2b3c4d5e6f", + "7a8b9c0d-1e2f-3a4b-5c6d-7e8f9a0b1c2d" + ], + "seen": true +} +``` + +### Response + +Echoes the request back. + +```json +{ + "email_ids": [ + "9b6f0e2a-3c4d-4f1a-8b2e-1a2b3c4d5e6f", + "7a8b9c0d-1e2f-3a4b-5c6d-7e8f9a0b1c2d" + ], + "seen": true +} +``` + +## Reply from the inbox + +`POST /unibox/reply` + +Sends or schedules a reply from one of your mailboxes. The send is routed through the per-mailbox scheduler according to `send_mode`. Requires an active organization. Auth: **Scope** `WRITE_UNIBOX` · **Org permission** `access_unibox`. + +### Request body + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `email_account_id` | string | Yes | UUID of the sending mailbox. | +| `to` | string[] | Yes | Recipient addresses (at least one). | +| `cc` | string[] | No | CC addresses. | +| `bcc` | string[] | No | BCC addresses. | +| `subject` | string | Yes | Subject line. | +| `body_html` | string | No | HTML body. | +| `body_plain` | string | No | Plain-text body. | +| `in_reply_to` | string[] | No | Message-ID(s) this reply threads under. | +| `thread_id` | string | No | Thread to thread the reply into. | +| `send_mode` | string | No | `instant` (default), `smart` (next mailbox gap), or `scheduled` (use `scheduled_at`). | +| `scheduled_at` | string | No | RFC 3339 timestamp. Required when `send_mode` is `scheduled`; must be in the future. | + +```json +{ + "email_account_id": "2a1b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d", + "to": ["jane@acme.com"], + "subject": "Re: Following up on pricing", + "body_html": "

Happy to hop on a call this week.

", + "in_reply_to": [""], + "thread_id": "thread-af83b21", + "send_mode": "instant" +} +``` + +### Response + +```json +{ + "task_id": "5c6d7e8f-9a0b-4c1d-8e2f-3a4b5c6d7e8f", + "scheduled_at": "2026-06-11T14:30:00Z", + "send_mode": "instant" +} +``` + +## List active snoozes + +`GET /unibox/snoozes` + +Returns your active thread snoozes. Auth: **Scope** `READ_UNIBOX` · **Org permission** `access_unibox`. + +The response wraps the snoozes in a `data` array. + +### Response + +```json +{ + "data": [ + { + "id": "3a4b5c6d-7e8f-4a0b-9c1d-2e3f4a5b6c7d", + "user_id": "1e2f3a4b-5c6d-7e8f-9a0b-1c2d3e4f5a6b", + "thread_id": "thread-af83b21", + "snoozed_until": "2026-06-12T09:00:00Z", + "created_at": "2026-06-11T14:00:00Z", + "updated_at": "2026-06-11T14:00:00Z" + } + ] +} +``` + +## Snooze a thread + +`POST /unibox/snooze` + +Hides a thread from your inbox until `snoozed_until` passes. Upsert semantics: a second call on the same thread updates the time in place. Auth: **Scope** `WRITE_UNIBOX` · **Org permission** `access_unibox`. + +### Request body + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `thread_id` | string | Yes | The thread to snooze. | +| `snoozed_until` | string | Yes | RFC 3339 timestamp to un-hide the thread. | + +```json +{ + "thread_id": "thread-af83b21", + "snoozed_until": "2026-06-12T09:00:00Z" +} +``` + +### Response + +```json +{ + "id": "3a4b5c6d-7e8f-4a0b-9c1d-2e3f4a5b6c7d", + "user_id": "1e2f3a4b-5c6d-7e8f-9a0b-1c2d3e4f5a6b", + "thread_id": "thread-af83b21", + "snoozed_until": "2026-06-12T09:00:00Z", + "created_at": "2026-06-11T14:00:00Z", + "updated_at": "2026-06-11T14:00:00Z" +} +``` + +## Unsnooze a thread + +`DELETE /unibox/snooze` + +Un-snoozes a thread immediately. Idempotent: deleting a snooze that does not exist still succeeds with `204`. Auth: **Scope** `WRITE_UNIBOX` · **Org permission** `access_unibox`. + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `thread_id` | query | string | Required. The thread to un-snooze. | + +### Response + +`204 No Content` with an empty body. + +## List scheduled sends + +`GET /unibox/scheduled` + +Returns the outbound emails you have queued but not yet sent. Pass `thread_id` to scope to a single conversation (used to render queued replies inline); the response shape is identical either way. Auth: **Scope** `READ_UNIBOX` · **Org permission** `access_unibox`. + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `thread_id` | query | string | Optional. Restrict to scheduled sends queued into one thread. | + +The response wraps the items in a `data` array. Each item is a preview of the queued message. + +### Response + +```json +{ + "data": [ + { + "task_id": "5c6d7e8f-9a0b-4c1d-8e2f-3a4b5c6d7e8f", + "scheduled_at": "2026-06-12T09:15:00Z", + "created_at": "2026-06-11T14:30:00Z", + "account_id": "2a1b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d", + "account_email": "sales@yourco.com", + "account_name": "Sales", + "to": ["jane@acme.com"], + "subject": "Re: Following up on pricing", + "snippet": "Happy to hop on a call this week.", + "thread_id": "thread-af83b21" + } + ] +} +``` + +## Cancel a scheduled send + +`DELETE /unibox/scheduled/:task_id` + +Cancels a pending scheduled send before it fires. The queued task is marked cancelled and short-circuits to a no-op when its run time arrives. Auth: **Scope** `WRITE_UNIBOX` · **Org permission** `access_unibox`. + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `task_id` | path | string | UUID of the scheduled task to cancel. | + +### Response + +`204 No Content` with an empty body. + +## Get a message by id + +`GET /unibox/:id` + +Returns a single message by its UUID, including the full envelope and body. Auth: **Scope** `READ_UNIBOX` · **Org permission** `access_unibox`. + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `id` | path | string | UUID of the message. | + +### Response + +```json +{ + "id": "9b6f0e2a-3c4d-4f1a-8b2e-1a2b3c4d5e6f", + "gmail_id": "18f0c2a9b7d4e5f6", + "uid": 4821, + "parent_id": "", + "thread_id": "thread-af83b21", + "flags": ["\\Seen"], + "bcc": [], + "cc": [], + "date": "2026-06-11T14:22:00Z", + "from": ["Jane Doe "], + "in_reply_to": [""], + "message_id": "", + "ReplyTo": [], + "to": ["sales@yourco.com"], + "subject": "Re: Following up on pricing", + "size": 18422, + "internal_date": "2026-06-11T14:22:09Z", + "mod_seq": 90210, + "body_plain": "Thanks for the details...", + "body_html": "

Thanks for the details...

" +} +``` + +## Errors + +All error responses follow the standard envelope. See [error codes](/api/error-codes/) for the full list. + +```json +{ + "error": "forbidden", + "message": "Unibox requires an active trial or paid subscription", + "code": "FORBIDDEN", + "request_id": "req_8f3a1c2b9d" +} +``` + +Common cases: `403` when the organization lacks unified-inbox access, `400` for a missing `thread_id`, an invalid cursor or limit, or no organization selected, and `400` when marking more than 500 messages seen in one call. diff --git a/docs/content/docs/api/reference/webhooks.mdx b/docs/content/docs/api/reference/webhooks.mdx new file mode 100644 index 00000000..2b29645f --- /dev/null +++ b/docs/content/docs/api/reference/webhooks.mdx @@ -0,0 +1,367 @@ +--- +title: Webhooks +description: Subscribe to Warmbly platform events and receive HMAC-signed delivery callbacks at your own HTTPS endpoints. +icon: Webhook +--- + +Webhooks let your systems react to Warmbly activity in real time. You register one or more HTTPS endpoints, optionally filter them to specific event types, and Warmbly POSTs a signed JSON payload to each matching endpoint whenever a subscribed event fires. Every delivery is signed with HMAC-SHA256 and carries a unique event id so you can verify authenticity and dedupe replays. Failed deliveries are retried with exponential backoff and their history is queryable for debugging. + +All webhook management endpoints are org-scoped and share one auth gate: **Scope** `WEBHOOKS` · **Org permission** `manage_settings`. The signing secret is server-generated and only returned at create and rotate time, so capture it then. + +## List webhook endpoints + +`GET /webhooks` + +Returns every endpoint configured for the caller's organization, along with the full event vocabulary for building a picker. Secrets are never included in this response. + +Auth: **Scope** `WEBHOOKS` · **Org permission** `manage_settings`. + +### Response + +Returns an object with the configured `endpoints` array and the canonical `event_types` list (every event Warmbly can emit). This is not a `data` + `pagination` envelope. + +```json +{ + "endpoints": [ + { + "id": "a3f1c2e4-5b6d-7e8f-9a0b-1c2d3e4f5a6b", + "organization_id": "11111111-2222-3333-4444-555555555555", + "url": "https://hooks.example.com/warmbly", + "description": "Production event sink", + "event_types": ["campaign.reply_received", "meeting.booked"], + "enabled": true, + "last_success_at": "2026-06-11T18:02:14Z", + "last_failure_at": null, + "last_failure_reason": null, + "consecutive_failures": 0, + "created_at": "2026-05-01T09:00:00Z", + "updated_at": "2026-06-11T18:02:14Z" + } + ], + "event_types": [ + "email_account.connected", + "campaign.email_sent", + "meeting.booked" + ] +} +``` + +## Create a webhook endpoint + +`POST /webhooks` + +Creates a new subscription. The response is the only time the signing `secret` is returned, so store it immediately. The URL must be HTTPS and resolve to a publicly routable host (loopback, private, and link-local targets are rejected unless the server runs with unsafe webhook URLs enabled for local or self-hosted development). + +Auth: **Scope** `WEBHOOKS` · **Org permission** `manage_settings`. + +### Request body + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `url` | string | Yes | HTTPS URL that will receive POST callbacks. Must be publicly routable. | +| `description` | string | No | Free-text label for your own reference. | +| `event_types` | string[] | No | Event names to subscribe to. Each must be a known type (see the event vocabulary below). An empty or omitted array subscribes to all events. | +| `enabled` | boolean | No | Whether the endpoint is active. Defaults to `true`. | + +```json +{ + "url": "https://hooks.example.com/warmbly", + "description": "Production event sink", + "event_types": ["campaign.reply_received", "meeting.booked"], + "enabled": true +} +``` + +### Response + +`201 Created` with the full endpoint, including the one-time `secret`. + +```json +{ + "id": "a3f1c2e4-5b6d-7e8f-9a0b-1c2d3e4f5a6b", + "organization_id": "11111111-2222-3333-4444-555555555555", + "url": "https://hooks.example.com/warmbly", + "description": "Production event sink", + "event_types": ["campaign.reply_received", "meeting.booked"], + "enabled": true, + "last_success_at": null, + "last_failure_at": null, + "last_failure_reason": null, + "consecutive_failures": 0, + "created_at": "2026-06-11T18:00:00Z", + "updated_at": "2026-06-11T18:00:00Z", + "secret": "whsec_9f8e7d6c5b4a39281706f5e4d3c2b1a09f8e7d6c5b4a39281706f5e4d3c2b1a0" +} +``` + +## Update a webhook endpoint + +`PATCH /webhooks/:id` + +Updates a subscription's URL, description, event filter, or enabled state. The signing secret is not changed here, use the rotate endpoint for that. The same URL and event-type validation as create applies. + +Auth: **Scope** `WEBHOOKS` · **Org permission** `manage_settings`. + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `id` | path | UUID | The endpoint id to update. | + +### Request body + +Same shape as create. All fields are replaced with the values sent, so send the complete desired state (event_types is overwritten, not merged). + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `url` | string | Yes | HTTPS, publicly routable URL. | +| `description` | string | No | Free-text label. | +| `event_types` | string[] | No | Replacement event filter. Empty means all events. | +| `enabled` | boolean | No | Active state. Defaults to `true` if omitted. | + +```json +{ + "url": "https://hooks.example.com/warmbly", + "description": "Production event sink (replies only)", + "event_types": ["campaign.reply_received"], + "enabled": true +} +``` + +### Response + +`200 OK` with the updated endpoint (no secret). + +```json +{ + "id": "a3f1c2e4-5b6d-7e8f-9a0b-1c2d3e4f5a6b", + "organization_id": "11111111-2222-3333-4444-555555555555", + "url": "https://hooks.example.com/warmbly", + "description": "Production event sink (replies only)", + "event_types": ["campaign.reply_received"], + "enabled": true, + "last_success_at": "2026-06-11T18:02:14Z", + "last_failure_at": null, + "last_failure_reason": null, + "consecutive_failures": 0, + "created_at": "2026-05-01T09:00:00Z", + "updated_at": "2026-06-11T18:05:00Z" +} +``` + +## Delete a webhook endpoint + +`DELETE /webhooks/:id` + +Deletes a subscription and cascades to its delivery history. + +Auth: **Scope** `WEBHOOKS` · **Org permission** `manage_settings`. + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `id` | path | UUID | The endpoint id to delete. | + +### Response + +`204 No Content` with an empty body. + +## Rotate the signing secret + +`POST /webhooks/:id/rotate-secret` + +Issues a new signing secret and returns it once. In-flight deliveries that were already signed continue to verify against the old secret until they settle, while new deliveries use the new secret. Update your verifier promptly after rotating. + +Auth: **Scope** `WEBHOOKS` · **Org permission** `manage_settings`. + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `id` | path | UUID | The endpoint id to rotate. | + +### Response + +`200 OK` with the new secret. This is the only time it is returned. + +```json +{ + "secret": "whsec_1a2b3c4d5e6f70819a2b3c4d5e6f70819a2b3c4d5e6f70819a2b3c4d5e6f7081" +} +``` + +## List delivery attempts + +`GET /webhooks/:id/deliveries` + +Returns recent delivery attempts for an endpoint, newest first. Each row updates in place across retries, so a single event that retried several times appears as one record whose `attempt_count` and `status` reflect the latest state. Useful for debugging integration failures. + +Auth: **Scope** `WEBHOOKS` · **Org permission** `manage_settings`. + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `id` | path | UUID | The endpoint id whose deliveries to list. | +| `limit` | query | integer | Max rows to return. Must be between 1 and 200. Defaults to 50. Out-of-range values return `400`. | + +This endpoint returns a `deliveries` array, not a `data` + `pagination` cursor envelope. + +### Response + +```json +{ + "deliveries": [ + { + "id": "d1e2f3a4-b5c6-7d8e-9f0a-1b2c3d4e5f6a", + "endpoint_id": "a3f1c2e4-5b6d-7e8f-9a0b-1c2d3e4f5a6b", + "organization_id": "11111111-2222-3333-4444-555555555555", + "event_type": "campaign.reply_received", + "event_id": "e9f8a7b6-c5d4-3e2f-1a0b-9c8d7e6f5a4b", + "payload": { + "id": "e9f8a7b6-c5d4-3e2f-1a0b-9c8d7e6f5a4b", + "event_type": "campaign.reply_received", + "organization_id": "11111111-2222-3333-4444-555555555555", + "created_at": "2026-06-11T18:02:13Z", + "data": { "...": "event-specific object" } + }, + "status": "delivered", + "attempt_count": 1, + "max_attempts": 8, + "next_attempt_at": "2026-06-11T18:02:13Z", + "last_attempt_at": "2026-06-11T18:02:14Z", + "response_status": 200, + "response_body_excerpt": "ok", + "error_reason": null, + "created_at": "2026-06-11T18:02:13Z", + "updated_at": "2026-06-11T18:02:14Z" + } + ] +} +``` + +Delivery `status` is one of `pending`, `in_flight`, `delivered`, `failed`, or `abandoned` (retries exhausted). `response_body_excerpt` is capped at the first 1024 bytes of the subscriber's response. + +## Delivery payload + +Every callback is an HTTP `POST` with a `Content-Type: application/json` body in this shape: + +| Field | Type | Description | +| --- | --- | --- | +| `id` | UUID | Unique event id. Stable across retries of the same event. Matches `X-Warmbly-Event-Id`. | +| `event_type` | string | The event name, for example `campaign.reply_received`. | +| `organization_id` | UUID | The organization the event belongs to. | +| `created_at` | string | RFC 3339 UTC timestamp of when the event was dispatched. | +| `data` | object | Event-specific payload. Shape depends on `event_type`. | + +```json +{ + "id": "e9f8a7b6-c5d4-3e2f-1a0b-9c8d7e6f5a4b", + "event_type": "campaign.reply_received", + "organization_id": "11111111-2222-3333-4444-555555555555", + "created_at": "2026-06-11T18:02:13Z", + "data": { "...": "event-specific object" } +} +``` + +### Headers + +Each delivery carries these request headers: + +| Header | Description | +| --- | --- | +| `X-Warmbly-Signature` | HMAC-SHA256 signature in the form `t=,v1=`. | +| `X-Warmbly-Event` | The event type, so you can route without parsing the body. | +| `X-Warmbly-Event-Id` | The unique event id (same as the payload `id`). Use it to dedupe replays. | +| `User-Agent` | `Warmbly-Webhooks/1.0`. | + +## Verifying signatures + +Warmbly signs every delivery with HMAC-SHA256 using your endpoint's signing secret. The `X-Warmbly-Signature` header has the form `t=,v1=`. To verify: + +1. Parse `t` and `v1` from the header. +2. Compute `HMAC-SHA256(secret, "." + rawRequestBody)` and hex-encode it. The signed string is the timestamp, a literal `.`, then the exact raw request body. +3. Compare your hex digest against `v1` using a constant-time comparison. +4. Optionally reject deliveries whose `t` is too far from the current time to limit replay windows. + +Always compute the HMAC over the raw, unmodified request body before any JSON re-serialization. The secret is the `whsec_`-prefixed value returned at create or rotate time. + +```text +signed_payload = "1749664934." + raw_body +expected_v1 = hex(hmac_sha256(secret, signed_payload)) +``` + +## Retries and backoff + +Deliveries are enqueued and drained asynchronously by a background worker, so the dispatching API call returns immediately and is not blocked on your endpoint. + +- A `2xx` response marks the delivery `delivered` and resets the endpoint's failure health. +- Any non-`2xx` response, a connection error, or a timeout (the worker uses a 15s request timeout) marks the attempt failed and schedules a retry. +- Backoff doubles each retry starting at 30 seconds (30s, 1m, 2m, 4m, 8m, 16m, 32m), capped at 1 hour per wait. +- The default `max_attempts` is 8. Once attempts are exhausted the delivery is marked `abandoned`. +- Both `4xx` and `5xx` responses are retried (a `4xx` may be a transient validation flap), up to the attempt cap. + +Make your handler idempotent: dedupe on `X-Warmbly-Event-Id` (equivalently the payload `id`), since the same event id can arrive more than once across retries. + +## Event vocabulary + +An endpoint with an empty `event_types` filter receives all of the following. The `data` object differs per event. + +### Email account lifecycle + +| Event | Fires when | +| --- | --- | +| `email_account.connected` | A mailbox is connected to the organization. | +| `email_account.removed` | A mailbox is removed. | + +### Campaign send pipeline + +| Event | Fires when | +| --- | --- | +| `campaign.email_sent` | A campaign email is dispatched by a worker. | +| `campaign.email_delivered` | A campaign email is accepted by the recipient's provider. | +| `campaign.email_opened` | A tracked open is recorded. | +| `campaign.email_clicked` | A tracked link click is recorded. | +| `campaign.email_bounced` | A campaign email bounces. | +| `campaign.reply_received` | A human reply lands for a campaign thread. | +| `campaign.unsubscribed` | A recipient unsubscribes. | +| `campaign.started` | A campaign starts sending. | +| `campaign.paused` | A campaign is paused. | +| `campaign.completed` | A campaign finishes. | +| `campaign.deliverability_warning` | A campaign's rolling bounce or complaint rate enters the early-warning band (half the auto-pause threshold). | +| `campaign.action` | A sequence flow `notify` action node fires. | + +### Warmup + +| Event | Fires when | +| --- | --- | +| `warmup.email_sent` | A warmup message is sent. | +| `warmup.health_changed` | A mailbox's warmup health state changes. | +| `warmup.placement_in_spam` | A warmup message is observed landing in spam. | +| `warmup.quarantined` | A mailbox is quarantined from the shared pool. | +| `warmup.blocked` | A mailbox is blocked from the shared pool. | + +### Deliverability + +| Event | Fires when | +| --- | --- | +| `deliverability.bounce` | A bounce signal is processed. | +| `deliverability.complaint` | A complaint (spam report) is processed. | + +### Meetings + +| Event | Fires when | +| --- | --- | +| `meeting.booked` | A call is booked through a connected scheduler (Calendly or Cal.com). | +| `meeting.rescheduled` | A booked call is rescheduled. | +| `meeting.canceled` | A booked call is canceled. | + +## Errors + +Webhook endpoints use the standard error envelope with stable `code` and `request_id` fields (see [error codes](/api/error-codes/)). Common cases: + +- `400` invalid payload, a non-HTTPS or non-routable `url`, an unknown event type, or a `limit` outside 1 to 200. +- `400` an invalid endpoint id in the path. +- `404` the endpoint does not exist or does not belong to your organization (returned on update target lookup, delete, rotate, and deliveries). + +```json +{ + "error": "Bad Request", + "message": "unknown event type: campaign.exploded", + "code": "bad_request", + "request_id": "req_7Yc2pK1nQ4" +} +``` diff --git a/docs/content/docs/guides/analytics.mdx b/docs/content/docs/guides/analytics.mdx index 733b1d27..0812dd2e 100644 --- a/docs/content/docs/guides/analytics.mdx +++ b/docs/content/docs/guides/analytics.mdx @@ -25,6 +25,8 @@ A few things worth knowing about how the counts behave: - **Open and click tracking** only work when the campaign has open tracking or link tracking turned on, and when the sender mailboxes have a tracking domain configured. With tracking off, opens and clicks stay at zero even though sends and replies are still counted. See [Deliverability](/guides/deliverability) for the pre-send checks that flag missing tracking domains. - **Replies are human replies.** An out-of-office auto-reply or an autoresponder does not count as a reply and does not stamp the contact as having replied. This keeps reply rate honest and stops an auto-reply from silently tripping a sequence's stop-on-reply rule. +- **Bots are filtered out.** Crawler and CLI user agents, browser prefetches, link previews from chat apps, and email security gateways that open every link in a message are served normally but never counted. Without this filter, a single corporate security scanner could mark every link in a campaign as "clicked" seconds after delivery. +- **Auto-opens are labeled, not hidden.** Privacy proxies such as Apple Mail Privacy Protection fetch the tracking pixel automatically whether or not the person reads the email. These opens still count (they confirm delivery), but they are tagged as auto-opens and shown next to the open count (for example "12 auto") so you can judge real engagement. If the same recipient later opens from a real client, the open is upgraded to a human open. Auto-opens never trigger opened-based sequence branches or automations. - **Rates are percentages of sent.** Open rate, click rate, reply rate, and bounce rate are all computed against the number of emails sent in the selected window. ## The workspace dashboard diff --git a/docs/content/docs/guides/automations.mdx b/docs/content/docs/guides/automations.mdx index 745073ee..5579ef43 100644 --- a/docs/content/docs/guides/automations.mdx +++ b/docs/content/docs/guides/automations.mdx @@ -41,10 +41,20 @@ A trigger is the event that starts the flow. Pick one in the trigger node's edit | Unsubscribed | A contact unsubscribes | | Warmup health changed | A mailbox's warmup health state changes | | Deliverability complaint | A spam complaint is recorded | +| Inbound webhook | An external system POSTs to this automation's unique URL | | Campaign action | Launched on demand from a campaign step, not from a real event | The **Campaign action** trigger is special: it never fires by itself. It only runs when a campaign sequence reaches a "Run automation" step, which lets you trigger an automation as part of a campaign flow. (A campaign-launched run still evaluates your conditions, and an automation that is **Off** is a no-op.) +### Inbound webhook trigger + +The **Inbound webhook** trigger lets anything outside Warmbly start a flow. Pick it on the trigger node and **save**: the editor then shows a unique URL for this automation. Any system that can send an HTTP request (a form tool, your backend, a no-code platform) `POST`s JSON to that URL and the flow runs. + +- The JSON body becomes the event payload, so reference its keys directly in actions and conditions with `{{.field}}` (a body of `{"email":"a@b.com","plan":"pro"}` gives you `{{.email}}` and `{{.plan}}`). A body that is not a JSON object is exposed verbatim as `{{.body}}`. +- The URL itself is the credential: it carries a high-entropy token, so treat it like a secret and rotate it (by switching the trigger away and back) if it leaks. Send it over HTTPS. +- The request returns immediately and the flow runs in the background, so a slow action never blocks the caller. An automation that is **Off** accepts the request and does nothing. +- Keys beginning with an underscore are stripped from the payload, and the body is capped at 1 MB. + Each trigger carries its own event data (for example, a reply carries the contact email, the reply intent, and a classifier confidence). Those values are what your conditions test and what your action text can insert. See [Personalization & expressions](/guides/expressions) for the full list of variables per trigger. ## IF conditions (branching) @@ -107,6 +117,8 @@ Connect an action to one of your integrations to push data out. Available action A Slack action needs a **channel** (for example `#sales`). A webhook action needs a **URL**. Slack, Discord, and webhook actions also accept an optional **message** template. +Slack and Discord notifications arrive as a branded card in Warmbly's sky-blue accent: a title, your message text when you set one, and contact and subject fields, rather than a plain line. Discord uses a rich embed and Slack a colored attachment. + ### Native (built-in) actions These act directly on the event's contact and need no external connection. Pick **Warmbly (built-in)** under **Run**: @@ -115,12 +127,32 @@ These act directly on the event's contact and need no external connection. Pick | --- | --- | | Add a tag | Adds a contact category (tag) | | Remove a tag | Removes a contact category (tag) | +| Label the email | Applies inbox labels to the conversation the contact replied on | | Create a task | Creates a task assigned to the workspace owner | | Create a deal | Creates a CRM deal in a chosen pipeline and stage | | Move the deal stage | Moves the contact's most recent open deal in a pipeline | | Unsubscribe the contact | Unsubscribes the contact (works when the event carries a campaign) | +| HTTP request / webhook | Calls any URL (method, headers, query, body all templated) and saves the response for later steps | +| Set variables | Computes named values from templates and stores them for later steps to reuse | +| Fire event | Publishes a custom event to the realtime gateway; your app receives it over the API websocket, with no public URL | -**Move the deal stage** acts on the contact's most recent open deal in the chosen pipeline. If they have no open deal there, nothing happens. **Unsubscribe the contact** only applies when the triggering event carries a campaign, such as a reply, bounce, or unsubscribe. +**Move the deal stage** acts on the contact's most recent open deal in the chosen pipeline. If they have no open deal there, nothing happens. **Unsubscribe the contact** only applies when the triggering event carries a campaign, such as a reply, bounce, or unsubscribe. **Label the email** only works on a **Reply received** automation: it labels the conversation that reply belongs to (the same labels you set by hand in the unibox), so it needs an inbox thread to act on. + +**HTTP request / webhook** is the generic "call any API" step. Choose a method, a URL, optional headers and query, and a body, all of which can use the same `{{.variable}}` templating as everywhere else. The call is retried briefly on a network or 5xx failure. The response is written back into the flow under the output name you choose (default `response`), so a later step can read `{{.response.body.id}}` or `{{.response.status}}`, and a condition can branch on `{{.response.ok}}` to handle failures. + +For safety the URL must be HTTPS and resolve to a public address. Warmbly blocks requests to private, loopback, link-local, and cloud-metadata addresses, checked at the moment the request is made (so a hostname that resolves to an internal IP is caught too) and again on any redirect. This is why the action can't be pointed at internal services. Outbound requests are also subject to a generous per-workspace daily limit to keep the feature from being used for abuse. + +**Set variables** computes one or more named values from templates and writes them back into the flow, so you can normalize or combine fields once and reuse the result (`{{.name}}`) in several later steps. Each value is a Go template; it does not run arbitrary code. + +**Fire event** is the inverse of the inbound webhook: instead of an outside system calling you, Warmbly tells your system something happened. Give it an event name and a set of templated key/value fields, and when the step runs Warmbly publishes a `CUSTOM_EVENT` to the realtime gateway. Your app subscribes with an API key (the `REALTIME_SUBSCRIBE` permission) over the websocket and receives `{ name, payload }`, so you get events without hosting a public URL or exposing an endpoint. See [Realtime events](/api/realtime/). The same step is available on campaign sequences. + +### Handling errors (the on-error branch) + +Every action node has an **on error** branch: the red dot on its right edge. Drag from it to the steps that should run when that action fails (a `5xx` from an HTTP request, a rejected Slack message, a CRM timeout, a webhook that won't connect). + +- When the action fails and an on-error branch is connected, the flow follows that branch and the run is **not** marked failed, the error is handled, like a try/catch. The normal path below the action is skipped. +- When an action has no on-error branch, a failure is recorded in History, the run is marked errored, and the flow still continues down the normal path best-effort, so one failing step never blocks the rest. +- A dry run never fails an action, so **Test** always shows the normal path, not the on-error branch. ### Templating action text @@ -146,7 +178,7 @@ Press **Test** to save the current canvas and then **dry-run** it against sample ### History -Press **History** to see recent real runs of this automation. Each run shows whether it succeeded or errored, when it started, and the result of each action step (including the error text when a step fails). Run history updates live as the automation fires, so you can watch it work without refreshing. +Press **History** to see recent real runs of this automation. Each run shows whether it succeeded or errored, when it started, and the result of each action step: the error text when a step fails, and a short summary of what the step did when it succeeds (the channel or URL it used, an HTTP request's response status, or the variables a Set variables step computed). Run history updates live as the automation fires, so you can watch it work without refreshing. Automation runs are best-effort and bounded against loops, so one failing step won't block the rest of the flow. The run is still recorded as errored in History so you can see what went wrong. diff --git a/docs/content/docs/guides/campaigns.mdx b/docs/content/docs/guides/campaigns.mdx index fc6c3307..673a01c6 100644 --- a/docs/content/docs/guides/campaigns.mdx +++ b/docs/content/docs/guides/campaigns.mdx @@ -109,6 +109,12 @@ A campaign can also pause itself. If it loses all of its sending accounts it bec If you have configured it to do so, a campaign can stop sending follow-ups to a contact as soon as that contact replies, so you do not keep emailing someone who is already in a conversation with you. +## Live activity and issues + +The campaign detail view streams a live activity feed: sends, opens, clicks, replies, bounces, and skips appear as they happen, for every teammate at once. Above the feed, a **Needs attention** panel surfaces anything that went wrong, such as a step that could not be scheduled. These entries are retried automatically and stay visible so a problem is never silent. + +If a campaign's scheduling ever stalls because of a transient infrastructure hiccup, Warmbly re-seeds it on its own within a few minutes, so an active campaign does not get stuck. You can always pause and resume to force a fresh start. + ## Safety posture Warmbly defaults are deliberately conservative. The goal is a low complaint rate and a low spam rate, not maximum throughput. diff --git a/docs/content/docs/guides/collaboration.mdx b/docs/content/docs/guides/collaboration.mdx new file mode 100644 index 00000000..f69f8c0e --- /dev/null +++ b/docs/content/docs/guides/collaboration.mdx @@ -0,0 +1,48 @@ +--- +title: Live collaboration +description: See your team working in real time across the whole dashboard. +icon: Users +--- + +The Warmbly dashboard is live by default. Campaign sends, opens, clicks, replies, new inbox mail, contact changes, automation runs, meetings, and audit entries all appear the moment they happen, for everyone in the workspace, without a refresh. On top of that event stream sits a presence layer that shows who on your team is online and what they are working on. + +## Online teammates + +The header shows an avatar stack of every workspace member who currently has the dashboard open. Hover it to see each person's name and where they are right now (for example "in unibox" or "editing"). The stack updates instantly as people come and go. + +## Who is already on this? + +Anywhere a record can be opened, Warmbly shows you when a teammate already has it open: + +- **Unibox**: a conversation row gets a green pulse when someone else is reading it, and an amber pulse when they are already writing a reply. The open thread shows a pill with their name, so two people never answer the same prospect twice. +- **Automations**: opening a flow that a teammate is editing shows an amber "is editing" pill next to the automation name. Coordinate before saving; the last save wins. +- **Campaigns**: the campaign header shows who else is viewing the same campaign. +- **Contacts**: the contact panel shows when someone else has the same person open for editing. + +Indicators are scoped to your workspace. Only members of the same organization can see your presence, and what they see is limited to your name, avatar, the dashboard page you are on, and the record you have open. + +## What updates live + +Beyond presence, the dashboard reflects changes from any source in real time: + +- An email sent inside a campaign updates the campaign's progress, the leads list, and analytics for every viewer the moment the worker sends it. +- Opens, clicks, and replies pulse into campaign stats and the activity feed as recipients act. Automated replies such as out-of-office do not count as replies. +- New mail appears in every member's unibox as it syncs, respecting inbox permissions. +- Contact imports, edits, and deletes refresh the contacts views for the whole team. +- Mailbox health transitions (for example a warmup quarantine) flip status badges live. +- The [audit log](/guides/security/) streams new entries as teammates act, so the activity trail is also a live feed. + +Events are permission-aware: a member without inbox access never receives unibox events, and billing events only reach members who can manage billing. See [Team roles](/guides/team-roles/) for the permission model. + +## Presence privacy + +Presence is a workspace setting, controlled by an admin under Settings, Workspace, Team presence. Two independent toggles decide how much members see about each other: + +- **Show who's online** turns the live avatar stack and online indicators on or off for the whole workspace. With it off, no one can see who has the dashboard open. +- **Show activity** keeps online status but hides what each person is doing, so teammates no longer see "viewing", "editing", or "replying to" a specific record. Online stays visible; only the detail is hidden. + +The toggles are enforced by the realtime service, so when a control is off the hidden signal is never sent to teammates, not merely hidden in the interface. Changes apply immediately to everyone connected, with no reload. Editing the settings requires the Manage settings permission. + +## Developers + +The same realtime stream is available to API consumers over a WebSocket, including the presence events that power these indicators. See the [Realtime WebSocket](/api/realtime/) reference for connection details, channels, and rate limits. diff --git a/docs/content/docs/guides/meta.json b/docs/content/docs/guides/meta.json index a4d1e996..fcef98ce 100644 --- a/docs/content/docs/guides/meta.json +++ b/docs/content/docs/guides/meta.json @@ -18,6 +18,7 @@ "analytics", "notifications", "security", - "team-roles" + "team-roles", + "collaboration" ] } diff --git a/docs/content/docs/guides/notifications.mdx b/docs/content/docs/guides/notifications.mdx index 84b3f5f3..84ea1e3e 100644 --- a/docs/content/docs/guides/notifications.mdx +++ b/docs/content/docs/guides/notifications.mdx @@ -74,13 +74,13 @@ Even with the reply notification off, you never miss responses. Every reply stil ## Channels -The settings page also shows where notifications are delivered. +The settings page controls where enabled notifications are delivered. The channel toggles apply across every category above. -- **In-app**: the bell in the dashboard. This is the channel that is live today, and it is controlled by the per-category toggles above. -- **Email**: delivery to your account email. Marked **Coming soon**. -- **Slack**: delivery through a connected Slack integration. Marked **Coming soon**. +- **In-app**: the bell in the dashboard. Always on, controlled by the per-category toggles above. +- **Email**: delivery to your account email. Turn it on to also receive each enabled notification as an email with a link back into the app. +- **Slack**: posts each enabled notification to your connected Slack, on the channel you have configured for Slack in the [Integrations](/guides/integrations) tab. Connect Slack and set up a channel there first; until a Slack channel is configured the toggle saves but nothing is delivered. -For now, the in-app feed is the delivery channel. If you want event-driven Slack or webhook delivery in the meantime, that is what [Automations](/guides/automations) are for: you can route events like replies, bookings, and record changes to outside tools there. +For richer, event-specific routing (custom messages, branching, multiple destinations), use [Automations](/guides/automations) instead: they can route events like replies, bookings, and record changes to outside tools with full control. ## Practical tips diff --git a/docs/content/docs/guides/security.mdx b/docs/content/docs/guides/security.mdx index d26a3a10..a5ea6df9 100644 --- a/docs/content/docs/guides/security.mdx +++ b/docs/content/docs/guides/security.mdx @@ -121,6 +121,18 @@ If you see a session you do not recognize, sign it out: If you spot a session you do not recognize, sign it out, then change your password and make sure two-factor authentication is on. Signing out other sessions is the fastest way to cut off access from a device that should not have it. +## Changing your password + +Change the password you sign in with from **Settings, then Security**, under **Password**. You will be asked for your current password, then a new one. New passwords must be at least 12 characters with upper and lower case and a number. Accounts that only ever sign in with Google, Apple, or a passkey have no password to change. + +Changing your password signs out every other device automatically, so it is a complete way to cut off a session you do not recognize. The device you change it on stays signed in. + +## Sign-in alerts + +Warmbly can notify you when your account is accessed from a device you have not used before (a new browser and operating system combination). The alert tells you the device and, where known, the location, with a reminder to change your password and sign out other sessions if it was not you. + +Sign-in alerts are a notification category: they appear in your in-app feed by default, and you can also receive them by email. Turn email on under **Settings, then Notifications**, in the **Security** section and the **Email** channel. Your very first sign-in is not alerted, since there is no earlier device to compare against. + ## Putting it together For the strongest account protection: diff --git a/docs/content/docs/guides/sequences.mdx b/docs/content/docs/guides/sequences.mdx index 95255120..dee02469 100644 --- a/docs/content/docs/guides/sequences.mdx +++ b/docs/content/docs/guides/sequences.mdx @@ -47,6 +47,7 @@ You can apply a saved template to a step, or save the current step as a reusable A step does not have to send an email. From the add menu (the chevron next to "Add step"), or by switching a step's type in its editor, a step can instead perform an action when a contact reaches it: - Add tag or remove tag +- Label email (applies inbox labels to the conversation the contact replied on) - Create task - Create deal or move deal stage - Unsubscribe the contact @@ -55,6 +56,10 @@ A step does not have to send an email. From the add menu (the chevron next to "A Action steps connect the same way as email steps and are most useful at the end of a reply branch (for example: on a positive reply, create a deal and notify your team). + +**Label email** is reply-only. It labels the conversation the contact replied on with the same labels you use by hand in the unibox, so it does something only once they have replied. Place it on a reply branch (if replied / positive / negative); on any other path it is a no-op because there is no thread to label. + + ## Wait and spacing Spacing between steps is a property of the **target** step, not a separate node. There is no standalone "wait" block on the canvas. Instead, when a contact follows a connection into a step, that step's wait determines how long they pause first. @@ -87,7 +92,7 @@ How it works: - The step's own subject and body are the **original**. It always sends until you add a variant. - Click **Add variant** to add Variant B (then C, D, E, F). You can have up to 5 variants per step, on top of the original. - Each variant has its own **name**, **weight**, **active** toggle, **subject**, and **body**. Leave a variant's subject or body blank to reuse the step's own. -- Sends split across the original and the active variants by **weight** (1 to 100). Higher weight means a larger share of contacts. Turning a variant **inactive** stops it from receiving new sends without deleting it. +- Sends split across the original and the active variants by **weight** (1 to 100). Higher weight means a larger share of contacts. The original is a control arm at the default weight, so one variant at the same weight gives an even split (the editor shows each arm's live share, like "~50% of contacts"). Turning a variant **inactive** stops it from receiving new sends without deleting it. ### Performance and the winner diff --git a/docs/content/docs/guides/team-roles.mdx b/docs/content/docs/guides/team-roles.mdx index 2428ac16..9c713051 100644 --- a/docs/content/docs/guides/team-roles.mdx +++ b/docs/content/docs/guides/team-roles.mdx @@ -8,8 +8,8 @@ A Warmbly workspace can be shared with your whole team. You invite people by ema Everything here lives under **Settings**: the roster and invitations are on the **Members** page, and the roles and permission matrix are on the **Roles & access** page. - -Inviting members, changing someone's role, and removing members are reserved for the workspace **owner**. If you are not the owner, you can still see the Members page, but the invite box, the role pickers, and the remove buttons will not appear. The Roles & access page is owner-only and shows a "permission denied" notice to everyone else. + +Inviting members, changing roles, removing members, and managing roles all require the **Manage team** permission (the owner and the seeded Admin role have it; any custom role can carry it). Members without it see the roster read-only, and the Roles & access page shows a permission notice instead. ## Inviting members @@ -58,20 +58,20 @@ A few rules apply: ## Roles and the permission matrix -Warmbly ships with a set of built-in roles. Each role is a fixed bundle of permissions. You pick the closest role for each person; custom permission bundles are not available yet. +Roles in Warmbly are workspace data. Every new workspace starts with three seeded roles (Admin, Manager, and Viewer), and all of them are ordinary roles: rename them, recolor them, change their permissions, delete them, or add your own. Owner is not a role but a membership status; there is exactly one owner per workspace. The **Roles & access** page shows a card for each role with a short description and a live count of how many people currently hold it, followed by the full **permission matrix**. -### The built-in roles +### The seeded roles -| Role | What it can do | +| Role | What it starts with | | --- | --- | -| **Owner** | Full control of the workspace. There is exactly one owner. Only the owner can transfer ownership. | -| **Admin** | Everything the owner can do except transfer ownership. | +| **Owner** (status, not a role) | Full control of the workspace, including ownership transfer. | +| **Admin** | Everything except transferring ownership. | | **Manager** | The day-to-day operator. Can run campaigns, manage contacts and mailboxes, and use integrations, but has no team, billing, settings, or API-key access. | | **Viewer** | Read-only. Can see campaigns, contacts, and reports but cannot change anything. | -There is also a legacy **Member** role kept only for backwards compatibility. It behaves the same as **Manager**. New members are not assigned Member; pick Manager instead. +These are starting points, not fixed tiers. Any of them can be edited or deleted once the workspace exists. ### What each permission means @@ -136,9 +136,24 @@ This is how the built-in roles map onto those capabilities. A check means the ro In short: **Admin** is the owner minus ownership transfer, **Manager** is everything operational without team, settings, billing, or API keys, and **Viewer** is read-only. - -Custom roles with bespoke permission bundles are planned but not available yet. For now the built-in roles cover the common patterns, so choose the one closest to what the person needs. - +## Custom roles + +Anyone with team management access can create additional roles on the **Roles & access** settings page: + +1. Click **New role** and give it a name (up to 50 characters), a color for the role pickers, and an optional description. Only the name "owner" is reserved. +2. Pick a template under **Start from** to copy a permission bundle as a starting point, then toggle individual permissions on or off. +3. Save, then assign the role from the member roster's role picker or directly in the invite flow. + +A few rules keep custom roles safe: + +- **Editing a role updates everyone assigned to it, immediately.** The editor shows how many members will be affected before you save. +- **You can only grant permissions you hold yourself.** A manager with team access cannot mint a role stronger than their own and assign it to someone. +- **Ownership transfer can never be part of a custom role.** It stays exclusive to the owner. +- **Members can hold several roles at once.** Their effective access is the combined (union) permissions of every role assigned to them. +- **Deleting a role is always allowed.** It is removed from anyone holding it; their remaining roles still apply. A member left with no roles keeps their membership but has no permissions until reassigned. +- Each workspace can have up to 25 roles in total (including the seeded ones). + +Custom roles apply everywhere permissions do: API access checks, dashboard visibility, and which realtime events a member's live dashboard receives. ## Removing members diff --git a/docs/public/asyncapi.json b/docs/public/asyncapi.json new file mode 100644 index 00000000..7991597c --- /dev/null +++ b/docs/public/asyncapi.json @@ -0,0 +1,230 @@ +{ + "asyncapi": "3.1.0", + "info": { + "title": "Warmbly Realtime Gateway", + "version": "1.0.0", + "description": "Resumable WebSocket gateway for live organization events. The socket speaks the Phoenix channel protocol (serializer 1.0.0). Authenticate with an API key (REALTIME_SUBSCRIBE permission) or a short-lived JWT passed as the `token` query parameter. Events carry a monotonic per-organization `seq`; reconnecting clients resume by replaying the gap. See the human guide at https://docs.warmbly.com/api/realtime/.", + "contact": { "name": "Warmbly", "url": "https://docs.warmbly.com" }, + "license": { "name": "Proprietary", "url": "https://warmbly.com" } + }, + "defaultContentType": "application/json", + "servers": { + "production": { + "host": "realtime.warmbly.com", + "pathname": "/socket/websocket", + "protocol": "wss", + "description": "Production gateway. Connect with ?vsn=1.0.0&token=.", + "security": [{ "$ref": "#/components/securitySchemes/token" }] + } + }, + "channels": { + "org": { + "address": "org:{org_id}", + "title": "Organization channel", + "description": "Per-organization event stream + team presence. A subscriber receives every event the member/key is permitted to see, optionally narrowed by intents. Messages are Phoenix channel frames [join_ref, ref, topic, event, payload].", + "parameters": { + "org_id": { "description": "The organization id (UUID) to subscribe to." } + }, + "messages": { + "join": { "$ref": "#/components/messages/Join" }, + "hello": { "$ref": "#/components/messages/Hello" }, + "event": { "$ref": "#/components/messages/Event" }, + "resumed": { "$ref": "#/components/messages/Resumed" }, + "resumeFailed": { "$ref": "#/components/messages/ResumeFailed" }, + "rateLimited": { "$ref": "#/components/messages/RateLimited" }, + "presenceState": { "$ref": "#/components/messages/PresenceState" }, + "presenceDiff": { "$ref": "#/components/messages/PresenceDiff" }, + "presenceUpdate": { "$ref": "#/components/messages/PresenceUpdate" } + } + } + }, + "operations": { + "joinOrg": { + "action": "send", + "channel": { "$ref": "#/channels/org" }, + "summary": "Join the org channel (optionally with intents and a resume token).", + "messages": [{ "$ref": "#/channels/org/messages/join" }], + "reply": { + "channel": { "$ref": "#/channels/org" }, + "messages": [{ "$ref": "#/channels/org/messages/hello" }] + } + }, + "receiveOrgStream": { + "action": "receive", + "channel": { "$ref": "#/channels/org" }, + "summary": "Receive live events, resume markers, presence, and rate-limit notices.", + "messages": [ + { "$ref": "#/channels/org/messages/event" }, + { "$ref": "#/channels/org/messages/resumed" }, + { "$ref": "#/channels/org/messages/resumeFailed" }, + { "$ref": "#/channels/org/messages/rateLimited" }, + { "$ref": "#/channels/org/messages/presenceState" }, + { "$ref": "#/channels/org/messages/presenceDiff" } + ] + }, + "updatePresence": { + "action": "send", + "channel": { "$ref": "#/channels/org" }, + "summary": "Update your own presence activity (JWT members only).", + "messages": [{ "$ref": "#/channels/org/messages/presenceUpdate" }] + } + }, + "components": { + "securitySchemes": { + "token": { + "type": "httpApiKey", + "in": "query", + "name": "token", + "description": "An API key with the REALTIME_SUBSCRIBE permission, or a short-lived connection JWT, passed as the `token` query parameter on the socket URL." + } + }, + "messages": { + "Join": { + "name": "phx_join", + "title": "Join (phx_join)", + "summary": "Subscribe to org:{org_id}. Optionally declare intents and a resume token.", + "payload": { "$ref": "#/components/schemas/JoinPayload" } + }, + "Hello": { + "name": "phx_reply", + "title": "HELLO (join reply)", + "summary": "Heartbeat cadence and the current stream sequence.", + "payload": { "$ref": "#/components/schemas/Hello" } + }, + "Event": { + "name": "event", + "title": "Domain event", + "summary": "A live or replayed event. The frame event name is the event_type (e.g. CAMPAIGN_UPDATED); the payload carries the body plus a monotonic seq.", + "payload": { "$ref": "#/components/schemas/Event" } + }, + "Resumed": { + "name": "resumed", + "title": "Resume complete", + "summary": "Sent after a successful resume replay; the events preceded it.", + "payload": { "$ref": "#/components/schemas/Resumed" } + }, + "ResumeFailed": { + "name": "resume_failed", + "title": "Resume failed", + "summary": "The buffer no longer covers your position (or the token was malformed). Do a full REST resync, then continue from current_seq.", + "payload": { "$ref": "#/components/schemas/ResumeFailed" } + }, + "RateLimited": { + "name": "rate_limited", + "title": "Outbound rate limited", + "payload": { "$ref": "#/components/schemas/RateLimited" } + }, + "PresenceState": { + "name": "presence_state", + "title": "Presence snapshot", + "payload": { "$ref": "#/components/schemas/PresenceState" } + }, + "PresenceDiff": { + "name": "presence_diff", + "title": "Presence diff", + "payload": { "$ref": "#/components/schemas/PresenceDiff" } + }, + "PresenceUpdate": { + "name": "presence:update", + "title": "Update own presence", + "payload": { "$ref": "#/components/schemas/PresenceUpdate" } + } + }, + "schemas": { + "JoinPayload": { + "type": "object", + "description": "Payload of the phx_join frame for org:{org_id}.", + "properties": { + "intents": { + "type": "array", + "items": { "type": "string" }, + "description": "Optional event-family tokens (case-insensitive substring of the event type, e.g. CAMPAIGN, EMAIL, AUDIT). Absent/empty = the full permitted stream. Not a security boundary." + }, + "resume": { + "type": "object", + "description": "Resume token from a reconnecting client.", + "properties": { + "last_seq": { + "type": "integer", + "minimum": 0, + "description": "The highest seq the client has processed. The server replays events with seq greater than this." + } + }, + "required": ["last_seq"] + } + } + }, + "Hello": { + "type": "object", + "required": ["org_id", "heartbeat_interval_ms", "server_timeout_ms", "seq"], + "properties": { + "org_id": { "type": "string", "format": "uuid" }, + "role": { "type": "string", "description": "The member's role in the org (JWT sockets)." }, + "heartbeat_interval_ms": { "type": "integer", "description": "Send a heartbeat on the phoenix topic at least this often." }, + "server_timeout_ms": { "type": "integer", "description": "The server closes the socket after this long with no heartbeat." }, + "seq": { "type": "integer", "description": "Current stream sequence. A fresh client should start tracking from here." }, + "resume_supported": { "type": "boolean" } + } + }, + "Event": { + "type": "object", + "required": ["event_type", "seq"], + "description": "Invalidation-oriented: the body carries ids, not full resource state. Refetch over REST for current contents. Permission- and intent-filtered identically for live and replayed events.", + "properties": { + "event_type": { "type": "string", "description": "e.g. AUDIT_CREATED, CAMPAIGN_UPDATED, EMAIL_SENT, EMAIL_RECEIVED. Also the frame event name." }, + "seq": { "type": "integer", "description": "Monotonic per-organization sequence. Track the highest seen; dedupe replays by it." }, + "org_id": { "type": "string", "format": "uuid" }, + "user_id": { "type": "string", "format": "uuid" }, + "campaign_id": { "type": "string", "format": "uuid" }, + "email_account_id": { "type": "string", "format": "uuid" } + }, + "additionalProperties": true + }, + "Resumed": { + "type": "object", + "required": ["from", "current_seq", "replayed"], + "properties": { + "from": { "type": "integer", "description": "The last_seq the client resumed from." }, + "current_seq": { "type": "integer", "description": "The stream sequence after replay; the client is now caught up to here." }, + "replayed": { "type": "integer", "description": "Number of events delivered during replay (after permission + intent filtering)." } + } + }, + "ResumeFailed": { + "type": "object", + "required": ["reason", "current_seq"], + "properties": { + "reason": { "type": "string", "enum": ["buffer_evicted", "invalid_resume"] }, + "current_seq": { "type": "integer", "description": "Resync over REST, then continue live from here." } + } + }, + "RateLimited": { + "type": "object", + "properties": { + "category": { "type": "string", "example": "ws_message" }, + "retry_after_ms": { "type": "integer" } + } + }, + "PresenceState": { + "type": "object", + "description": "Phoenix presence snapshot: a map of user_id -> { metas: [...] }.", + "additionalProperties": true + }, + "PresenceDiff": { + "type": "object", + "description": "Phoenix presence diff with joins and leaves.", + "properties": { + "joins": { "type": "object", "additionalProperties": true }, + "leaves": { "type": "object", "additionalProperties": true } + } + }, + "PresenceUpdate": { + "type": "object", + "properties": { + "page": { "type": "string" }, + "resource": { "type": "string", "description": "e.g. thread:, campaign:, contact:." }, + "action": { "type": "string", "enum": ["viewing", "editing", "replying", "idle"] } + } + } + } + } +} diff --git a/docs/public/openapi.json b/docs/public/openapi.json new file mode 100644 index 00000000..8986df12 --- /dev/null +++ b/docs/public/openapi.json @@ -0,0 +1,27731 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Warmbly API", + "version": "1.0.0", + "description": "The Warmbly API lets you drive mailboxes, campaigns, contacts, the unibox, CRM, and more programmatically. Authenticate with an API key as a Bearer token. All paths are relative to the versioned base URL.", + "contact": { + "name": "Warmbly", + "url": "https://docs.warmbly.com" + }, + "license": { + "name": "Proprietary", + "url": "https://warmbly.com" + } + }, + "servers": [ + { + "url": "https://api.warmbly.com/v1", + "description": "Production (v1)" + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "tags": [ + { + "name": "auth", + "description": "Authentication: login, registration, password reset, 2FA, and sessions." + }, + { + "name": "mailboxes", + "description": "Connected sending mailboxes and their warmup lifecycle." + }, + { + "name": "campaigns", + "description": "Cold outreach campaigns, steps, and A/B variants." + }, + { + "name": "contacts", + "description": "Contacts and their tags." + }, + { + "name": "unibox", + "description": "Unified inbox: threads, replies, and labels." + }, + { + "name": "crm", + "description": "Deals, tasks, notes, and pipelines." + }, + { + "name": "api-keys", + "description": "API key management and usage logs." + }, + { + "name": "webhooks", + "description": "Outbound webhook endpoints and deliveries." + }, + { + "name": "analytics", + "description": "Campaign and deliverability analytics." + }, + { + "name": "integrations", + "description": "Third-party connections and automations." + }, + { + "name": "account-org", + "description": "Account, organization, and plan reference data." + }, + { + "name": "deliverability-ops", + "description": "Deliverability event ingest, suppression, and seed placement." + } + ], + "paths": { + "/auth/login": { + "post": { + "operationId": "auth_login_start", + "summary": "Start login (request email code)", + "description": "Step 1 of email login. Verifies the email/password and Turnstile token, then emails a one-time confirmation code. Returns an opaque session handle to pass to /auth/login/confirm.", + "tags": [ + "auth" + ], + "security": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthCredentials" + } + } + } + }, + "responses": { + "200": { + "description": "Confirmation code sent; returns the session handle.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthSession" + } + } + } + }, + "400": { + "description": "Invalid request body.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Turnstile / captcha rejected.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/auth/login/confirm": { + "post": { + "operationId": "auth_login_confirm", + "summary": "Confirm login (exchange code for tokens)", + "description": "Step 2 of email login. Exchanges the session handle plus the emailed code for a token pair. If 2FA is enabled, returns a 2FA challenge (pending_token) instead of a session.", + "tags": [ + "auth" + ], + "security": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfirmRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Login result: either a full token pair, or a 2FA challenge.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginResult" + } + } + } + }, + "400": { + "description": "Invalid request body.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Wrong or expired code, or invalid session.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Turnstile / captcha rejected.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited / too many attempts.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/auth/register": { + "post": { + "operationId": "auth_register_start", + "summary": "Start registration (request email code)", + "description": "Step 1 of registration. Validates email/password and Turnstile, then emails a confirmation code. Returns an opaque session handle for /auth/register/confirm.", + "tags": [ + "auth" + ], + "security": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthCredentials" + } + } + } + }, + "responses": { + "200": { + "description": "Confirmation code sent; returns the session handle.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthSession" + } + } + } + }, + "400": { + "description": "Invalid request body or weak password.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Email already in use or not allowed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Turnstile / captcha rejected.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/auth/register/confirm": { + "post": { + "operationId": "auth_register_confirm", + "summary": "Confirm registration", + "description": "Step 2 of registration. Exchanges the session handle plus the emailed code to finalize the account. Returns 204 on success.", + "tags": [ + "auth" + ], + "security": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfirmRequest" + } + } + } + }, + "responses": { + "204": { + "description": "Account confirmed." + }, + "400": { + "description": "Invalid request body.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Wrong or expired code, or invalid session.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Turnstile / captcha rejected.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/auth/refresh": { + "post": { + "operationId": "auth_refresh", + "summary": "Refresh the token pair", + "description": "Exchanges a valid refresh token for a new token pair (rotating refresh).", + "tags": [ + "auth" + ], + "security": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RefreshRequest" + } + } + } + }, + "responses": { + "200": { + "description": "A fresh token pair.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TokenPair" + } + } + } + }, + "400": { + "description": "Invalid request body.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Refresh token invalid, expired, or revoked.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/auth/reset-password": { + "post": { + "operationId": "auth_reset_password_start", + "summary": "Start password reset", + "description": "Sends a password-reset code to the email if an account exists. Always returns 200 to avoid account enumeration.", + "tags": [ + "auth" + ], + "security": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResetPasswordStartRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Reset email sent if the account exists." + }, + "400": { + "description": "Invalid request body.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Turnstile / captcha rejected.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/auth/reset-password/confirm": { + "post": { + "operationId": "auth_reset_password_confirm", + "summary": "Confirm password reset", + "description": "Exchanges the reset session handle plus a new password to set a new password.", + "tags": [ + "auth" + ], + "security": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResetPasswordConfirmRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Password updated." + }, + "400": { + "description": "Invalid request body or weak password.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Invalid or expired reset session.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Turnstile / captcha rejected.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/auth/2fa/verify": { + "post": { + "operationId": "auth_2fa_verify_login", + "summary": "Verify 2FA login challenge", + "description": "Exchanges the single-use pending_token from /auth/login/confirm plus a TOTP or recovery code for a real token pair.", + "tags": [ + "auth" + ], + "security": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TwoFAVerifyRequest" + } + } + } + }, + "responses": { + "200": { + "description": "A full token pair.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TokenPair" + } + } + } + }, + "400": { + "description": "Invalid request body.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Pending token invalid/expired, or code wrong.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Too many attempts.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/auth/passkey/login/begin": { + "post": { + "operationId": "auth_passkey_login_begin", + "summary": "Begin passkey (WebAuthn) login", + "description": "Starts a discoverable/usernameless passkey login. Returns the WebAuthn assertion options plus an opaque session handle to pass to /auth/passkey/login/finish.", + "tags": [ + "auth" + ], + "security": [], + "responses": { + "200": { + "description": "WebAuthn assertion options and the login session handle.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PasskeyLoginChallenge" + } + } + } + }, + "400": { + "description": "Invalid request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/auth/passkey/login/finish": { + "post": { + "operationId": "auth_passkey_login_finish", + "summary": "Finish passkey (WebAuthn) login", + "description": "Submits the WebAuthn assertion together with the login session handle. On success returns a full token pair.", + "tags": [ + "auth" + ], + "security": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PasskeyLoginFinishRequest" + } + } + } + }, + "responses": { + "200": { + "description": "A full token pair.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TokenPair" + } + } + } + }, + "400": { + "description": "Invalid request body.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Assertion rejected or session invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/auth/logout": { + "post": { + "operationId": "auth_logout", + "summary": "Log out the current session", + "description": "Revokes the session bound to the bearer access token. Requires a user session token (not an API key).", + "tags": [ + "auth" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "responses": { + "204": { + "description": "Session revoked." + }, + "401": { + "description": "Missing or invalid session token.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Not allowed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/auth/logout-all": { + "post": { + "operationId": "auth_logout_all", + "summary": "Log out all sessions", + "description": "Revokes every active session for the authenticated user. Requires a user session token (not an API key).", + "tags": [ + "auth" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "responses": { + "204": { + "description": "All sessions revoked." + }, + "401": { + "description": "Missing or invalid session token.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Not allowed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/auth/me": { + "get": { + "operationId": "auth_get_me", + "summary": "Get the authenticated user", + "description": "Returns the current user profile, including per-user folders, tags, and categories. Requires a user session token (not an API key).", + "tags": [ + "auth" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "The authenticated user.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "401": { + "description": "Missing or invalid session token.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Not allowed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "patch": { + "operationId": "auth_update_me", + "summary": "Update the authenticated user's profile", + "description": "Updates basic profile fields for the current user. Requires a user session token (not an API key).", + "tags": [ + "auth" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateProfileRequest" + } + } + } + }, + "responses": { + "200": { + "description": "The updated user.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "400": { + "description": "Invalid request body.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid session token.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Not allowed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/auth/me/password": { + "post": { + "operationId": "auth_change_password", + "summary": "Change password", + "description": "Changes the signed-in user's password (current + new). Requires a user session token (not an API key).", + "tags": [ + "auth" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChangePasswordRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Password changed." + }, + "400": { + "description": "Invalid request body or weak new password.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Current password wrong or session invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Not allowed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/auth/sessions": { + "get": { + "operationId": "auth_list_sessions", + "summary": "List active sessions", + "description": "Lists the authenticated user's active sessions, with the caller's current session flagged. Requires a user session token (not an API key). This endpoint returns a plain array, not a paginated wrapper.", + "tags": [ + "auth" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Active sessions.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionList" + } + } + } + }, + "401": { + "description": "Missing or invalid session token.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Not allowed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "delete": { + "operationId": "auth_revoke_other_sessions", + "summary": "Revoke all other sessions", + "description": "Ends every active session except the current one. Requires a user session token (not an API key).", + "tags": [ + "auth" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "responses": { + "204": { + "description": "Other sessions revoked." + }, + "401": { + "description": "Missing or invalid session token.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Not allowed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/auth/sessions/{id}": { + "delete": { + "operationId": "auth_revoke_session", + "summary": "Revoke a specific session", + "description": "Ends one of the user's sessions by id. Requires a user session token (not an API key).", + "tags": [ + "auth" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + }, + "description": "Session id to revoke." + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "responses": { + "204": { + "description": "Session revoked." + }, + "400": { + "description": "Invalid session id.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid session token.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Not allowed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Session not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/auth/2fa/status": { + "get": { + "operationId": "auth_2fa_status", + "summary": "Get 2FA status", + "description": "Reports whether the authenticated user has 2FA enabled. Requires a user session token (not an API key).", + "tags": [ + "auth" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "2FA status.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TwoFAStatus" + } + } + } + }, + "401": { + "description": "Missing or invalid session token.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Not allowed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/auth/2fa/enroll/start": { + "post": { + "operationId": "auth_2fa_enroll_start", + "summary": "Begin 2FA enrollment", + "description": "Generates a fresh TOTP secret and otpauth provisioning URI (shown once). Requires a user session token (not an API key).", + "tags": [ + "auth" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "responses": { + "200": { + "description": "The TOTP secret and otpauth URI.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TwoFAEnrollStart" + } + } + } + }, + "401": { + "description": "Missing or invalid session token.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Not allowed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/auth/2fa/enroll/confirm": { + "post": { + "operationId": "auth_2fa_enroll_confirm", + "summary": "Confirm 2FA enrollment", + "description": "Verifies a TOTP code, enables 2FA, and returns one-time recovery codes (shown once). Requires a user session token (not an API key).", + "tags": [ + "auth" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TwoFACodeRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Recovery codes.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TwoFARecoveryCodes" + } + } + } + }, + "400": { + "description": "Invalid request body.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Wrong code or session invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Not allowed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/auth/2fa": { + "delete": { + "operationId": "auth_2fa_disable", + "summary": "Disable 2FA", + "description": "Turns off 2FA for the user. Requires a current TOTP or recovery code in the body. Requires a user session token (not an API key).", + "tags": [ + "auth" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TwoFACodeRequest" + } + } + } + }, + "responses": { + "200": { + "description": "2FA disabled.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OkResponse" + } + } + } + }, + "400": { + "description": "Invalid request body.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Wrong code or session invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Not allowed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/auth/passkey/credentials": { + "get": { + "operationId": "auth_list_passkey_credentials", + "summary": "List passkey credentials", + "description": "Lists the authenticated user's registered passkeys. Requires a user session token (not an API key). Returns a plain array, not a paginated wrapper.", + "tags": [ + "auth" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Registered passkeys.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PasskeyCredentialList" + } + } + } + }, + "401": { + "description": "Missing or invalid session token.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Not allowed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/auth/passkey/credentials/{id}": { + "patch": { + "operationId": "auth_rename_passkey_credential", + "summary": "Rename a passkey", + "description": "Renames a registered passkey by id. Requires a user session token (not an API key).", + "tags": [ + "auth" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + }, + "description": "Passkey credential id." + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PasskeyRenameRequest" + } + } + } + }, + "responses": { + "200": { + "description": "The updated passkey.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PasskeyCredential" + } + } + } + }, + "400": { + "description": "Invalid request body or id.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid session token.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Not allowed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Passkey not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "delete": { + "operationId": "auth_delete_passkey_credential", + "summary": "Delete a passkey", + "description": "Removes a registered passkey by id. Requires a user session token (not an API key).", + "tags": [ + "auth" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + }, + "description": "Passkey credential id." + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "responses": { + "204": { + "description": "Passkey deleted." + }, + "400": { + "description": "Invalid id.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid session token.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Not allowed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Passkey not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/emails": { + "get": { + "operationId": "mailboxes_list", + "summary": "List mailboxes", + "description": "Returns the organization's connected mailboxes, newest first, with cursor pagination.", + "tags": [ + "mailboxes" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "q", + "in": "query", + "required": false, + "description": "Free-text search over mailbox address and name.", + "schema": { + "type": "string" + } + }, + { + "name": "tag", + "in": "query", + "required": false, + "description": "Tag id to filter by.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque pagination token from a previous pagination.next_cursor.", + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Page size. Default 50, max 100.", + "schema": { + "type": "integer", + "default": 50, + "maximum": 100, + "minimum": 1 + } + } + ], + "responses": { + "200": { + "description": "A page of mailboxes.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MailboxList" + } + } + } + }, + "400": { + "description": "Invalid cursor, limit, or tag.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Insufficient scope or permission.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/emails/{id}": { + "get": { + "operationId": "mailboxes_get", + "summary": "Get a mailbox", + "description": "Returns a single mailbox by id.", + "tags": [ + "mailboxes" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The mailbox (email account) id.", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "The mailbox.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Mailbox" + } + } + } + }, + "400": { + "description": "Invalid id.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Insufficient scope, permission, or mailbox not allowed for this key.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Mailbox not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "patch": { + "operationId": "mailboxes_update", + "summary": "Update a mailbox", + "description": "Updates mailbox settings. All fields are optional; only present fields are applied.", + "tags": [ + "mailboxes" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + }, + { + "name": "id", + "in": "path", + "required": true, + "description": "The mailbox id.", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MailboxUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "The updated mailbox.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Mailbox" + } + } + } + }, + "400": { + "description": "Invalid request body or id.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Insufficient scope, permission, or mailbox not allowed for this key.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Mailbox not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "delete": { + "operationId": "mailboxes_delete", + "summary": "Delete a mailbox", + "description": "Disconnects and deletes a mailbox. It is removed from all warmup pools.", + "tags": [ + "mailboxes" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + }, + { + "name": "id", + "in": "path", + "required": true, + "description": "The mailbox id.", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "Mailbox deleted." + }, + "400": { + "description": "Invalid id.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Insufficient scope, permission, or mailbox not allowed for this key.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Mailbox not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/emails/{id}/track": { + "patch": { + "operationId": "mailboxes_update_tracking_domain", + "summary": "Update the tracking domain", + "description": "Sets or clears the custom open/click tracking domain for a mailbox. Send an empty domain to clear it and fall back to the shared default.", + "tags": [ + "mailboxes" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + }, + { + "name": "id", + "in": "path", + "required": true, + "description": "The mailbox id.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "domain", + "in": "query", + "required": false, + "description": "The custom tracking subdomain (for example t.acme.com). Empty clears it.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The resolved tracking-domain state.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MailboxTrackingDomain" + } + } + } + }, + "400": { + "description": "Invalid id or domain.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Insufficient scope, permission, or mailbox not allowed for this key.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Mailbox not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/emails/{id}/warmup/start": { + "post": { + "operationId": "mailboxes_warmup_start", + "summary": "Start warmup", + "description": "Enables warmup for a mailbox. When resuming from a paused state it preserves ramp progress.", + "tags": [ + "mailboxes" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + }, + { + "name": "id", + "in": "path", + "required": true, + "description": "The mailbox id.", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "The updated mailbox.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Mailbox" + } + } + } + }, + "400": { + "description": "Invalid id.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Insufficient scope, permission, or mailbox not allowed for this key.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Mailbox not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/emails/{id}/warmup/pause": { + "post": { + "operationId": "mailboxes_warmup_pause", + "summary": "Pause warmup", + "description": "Pauses warmup without losing ramp progress. A later start continues from the same daily volume.", + "tags": [ + "mailboxes" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + }, + { + "name": "id", + "in": "path", + "required": true, + "description": "The mailbox id.", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "The updated mailbox. A paused mailbox has a non-null warmup_paused_at.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Mailbox" + } + } + } + }, + "400": { + "description": "Invalid id.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Insufficient scope, permission, or mailbox not allowed for this key.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Mailbox not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/emails/{id}/warmup/resume": { + "post": { + "operationId": "mailboxes_warmup_resume", + "summary": "Resume warmup", + "description": "Resumes a paused warmup, shifting the ramp anchor forward so progress continues where it left off.", + "tags": [ + "mailboxes" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + }, + { + "name": "id", + "in": "path", + "required": true, + "description": "The mailbox id.", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "The updated mailbox, with warmup_paused_at cleared.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Mailbox" + } + } + } + }, + "400": { + "description": "Invalid id.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Insufficient scope, permission, or mailbox not allowed for this key.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Mailbox not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/emails/{id}/warmup/stop": { + "post": { + "operationId": "mailboxes_warmup_stop", + "summary": "Stop warmup", + "description": "Disables warmup entirely and clears ramp progress. A later start begins a fresh ramp.", + "tags": [ + "mailboxes" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + }, + { + "name": "id", + "in": "path", + "required": true, + "description": "The mailbox id.", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "The updated mailbox, with warmup disabled.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Mailbox" + } + } + } + }, + "400": { + "description": "Invalid id.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Insufficient scope, permission, or mailbox not allowed for this key.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Mailbox not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/emails/{id}/auth-check": { + "get": { + "operationId": "mailboxes_auth_check", + "summary": "Check domain authentication", + "description": "Validates SPF, DKIM, and DMARC for the mailbox's sending domain on demand.", + "tags": [ + "mailboxes" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The mailbox id. The domain is derived from the mailbox address.", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "The authentication-check result.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MailboxAuthCheck" + } + } + } + }, + "400": { + "description": "Invalid id.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Insufficient scope, permission, or mailbox not allowed for this key.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Mailbox not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/emails/verify": { + "post": { + "operationId": "mailboxes_verify_address", + "summary": "Verify an email address", + "description": "Verifies a single email address on demand (syntax, MX, SMTP RCPT probe, catch-all detection). The address may be supplied in the JSON body or as the email query param; the body takes precedence.", + "tags": [ + "mailboxes" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + }, + { + "name": "email", + "in": "query", + "required": false, + "description": "The address to verify. Used when not supplied in the body.", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MailboxVerifyRequest" + } + } + } + }, + "responses": { + "200": { + "description": "The verification result.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MailboxVerifyResult" + } + } + } + }, + "400": { + "description": "Missing or empty address.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Insufficient scope or permission.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/emails/{id}/warmup/ban-status": { + "get": { + "operationId": "mailboxes_warmup_ban_status", + "summary": "Get warmup ban status", + "description": "Returns whether a mailbox is blocked from the shared warmup pool, why, and whether the owner can appeal.", + "tags": [ + "mailboxes" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The mailbox id.", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "The warmup ban status.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MailboxWarmupBanStatus" + } + } + } + }, + "400": { + "description": "Invalid id.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Insufficient scope, permission, or mailbox not allowed for this key.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Mailbox not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/emails/{id}/warmup/appeal": { + "post": { + "operationId": "mailboxes_warmup_appeal", + "summary": "Submit a warmup appeal", + "description": "Lets the mailbox owner appeal a warmup ban with a reason.", + "tags": [ + "mailboxes" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + }, + { + "name": "id", + "in": "path", + "required": true, + "description": "The mailbox id.", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MailboxWarmupAppealRequest" + } + } + } + }, + "responses": { + "200": { + "description": "The created appeal id.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MailboxWarmupAppealResult" + } + } + } + }, + "400": { + "description": "Invalid id or request body.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Insufficient scope, permission, or mailbox not allowed for this key.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Mailbox not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/emails/{id}/send": { + "post": { + "operationId": "mailboxes_send", + "summary": "Send from a mailbox", + "description": "Sends a one-off email from a specific mailbox, scheduled and dispatched through the mailbox's assigned worker. Requires an active organization.", + "tags": [ + "mailboxes" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + }, + { + "name": "id", + "in": "path", + "required": true, + "description": "The sending mailbox id.", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MailboxSendRequest" + } + } + } + }, + "responses": { + "200": { + "description": "The queued send task.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MailboxSendResult" + } + } + } + }, + "400": { + "description": "Invalid id, request body, or no active organization.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Insufficient scope, permission, or mailbox not allowed for this key.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Mailbox not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/campaigns": { + "get": { + "operationId": "campaigns_list", + "summary": "List campaigns", + "description": "Search and page through the organization's campaigns. Scope READ_CAMPAIGNS, org permission view_campaigns.", + "tags": [ + "campaigns" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "q", + "in": "query", + "required": false, + "description": "Free-text filter on campaign name.", + "schema": { + "type": "string" + } + }, + { + "name": "folder", + "in": "query", + "required": false, + "description": "Restrict to a single folder id.", + "schema": { + "type": "string" + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque pagination cursor from the previous page.", + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Page size (default 50, max 100).", + "schema": { + "type": "integer", + "default": 50, + "maximum": 100, + "minimum": 1 + } + } + ], + "responses": { + "200": { + "description": "A page of campaigns.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CampaignList" + } + } + } + }, + "400": { + "description": "Invalid cursor or limit.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "post": { + "operationId": "campaigns_create", + "summary": "Create a campaign", + "description": "Create a campaign. Only name is required. Scope WRITE_CAMPAIGNS, org permission manage_campaigns.", + "tags": [ + "campaigns" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CampaignCreate" + } + } + } + }, + "responses": { + "201": { + "description": "The created campaign.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Campaign" + } + } + } + }, + "400": { + "description": "Validation error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/campaigns/{id}": { + "get": { + "operationId": "campaigns_get", + "summary": "Get a campaign", + "description": "Fetch a single campaign by id. Scope READ_CAMPAIGNS, org permission view_campaigns.", + "tags": [ + "campaigns" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "The campaign.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Campaign" + } + } + } + }, + "400": { + "description": "Invalid id.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "patch": { + "operationId": "campaigns_update", + "summary": "Update a campaign", + "description": "Patch any subset of campaign fields. Omitted fields are unchanged. Scope WRITE_CAMPAIGNS, org permission manage_campaigns.", + "tags": [ + "campaigns" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CampaignUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "The updated campaign.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Campaign" + } + } + } + }, + "400": { + "description": "Validation error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "delete": { + "operationId": "campaigns_delete", + "summary": "Delete a campaign", + "description": "Permanently delete a campaign. Scope WRITE_CAMPAIGNS, org permission manage_campaigns.", + "tags": [ + "campaigns" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "responses": { + "204": { + "description": "Deleted." + }, + "400": { + "description": "Invalid id.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/campaigns/{id}/advanced": { + "get": { + "operationId": "campaigns_get_advanced", + "summary": "Get advanced settings", + "description": "Return the campaign's advanced outreach overrides. Scope READ_CAMPAIGNS, org permission view_campaigns.", + "tags": [ + "campaigns" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "The advanced settings.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CampaignAdvancedSettings" + } + } + } + }, + "400": { + "description": "Invalid id.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "patch": { + "operationId": "campaigns_update_advanced", + "summary": "Update advanced settings", + "description": "Replace the campaign's advanced overrides. Scope WRITE_CAMPAIGNS, org permission manage_settings.", + "tags": [ + "campaigns" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CampaignAdvancedUpdate" + } + } + } + }, + "responses": { + "204": { + "description": "Updated." + }, + "400": { + "description": "Validation error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/campaigns/{id}/ab-variants": { + "get": { + "operationId": "campaigns_list_ab_variants", + "summary": "List A/B variants", + "description": "List the campaign's A/B variants. Scope READ_CAMPAIGNS, org permission view_campaigns.", + "tags": [ + "campaigns" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "The variants.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CampaignABVariantList" + } + } + } + }, + "401": { + "description": "Unauthorized.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "post": { + "operationId": "campaigns_create_ab_variant", + "summary": "Create an A/B variant", + "description": "Add a variant to the campaign (or one step via step_id). Scope WRITE_CAMPAIGNS, org permission manage_settings.", + "tags": [ + "campaigns" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CampaignABVariantCreate" + } + } + } + }, + "responses": { + "201": { + "description": "The created variant.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CampaignABVariant" + } + } + } + }, + "400": { + "description": "Validation error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/campaigns/{id}/ab-variants/{variantId}": { + "patch": { + "operationId": "campaigns_update_ab_variant", + "summary": "Update an A/B variant", + "description": "Patch a variant. Omitted fields are unchanged. Scope WRITE_CAMPAIGNS, org permission manage_settings.", + "tags": [ + "campaigns" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "variantId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CampaignABVariantUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "The updated variant.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CampaignABVariant" + } + } + } + }, + "400": { + "description": "Validation error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "delete": { + "operationId": "campaigns_delete_ab_variant", + "summary": "Delete an A/B variant", + "description": "Remove a variant. Scope WRITE_CAMPAIGNS, org permission manage_settings.", + "tags": [ + "campaigns" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "variantId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "responses": { + "204": { + "description": "Deleted." + }, + "401": { + "description": "Unauthorized.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/campaigns/{id}/ab-analysis": { + "get": { + "operationId": "campaigns_get_ab_analysis", + "summary": "Get A/B analysis", + "description": "Return per-variant engagement stats and the computed winner. Scope READ_ANALYTICS, org permission view_analytics.", + "tags": [ + "campaigns" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "The A/B analysis.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ABWinnerAnalysis" + } + } + } + }, + "401": { + "description": "Unauthorized.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/campaigns/{id}/attachments": { + "get": { + "operationId": "campaigns_list_attachments", + "summary": "List attachments", + "description": "List the campaign's attachments, each with a short-lived presigned download url. Scope READ_CAMPAIGNS, org permission view_campaigns.", + "tags": [ + "campaigns" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "The attachments.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CampaignAttachmentList" + } + } + } + }, + "401": { + "description": "Unauthorized.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "post": { + "operationId": "campaigns_upload_attachment", + "summary": "Upload an attachment", + "description": "Upload a file (max 15 MB) to attach to the campaign or one step. Multipart form data. Scope WRITE_CAMPAIGNS, org permission manage_campaigns.", + "tags": [ + "campaigns" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "required": [ + "file" + ], + "properties": { + "file": { + "type": "string", + "format": "binary", + "description": "The file to upload (max 15 MB). Executable and script types are rejected." + }, + "step_id": { + "type": "string", + "format": "uuid", + "description": "Scope the attachment to one sequence step." + } + } + } + } + } + }, + "responses": { + "201": { + "description": "The created attachment.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CampaignAttachment" + } + } + } + }, + "400": { + "description": "Validation error or rejected file type.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/campaigns/{id}/attachments/{attachmentId}": { + "delete": { + "operationId": "campaigns_delete_attachment", + "summary": "Delete an attachment", + "description": "Delete a campaign attachment and its stored object. Scope WRITE_CAMPAIGNS, org permission manage_campaigns.", + "tags": [ + "campaigns" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "attachmentId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "responses": { + "204": { + "description": "Deleted." + }, + "401": { + "description": "Unauthorized.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/campaigns/{id}/preflight": { + "post": { + "operationId": "campaigns_run_preflight", + "summary": "Run preflight", + "description": "Run the campaign's preflight validation checks and return a scored report. No mail is sent. Scope SEND_CAMPAIGNS, org permission send_campaigns.", + "tags": [ + "campaigns" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "responses": { + "200": { + "description": "The preflight report.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PreflightReport" + } + } + } + }, + "401": { + "description": "Unauthorized.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/campaigns/{id}/test-email": { + "post": { + "operationId": "campaigns_send_test_email", + "summary": "Send a test email", + "description": "Send a one-off preview of a sequence step to a recipient through a chosen mailbox. Scope SEND_CAMPAIGNS, org permission send_campaigns.", + "tags": [ + "campaigns" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CampaignTestEmailRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Test email sent.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CampaignTestEmailResult" + } + } + } + }, + "400": { + "description": "Validation error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/campaigns/{id}/start": { + "post": { + "operationId": "campaigns_start", + "summary": "Start a campaign", + "description": "Activate the campaign so it begins sending real mail. Scope SEND_CAMPAIGNS, org permission send_campaigns.", + "tags": [ + "campaigns" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "responses": { + "200": { + "description": "Started.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CampaignStartResult" + } + } + } + }, + "400": { + "description": "Campaign not in a startable state.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/campaigns/{id}/stop": { + "post": { + "operationId": "campaigns_stop", + "summary": "Stop a campaign", + "description": "Pause an active campaign. Scope SEND_CAMPAIGNS, org permission send_campaigns.", + "tags": [ + "campaigns" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "responses": { + "200": { + "description": "Stopped.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CampaignStopResult" + } + } + } + }, + "400": { + "description": "Campaign not in a stoppable state.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/campaigns/{id}/logs": { + "get": { + "operationId": "campaigns_list_logs", + "summary": "Get campaign logs", + "description": "Page through the campaign's activity log. Scope READ_CAMPAIGNS, org permission view_campaigns.", + "tags": [ + "campaigns" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque pagination cursor from the previous page.", + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Page size, 1 to 100 (default 50).", + "schema": { + "type": "integer", + "default": 50, + "maximum": 100, + "minimum": 1 + } + } + ], + "responses": { + "200": { + "description": "A page of log entries.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CampaignLogList" + } + } + } + }, + "400": { + "description": "Invalid cursor or limit.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/campaigns/{id}/senders": { + "get": { + "operationId": "campaigns_list_senders", + "summary": "List campaign senders", + "description": "Return the campaign's explicit sender pool. Scope READ_CAMPAIGNS, org permission view_campaigns.", + "tags": [ + "campaigns" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "The sender pool.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CampaignSenderList" + } + } + } + }, + "401": { + "description": "Unauthorized.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "put": { + "operationId": "campaigns_replace_senders", + "summary": "Replace senders", + "description": "Atomically replace the campaign's explicit sender pool. Scope WRITE_CAMPAIGNS, org permission manage_campaigns.", + "tags": [ + "campaigns" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CampaignSendersReplace" + } + } + } + }, + "responses": { + "200": { + "description": "The resulting sender pool.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CampaignSenderList" + } + } + } + }, + "400": { + "description": "Validation error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/campaigns/{id}/tracking-domain/verify": { + "post": { + "operationId": "campaigns_verify_tracking_domain", + "summary": "Verify campaign tracking domain", + "description": "Resolve the campaign-scoped tracking domain's CNAME and flip tracking_domain_verified on success. Scope WRITE_CAMPAIGNS, org permission manage_campaigns.", + "tags": [ + "campaigns" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "responses": { + "200": { + "description": "The tracking-domain status.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TrackingDomainStatus" + } + } + } + }, + "400": { + "description": "Verification failed or no domain configured.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/campaigns/{id}/steps": { + "get": { + "operationId": "campaigns_list_steps", + "summary": "List steps", + "description": "Return the campaign's sequence steps in order. Scope READ_CAMPAIGNS, org permission view_campaigns.", + "tags": [ + "campaigns" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "The sequence steps (bare array, no envelope).", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CampaignStep" + } + } + } + } + }, + "401": { + "description": "Unauthorized.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "post": { + "operationId": "campaigns_create_step", + "summary": "Create a step", + "description": "Append a new empty sequence step created with defaults, then edited with PATCH. No request body. Scope WRITE_CAMPAIGNS, org permission manage_campaigns.", + "tags": [ + "campaigns" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "responses": { + "201": { + "description": "The created step.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CampaignStep" + } + } + } + }, + "401": { + "description": "Unauthorized.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/campaigns/{id}/steps/{sid}": { + "patch": { + "operationId": "campaigns_update_step", + "summary": "Update a step", + "description": "Patch a sequence step: copy, spacing, node kind, branching tree, or action config. Omitted fields are unchanged. Scope WRITE_CAMPAIGNS, org permission manage_campaigns.", + "tags": [ + "campaigns" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "sid", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CampaignStepUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "The updated step.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CampaignStep" + } + } + } + }, + "400": { + "description": "Validation error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "delete": { + "operationId": "campaigns_delete_step", + "summary": "Delete a step", + "description": "Delete a sequence step. Scope WRITE_CAMPAIGNS, org permission manage_campaigns.", + "tags": [ + "campaigns" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "sid", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "responses": { + "200": { + "description": "Deleted (empty body)." + }, + "401": { + "description": "Unauthorized.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/campaign-template-preview": { + "post": { + "operationId": "campaigns_template_preview", + "summary": "Preview a template", + "description": "Render subject and body templates against a sample (or supplied) contact and report parse errors plus unresolved tokens. No side effects. Scope READ_CAMPAIGNS, org permission view_campaigns.", + "tags": [ + "campaigns" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TemplatePreviewRequest" + } + } + } + }, + "responses": { + "200": { + "description": "The rendered preview.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TemplatePreview" + } + } + } + }, + "400": { + "description": "Validation error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/generation/write": { + "post": { + "operationId": "campaigns_generate_writing", + "summary": "Generate copy with the writing assistant", + "description": "Generate outreach copy with the AI writing assistant. Gated to paid and free-trial orgs; consumes one AI credit (refunded on provider failure). Scope WRITE_CAMPAIGNS, org permission manage_campaigns.", + "tags": [ + "campaigns" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GenerationWriteRequest" + } + } + } + }, + "responses": { + "200": { + "description": "The generated copy.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GenerationWriteResult" + } + } + } + }, + "400": { + "description": "Validation error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "402": { + "description": "Out of AI credits (code insufficient_credits).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/contacts/search": { + "post": { + "tags": [ + "contacts" + ], + "operationId": "contacts_search", + "summary": "Search contacts", + "description": "Faceted, org-scoped contact search. Filters live in the body; pagination is via query params. Scope `READ_CONTACTS`.", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque pagination cursor from the previous page's pagination.next_cursor.", + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Page size (numeric string). Default 50, max 100.", + "schema": { + "type": "string" + } + }, + { + "name": "category", + "in": "query", + "required": false, + "description": "Convenience filter for a single category ID.", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "required": false, + "description": "All filters optional; an empty body matches every contact in the organization.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactSearchRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Matching contacts.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactList" + } + } + } + }, + "400": { + "description": "Invalid body, cursor, or limit.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid API key.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "API key lacks READ_CONTACTS or caller lacks view_contacts.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/contacts": { + "post": { + "tags": [ + "contacts" + ], + "operationId": "contacts_create", + "summary": "Create contacts", + "description": "Creates one or more contacts. The body is a JSON array, so a single create is an array of length one. Scope `WRITE_CONTACTS`.", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/components/schemas/ContactCreate" + } + } + } + } + }, + "responses": { + "200": { + "description": "The created contacts as a bare array.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Contact" + } + } + } + } + }, + "400": { + "description": "Empty array or too many contacts.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid API key.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "API key lacks WRITE_CONTACTS or caller lacks manage_contacts.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "patch": { + "tags": [ + "contacts" + ], + "operationId": "contacts_bulk_update", + "summary": "Bulk update contacts", + "description": "Applies one set of edits across up to 1000 contacts: add/remove campaigns and categories, custom-field operations, and subscription. Scope `BULK_CONTACTS`.", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactBulkUpdateRequest" + } + } + } + }, + "responses": { + "200": { + "description": "The updated contacts as a bare array.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Contact" + } + } + } + } + }, + "400": { + "description": "No contacts provided or more than 1000.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid API key.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "API key lacks BULK_CONTACTS or caller lacks manage_contacts.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "delete": { + "tags": [ + "contacts" + ], + "operationId": "contacts_bulk_delete", + "summary": "Bulk delete contacts", + "description": "Deletes up to 1000 contacts by ID. Scope `BULK_CONTACTS`.", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": true, + "description": "A JSON array of contact ID strings (1 to 1000).", + "content": { + "application/json": { + "schema": { + "type": "array", + "minItems": 1, + "maxItems": 1000, + "items": { + "type": "string", + "format": "uuid" + } + } + } + } + }, + "responses": { + "204": { + "description": "Contacts deleted." + }, + "400": { + "description": "Empty array or more than 1000 IDs.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid API key.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "API key lacks BULK_CONTACTS or caller lacks manage_contacts.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/contacts/export": { + "post": { + "tags": [ + "contacts" + ], + "operationId": "contacts_export", + "summary": "Export contacts", + "description": "Exports contacts to CSV, XLSX, or JSON. The response is the file itself, not JSON. Capped at 50,000 rows. Scope `READ_CONTACTS`.", + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactExportRequest" + } + } + } + }, + "responses": { + "200": { + "description": "The export file as an attachment.", + "headers": { + "Content-Disposition": { + "description": "attachment; filename=\"...\".", + "schema": { + "type": "string" + } + }, + "X-Total-Rows": { + "description": "Number of rows written.", + "schema": { + "type": "integer" + } + } + }, + "content": { + "text/csv": { + "schema": { + "type": "string", + "format": "binary" + } + }, + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": { + "schema": { + "type": "string", + "format": "binary" + } + }, + "application/json": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "description": "Invalid format, scope, or filters.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid API key.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "API key lacks READ_CONTACTS or caller lacks view_contacts.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/contacts/import/preview": { + "post": { + "tags": [ + "contacts" + ], + "operationId": "contacts_import_preview", + "summary": "Preview an import", + "description": "Uploads a CSV or XLSX file and returns detected columns plus a sample so the client can build a column mapping. Uploads capped at 50 MB. Scope `WRITE_CONTACTS`.", + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "required": [ + "file" + ], + "properties": { + "file": { + "type": "string", + "format": "binary", + "description": "The CSV/XLSX upload." + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Detected columns, sample rows, and a suggested mapping.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactImportPreview" + } + } + } + }, + "400": { + "description": "Missing file, unsupported format, or over the size cap.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid API key.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "API key lacks WRITE_CONTACTS or caller lacks manage_contacts.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/contacts/import/commit": { + "post": { + "tags": [ + "contacts" + ], + "operationId": "contacts_import_commit", + "summary": "Commit an import", + "description": "Re-uploads the file with a mapping and dedup options, applies it, and returns per-row results. Imports capped at 50,000 rows. Scope `BULK_CONTACTS`.", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "required": [ + "file", + "options" + ], + "properties": { + "file": { + "type": "string", + "format": "binary", + "description": "The CSV/XLSX upload (max 50 MB)." + }, + "options": { + "type": "string", + "description": "JSON-encoded ContactImportCommitOptions as a string." + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Per-row import results.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactImportResult" + } + } + } + }, + "400": { + "description": "Missing file/options, invalid mapping or dedup, or over a cap.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid API key.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "API key lacks BULK_CONTACTS or caller lacks manage_contacts.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/contacts/lookup": { + "get": { + "tags": [ + "contacts" + ], + "operationId": "contacts_lookup", + "summary": "Look up a contact by email", + "description": "Resolves a sender address to a contact. Returns 200 with {\"contact\": null} when nothing matches. A display-name wrapped address (`Name `) is accepted and unwrapped. Scope `READ_CONTACTS`.", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "email", + "in": "query", + "required": true, + "description": "The email address to resolve.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The resolved contact, or null.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactLookupResult" + } + } + } + }, + "400": { + "description": "Missing or malformed email.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid API key.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "API key lacks READ_CONTACTS or caller lacks view_contacts.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/contacts/{id}": { + "get": { + "tags": [ + "contacts" + ], + "operationId": "contacts_get", + "summary": "Get a contact", + "description": "Returns the hydrated contact 360 payload: the contact plus an engagement summary and, when present, suppression state. Scope `READ_CONTACTS`.", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "The hydrated contact.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactDetail" + } + } + } + }, + "400": { + "description": "Invalid contact ID.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid API key.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "API key lacks READ_CONTACTS or caller lacks view_contacts.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Contact not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "patch": { + "tags": [ + "contacts" + ], + "operationId": "contacts_update", + "summary": "Update a contact", + "description": "Partially updates a single contact; only the fields present change. Category lists can be set wholesale or adjusted with diff-style add/remove. Scope `WRITE_CONTACTS`.", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "The updated contact.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Contact" + } + } + } + }, + "400": { + "description": "Invalid contact ID or body.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid API key.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "API key lacks WRITE_CONTACTS or caller lacks manage_contacts.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Contact not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "delete": { + "tags": [ + "contacts" + ], + "operationId": "contacts_delete", + "summary": "Delete a contact", + "description": "Deletes a single contact. Scope `WRITE_CONTACTS`.", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "responses": { + "204": { + "description": "Contact deleted." + }, + "400": { + "description": "Invalid contact ID.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid API key.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "API key lacks WRITE_CONTACTS or caller lacks manage_contacts.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Contact not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/contacts/{id}/emails": { + "get": { + "tags": [ + "contacts" + ], + "operationId": "contacts_emails_list", + "summary": "List emails sent to a contact", + "description": "One row per email sent (or attempted) to the contact, newest first. Keyset paginated on (created_at, task_id); pass both before_at and before_id together. Scope `READ_CONTACTS`.", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Page size, 1 to 200 (default 50).", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 200, + "default": 50 + } + }, + { + "name": "before_at", + "in": "query", + "required": false, + "description": "created_at of the last row from the previous page (RFC 3339 nano).", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "before_id", + "in": "query", + "required": false, + "description": "task_id of the last row from the previous page.", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Sent emails.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactSentEmailList" + } + } + } + }, + "400": { + "description": "Invalid contact ID or limit.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid API key.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "API key lacks READ_CONTACTS or caller lacks view_contacts.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Contact not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/contacts/{id}/timeline": { + "get": { + "tags": [ + "contacts" + ], + "operationId": "contacts_timeline_list", + "summary": "List a contact's timeline", + "description": "Merged activity feed: sends, opens, clicks, replies, bounces, deliverability/suppression events, notes, and meeting bookings. Requires a selected organization. Paginate via the `before` timestamp. Scope `READ_CONTACTS`.", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Page size, 1 to 200 (default 50).", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 200, + "default": 50 + } + }, + { + "name": "before", + "in": "query", + "required": false, + "description": "The `at` timestamp of the oldest event from the previous page (RFC 3339 nano).", + "schema": { + "type": "string", + "format": "date-time" + } + } + ], + "responses": { + "200": { + "description": "Timeline events with a has_more flag.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactTimelineResult" + } + } + } + }, + "400": { + "description": "Invalid contact ID, or no organization selected.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid API key.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "API key lacks READ_CONTACTS or caller lacks view_contacts.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Contact not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/contacts/{id}/activities": { + "get": { + "tags": [ + "contacts" + ], + "operationId": "contacts_activities_list", + "summary": "List a contact's activities", + "description": "Structured CRM activity log for a contact. Requires a selected organization. Scope `READ_CONTACTS`.", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Page size, 1 to 100 (default 50).", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque cursor from the previous page's pagination.next_cursor.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "CRM activity log.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactActivityList" + } + } + } + }, + "400": { + "description": "Invalid contact ID, cursor, or no organization selected.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid API key.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "API key lacks READ_CONTACTS or caller lacks view_contacts.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Contact not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/contacts/{id}/notes": { + "get": { + "tags": [ + "contacts" + ], + "operationId": "contacts_notes_list", + "summary": "List a contact's notes", + "description": "CRM notes attached to a contact, newest first. Requires a selected organization. Scope `READ_CONTACTS`.", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Page size, 1 to 100 (default 50).", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque cursor from the previous page's pagination.next_cursor.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Contact notes.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactNoteList" + } + } + } + }, + "400": { + "description": "Invalid contact ID, cursor, or no organization selected.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid API key.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "API key lacks READ_CONTACTS or caller lacks view_contacts.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Contact not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "post": { + "tags": [ + "contacts" + ], + "operationId": "contacts_notes_create", + "summary": "Create a contact note", + "description": "Adds a note to a contact. Requires a selected organization. Scope `WRITE_CONTACTS`.", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactNoteCreate" + } + } + } + }, + "responses": { + "201": { + "description": "The created note.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactNote" + } + } + } + }, + "400": { + "description": "Invalid contact ID, missing/too-long content, or no organization selected.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid API key.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "API key lacks WRITE_CONTACTS or caller lacks manage_contacts.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Contact not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/contacts/{id}/notes/{noteId}": { + "patch": { + "tags": [ + "contacts" + ], + "operationId": "contacts_notes_update", + "summary": "Update a contact note", + "description": "Edits a note's content. Requires a selected organization. Scope `WRITE_CONTACTS`.", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "noteId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactNoteUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "The updated note.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactNote" + } + } + } + }, + "400": { + "description": "Invalid IDs, body, or no organization selected.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid API key.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "API key lacks WRITE_CONTACTS or caller lacks manage_contacts.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Contact or note not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "delete": { + "tags": [ + "contacts" + ], + "operationId": "contacts_notes_delete", + "summary": "Delete a contact note", + "description": "Deletes a note. Requires a selected organization. Scope `WRITE_CONTACTS`.", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "noteId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "responses": { + "204": { + "description": "Note deleted." + }, + "400": { + "description": "Invalid IDs or no organization selected.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid API key.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "API key lacks WRITE_CONTACTS or caller lacks manage_contacts.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Contact or note not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/contacts/{id}/deals": { + "get": { + "tags": [ + "contacts" + ], + "operationId": "contacts_deals_list", + "summary": "List a contact's deals", + "description": "CRM deals associated with a contact, returned as a bare JSON array. Scope `READ_CRM`.", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "The contact's deals.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Deal" + } + } + } + } + }, + "400": { + "description": "Invalid contact ID.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid API key.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "API key lacks READ_CRM or caller lacks view_contacts.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Contact not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/unibox": { + "get": { + "operationId": "unibox_list", + "summary": "List incoming mail", + "description": "Org-wide inbox list, collapsed to one row per thread (newest message), with filtering and cursor pagination. Excludes snoozed threads unless `snoozed=true`.", + "tags": [ + "unibox" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque pagination cursor from a previous response.", + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Page size, clamped to the server min/max.", + "schema": { + "type": "integer", + "default": 50, + "maximum": 100 + } + }, + { + "name": "from", + "in": "query", + "required": false, + "description": "Filter by sender address (substring).", + "schema": { + "type": "string" + } + }, + { + "name": "subject", + "in": "query", + "required": false, + "description": "Filter by subject (substring).", + "schema": { + "type": "string" + } + }, + { + "name": "unseen", + "in": "query", + "required": false, + "description": "`true` returns only threads with unread messages.", + "schema": { + "type": "boolean" + } + }, + { + "name": "awaiting_reply", + "in": "query", + "required": false, + "description": "`true` returns only threads whose latest message was sent by you.", + "schema": { + "type": "boolean" + } + }, + { + "name": "snoozed", + "in": "query", + "required": false, + "description": "`true` returns only snoozed threads. Omit to exclude snoozed threads.", + "schema": { + "type": "string" + } + }, + { + "name": "since", + "in": "query", + "required": false, + "description": "Lower bound on date, `YYYY-MM-DD`.", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "name": "until", + "in": "query", + "required": false, + "description": "Upper bound on date, `YYYY-MM-DD`.", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "name": "email_id", + "in": "query", + "required": false, + "description": "Restrict to a single mailbox by UUID.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "email_ids", + "in": "query", + "required": false, + "description": "Comma-separated mailbox UUIDs. A thread matches if it landed in any of them.", + "schema": { + "type": "string" + } + }, + { + "name": "category_ids", + "in": "query", + "required": false, + "description": "Comma-separated conversation-label UUIDs. A thread matches if it carries any of them.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Inbox list page.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UniboxThreadList" + } + } + } + }, + "400": { + "description": "Invalid cursor, limit, or no organization selected.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Missing scope/permission, or organization lacks unified-inbox access.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/unibox/count": { + "get": { + "operationId": "unibox_count", + "summary": "Get unread count", + "description": "Org-wide unread message count, optionally scoped to one mailbox.", + "tags": [ + "unibox" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "email_id", + "in": "query", + "required": false, + "description": "Optional mailbox UUID to count unread for a single mailbox.", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Unread count.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UniboxCount" + } + } + } + }, + "400": { + "description": "No organization selected.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Missing scope/permission, or organization lacks unified-inbox access.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/unibox/overview": { + "get": { + "operationId": "unibox_overview", + "summary": "Get inbox overview", + "description": "Rolls up scope-rail and metric-strip counts (unread, today, week, snoozed, awaiting-reply, pending-scheduled) plus per-mailbox, per-tag, and per-conversation-label breakdowns.", + "tags": [ + "unibox" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Inbox overview rollup.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UniboxOverview" + } + } + } + }, + "400": { + "description": "No organization selected.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Missing scope/permission, or organization lacks unified-inbox access.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/unibox/thread": { + "get": { + "operationId": "unibox_get_thread", + "summary": "Get a thread", + "description": "Every message in a single conversation, with cursor pagination. With no `email_id` the thread is read across every mailbox in the organization.", + "tags": [ + "unibox" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "thread_id", + "in": "query", + "required": true, + "description": "The thread to read. Also accepted as `id`.", + "schema": { + "type": "string" + } + }, + { + "name": "email_id", + "in": "query", + "required": false, + "description": "Optional mailbox UUID to scope the thread to one mailbox. Also accepted as `email`.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque pagination cursor.", + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Page size. Out-of-range values return 400.", + "schema": { + "type": "integer", + "default": 50, + "maximum": 100 + } + } + ], + "responses": { + "200": { + "description": "Thread messages page.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UniboxMessageList" + } + } + } + }, + "400": { + "description": "Missing `thread_id`, invalid cursor/limit, or no organization selected.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Missing scope/permission, or organization lacks unified-inbox access.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/unibox/thread/labels": { + "get": { + "operationId": "unibox_get_thread_labels", + "summary": "Get thread labels", + "description": "Conversation labels (your categories) attached to a thread.", + "tags": [ + "unibox" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "thread_id", + "in": "query", + "required": true, + "description": "The thread to read labels for. Also accepted as `id`.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Label set wrapped in a `data` array.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UniboxLabelList" + } + } + } + }, + "400": { + "description": "Missing `thread_id` or no organization selected.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Missing scope/permission, or organization lacks unified-inbox access.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "put": { + "operationId": "unibox_set_thread_labels", + "summary": "Set thread labels", + "description": "Replaces the full conversation-label set on a thread. `category_ids` is the desired set, so the call is idempotent and retries are naturally safe.", + "tags": [ + "unibox" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UniboxSetThreadLabelsRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Resulting label set wrapped in a `data` array.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UniboxLabelList" + } + } + } + }, + "400": { + "description": "Missing `thread_id` or no organization selected.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Missing scope/permission, or organization lacks unified-inbox access.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/unibox/seen": { + "patch": { + "operationId": "unibox_mark_seen", + "summary": "Mark messages seen", + "description": "Marks a batch of messages as read or unread, org-wide. Up to 500 ids per call.", + "tags": [ + "unibox" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UniboxMarkSeenRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Echoes the request back.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UniboxMarkSeenRequest" + } + } + } + }, + "400": { + "description": "Invalid body, more than 500 ids, or no organization selected.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Missing scope/permission, or organization lacks unified-inbox access.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/unibox/reply": { + "post": { + "operationId": "unibox_reply", + "summary": "Reply from the inbox", + "description": "Sends or schedules a reply from one of your mailboxes, routed through the per-mailbox scheduler according to `send_mode`. Requires an active organization.", + "tags": [ + "unibox" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UniboxReplyRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Reply queued or scheduled.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UniboxReplyResult" + } + } + } + }, + "400": { + "description": "Invalid body, invalid mailbox UUID, missing future `scheduled_at`, or no organization selected.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Missing scope/permission, or organization lacks unified-inbox access.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/unibox/snoozes": { + "get": { + "operationId": "unibox_list_snoozes", + "summary": "List active snoozes", + "description": "Your active thread snoozes.", + "tags": [ + "unibox" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Active snoozes wrapped in a `data` array.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UniboxSnoozeList" + } + } + } + }, + "400": { + "description": "No organization selected.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Missing scope/permission, or organization lacks unified-inbox access.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/unibox/snooze": { + "post": { + "operationId": "unibox_snooze", + "summary": "Snooze a thread", + "description": "Hides a thread from your inbox until `snoozed_until` passes. Upsert semantics: a second call on the same thread updates the time in place.", + "tags": [ + "unibox" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UniboxSnoozeRequest" + } + } + } + }, + "responses": { + "200": { + "description": "The created or updated snooze.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UniboxSnooze" + } + } + } + }, + "400": { + "description": "Invalid body or no organization selected.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Missing scope/permission, or organization lacks unified-inbox access.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "delete": { + "operationId": "unibox_unsnooze", + "summary": "Unsnooze a thread", + "description": "Un-snoozes a thread immediately. Idempotent: deleting a snooze that does not exist still succeeds with 204.", + "tags": [ + "unibox" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "thread_id", + "in": "query", + "required": true, + "description": "The thread to un-snooze.", + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "responses": { + "204": { + "description": "Snooze removed (or already absent). Empty body." + }, + "400": { + "description": "Missing `thread_id` or no organization selected.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Missing scope/permission, or organization lacks unified-inbox access.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/unibox/scheduled": { + "get": { + "operationId": "unibox_list_scheduled", + "summary": "List scheduled sends", + "description": "Outbound emails you have queued but not yet sent. Pass `thread_id` to scope to a single conversation; the response shape is identical either way.", + "tags": [ + "unibox" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "thread_id", + "in": "query", + "required": false, + "description": "Restrict to scheduled sends queued into one thread.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Queued message previews wrapped in a `data` array.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UniboxScheduledList" + } + } + } + }, + "400": { + "description": "No organization selected.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Missing scope/permission, or organization lacks unified-inbox access.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/unibox/scheduled/{task_id}": { + "delete": { + "operationId": "unibox_cancel_scheduled", + "summary": "Cancel a scheduled send", + "description": "Cancels a pending scheduled send before it fires. The queued task is marked cancelled and short-circuits to a no-op when its run time arrives.", + "tags": [ + "unibox" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "task_id", + "in": "path", + "required": true, + "description": "UUID of the scheduled task to cancel.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "responses": { + "204": { + "description": "Scheduled send cancelled. Empty body." + }, + "400": { + "description": "Invalid task id or no organization selected.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Missing scope/permission, or organization lacks unified-inbox access.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Scheduled send not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/unibox/{id}": { + "get": { + "operationId": "unibox_get", + "summary": "Get a message by id", + "description": "A single message by its UUID, including the full envelope and body.", + "tags": [ + "unibox" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "UUID of the message.", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "The message.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UniboxEmail" + } + } + } + }, + "400": { + "description": "Invalid message id or no organization selected.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Missing scope/permission, or organization lacks unified-inbox access.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Message not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/crm/pipelines": { + "get": { + "operationId": "crm_list_pipelines", + "summary": "List pipelines", + "description": "Return every pipeline in the organization, each with its ordered stages. Returns a bare array, not a list envelope.", + "tags": [ + "crm" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Array of pipelines (each with its stages).", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Pipeline" + } + } + } + } + }, + "400": { + "description": "Invalid request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Missing required scope or permission.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "post": { + "operationId": "crm_create_pipeline", + "summary": "Create pipeline", + "description": "Create a pipeline, optionally seeding it with an ordered set of stages.", + "tags": [ + "crm" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreatePipeline" + } + } + } + }, + "responses": { + "201": { + "description": "Created pipeline (including its stages).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pipeline" + } + } + } + }, + "400": { + "description": "Invalid request body.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Missing required scope or permission.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/crm/pipelines/{id}": { + "get": { + "operationId": "crm_get_pipeline", + "summary": "Get pipeline", + "description": "Fetch a single pipeline with its stages.", + "tags": [ + "crm" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Pipeline ID.", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "The pipeline.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pipeline" + } + } + } + }, + "400": { + "description": "Malformed path parameter.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Missing required scope or permission.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Pipeline not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "patch": { + "operationId": "crm_update_pipeline", + "summary": "Update pipeline", + "description": "Rename a pipeline. Only the name can be changed here; stages have their own endpoints.", + "tags": [ + "crm" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Pipeline ID.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdatePipeline" + } + } + } + }, + "responses": { + "200": { + "description": "Updated pipeline.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pipeline" + } + } + } + }, + "400": { + "description": "Invalid request body or path parameter.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Missing required scope or permission.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Pipeline not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "delete": { + "operationId": "crm_delete_pipeline", + "summary": "Delete pipeline", + "description": "Delete a pipeline and its stages.", + "tags": [ + "crm" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Pipeline ID.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "responses": { + "204": { + "description": "Pipeline deleted." + }, + "400": { + "description": "Malformed path parameter.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Missing required scope or permission.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Pipeline not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/crm/pipelines/{id}/stages": { + "post": { + "operationId": "crm_create_stage", + "summary": "Create stage", + "description": "Append a stage to a pipeline.", + "tags": [ + "crm" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Pipeline ID.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreatePipelineStage" + } + } + } + }, + "responses": { + "201": { + "description": "Created stage.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PipelineStage" + } + } + } + }, + "400": { + "description": "Invalid request body or path parameter.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Missing required scope or permission.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Pipeline not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/crm/pipelines/{id}/stages/{stageId}": { + "patch": { + "operationId": "crm_update_stage", + "summary": "Update stage", + "description": "Rename or recolor a stage.", + "tags": [ + "crm" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Pipeline ID.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "stageId", + "in": "path", + "required": true, + "description": "Stage ID.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdatePipelineStage" + } + } + } + }, + "responses": { + "200": { + "description": "Updated stage.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PipelineStage" + } + } + } + }, + "400": { + "description": "Invalid request body or path parameter.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Missing required scope or permission.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Pipeline or stage not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "delete": { + "operationId": "crm_delete_stage", + "summary": "Delete stage", + "description": "Remove a stage from a pipeline.", + "tags": [ + "crm" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Pipeline ID.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "stageId", + "in": "path", + "required": true, + "description": "Stage ID.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "responses": { + "204": { + "description": "Stage deleted." + }, + "400": { + "description": "Malformed path parameter.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Missing required scope or permission.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Pipeline or stage not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/crm/deals": { + "get": { + "operationId": "crm_list_deals", + "summary": "List deals", + "description": "List deals with optional pipeline, stage, and status filters, keyset-paginated.", + "tags": [ + "crm" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "pipeline_id", + "in": "query", + "required": false, + "description": "Restrict to deals in this pipeline.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "stage_id", + "in": "query", + "required": false, + "description": "Restrict to deals in this stage.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "status", + "in": "query", + "required": false, + "description": "Restrict to a deal status.", + "schema": { + "type": "string", + "enum": [ + "open", + "won", + "lost" + ] + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque keyset cursor from a previous page's pagination.next_cursor.", + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Page size, 1 to 100.", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50 + } + } + ], + "responses": { + "200": { + "description": "Page of deals.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DealList" + } + } + } + }, + "400": { + "description": "Invalid cursor, limit, or filter value.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Missing required scope or permission.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "post": { + "operationId": "crm_create_deal", + "summary": "Create deal", + "description": "Create a deal in a pipeline stage, optionally linked to a contact and attributed to a campaign and source mailbox. New deals default to status open.", + "tags": [ + "crm" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateDeal" + } + } + } + }, + "responses": { + "201": { + "description": "Created deal.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Deal" + } + } + } + }, + "400": { + "description": "Invalid request body.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Missing required scope or permission.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/crm/deals/search": { + "post": { + "operationId": "crm_search_deals", + "summary": "Search deals", + "description": "Faceted, offset-paginated deal search. Every filter is optional; an empty body matches every deal in the organization. Filters go in the JSON body; limit and offset are query params.", + "tags": [ + "crm" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "description": "Page size, 1 to 200.", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 200, + "default": 50 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque cursor from a previous response's pagination.next_cursor. Omit for the first page.", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SearchDeals" + } + } + } + }, + "responses": { + "200": { + "description": "Offset-paginated deal results with an exact total.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DealsSearchResult" + } + } + } + }, + "400": { + "description": "Invalid limit, offset, or filter body.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Missing required scope or permission.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/crm/deals/summary": { + "post": { + "operationId": "crm_deals_summary", + "summary": "Deals summary", + "description": "Aggregate counts and value sums over the same filter body as deal search, including per-stage totals. All facets are optional.", + "tags": [ + "crm" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SearchDeals" + } + } + } + }, + "responses": { + "200": { + "description": "Aggregate deal totals.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DealsSummary" + } + } + } + }, + "400": { + "description": "Invalid filter body.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Missing required scope or permission.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/crm/deals/{id}": { + "get": { + "operationId": "crm_get_deal", + "summary": "Get deal", + "description": "Fetch a single deal. Joined contact, stage, and campaign_name are only populated by the list and search queries, not by this single-row read.", + "tags": [ + "crm" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Deal ID.", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "The deal.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Deal" + } + } + } + }, + "400": { + "description": "Malformed path parameter.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Missing required scope or permission.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Deal not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "patch": { + "operationId": "crm_update_deal", + "summary": "Update deal", + "description": "Update a deal. Moving it to a different stage_id records a stage-change activity, and setting status to won or lost stamps the corresponding close timestamp.", + "tags": [ + "crm" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Deal ID.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateDeal" + } + } + } + }, + "responses": { + "200": { + "description": "Updated deal.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Deal" + } + } + } + }, + "400": { + "description": "Invalid request body or path parameter.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Missing required scope or permission.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Deal not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "delete": { + "operationId": "crm_delete_deal", + "summary": "Delete deal", + "description": "Delete a deal.", + "tags": [ + "crm" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Deal ID.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "responses": { + "204": { + "description": "Deal deleted." + }, + "400": { + "description": "Malformed path parameter.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Missing required scope or permission.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Deal not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/crm/task-types": { + "get": { + "operationId": "crm_list_task_types", + "summary": "List task types", + "description": "List the organization's CRM task types. A default set is seeded the first time an org lists its types. Returns a data array with no pagination envelope.", + "tags": [ + "crm" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Task types.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CRMTaskTypeList" + } + } + } + }, + "400": { + "description": "Invalid request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Missing required scope or permission.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "post": { + "operationId": "crm_create_task_type", + "summary": "Create task type", + "description": "Create a CRM task type.", + "tags": [ + "crm" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateCRMTaskType" + } + } + } + }, + "responses": { + "201": { + "description": "Created task type.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CRMTaskType" + } + } + } + }, + "400": { + "description": "Invalid request body.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Missing required scope or permission.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/crm/task-types/{id}": { + "patch": { + "operationId": "crm_update_task_type", + "summary": "Update task type", + "description": "Rename, recolor, or reorder a task type.", + "tags": [ + "crm" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Task type ID.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateCRMTaskType" + } + } + } + }, + "responses": { + "200": { + "description": "Updated task type.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CRMTaskType" + } + } + } + }, + "400": { + "description": "Invalid request body or path parameter.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Missing required scope or permission.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Task type not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "delete": { + "operationId": "crm_delete_task_type", + "summary": "Delete task type", + "description": "Delete a task type. Tasks reference their type by name, so existing tasks keep their label and fall back to a neutral color rather than being orphaned.", + "tags": [ + "crm" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Task type ID.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "responses": { + "204": { + "description": "Task type deleted." + }, + "400": { + "description": "Malformed path parameter.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Missing required scope or permission.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Task type not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/crm/tasks": { + "get": { + "operationId": "crm_list_tasks", + "summary": "List tasks", + "description": "List CRM tasks with optional contact, deal, assignee, and status filters, keyset-paginated.", + "tags": [ + "crm" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "contact_id", + "in": "query", + "required": false, + "description": "Restrict to tasks linked to this contact.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "deal_id", + "in": "query", + "required": false, + "description": "Restrict to tasks linked to this deal.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "assigned_to", + "in": "query", + "required": false, + "description": "Restrict to tasks assigned to this user.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "status", + "in": "query", + "required": false, + "description": "Restrict to a task status.", + "schema": { + "type": "string", + "enum": [ + "pending", + "in_progress", + "completed", + "cancelled" + ] + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque keyset cursor from a previous page's pagination.next_cursor.", + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Page size, 1 to 100.", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50 + } + } + ], + "responses": { + "200": { + "description": "Page of tasks.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CRMTaskList" + } + } + } + }, + "400": { + "description": "Invalid cursor, limit, or filter value.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Missing required scope or permission.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "post": { + "operationId": "crm_create_task", + "summary": "Create task", + "description": "Create a CRM task, optionally linked to a contact and deal and assigned to a user or team. created_by is set to the authenticated user.", + "tags": [ + "crm" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateCRMTask" + } + } + } + }, + "responses": { + "201": { + "description": "Created task.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CRMTask" + } + } + } + }, + "400": { + "description": "Invalid request body.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Missing required scope or permission.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/crm/tasks/search": { + "post": { + "operationId": "crm_search_tasks", + "summary": "Search tasks", + "description": "Faceted, offset-paginated task search. Every filter is optional; an empty body matches every task in the organization. Filters go in the JSON body; limit and offset are query params.", + "tags": [ + "crm" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "description": "Page size, 1 to 200.", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 200, + "default": 50 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque cursor from a previous response's pagination.next_cursor. Omit for the first page.", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SearchTasks" + } + } + } + }, + "responses": { + "200": { + "description": "Offset-paginated task results with an exact total.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TasksSearchResult" + } + } + } + }, + "400": { + "description": "Invalid limit, offset, or filter body.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Missing required scope or permission.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/crm/tasks/summary": { + "post": { + "operationId": "crm_tasks_summary", + "summary": "Tasks summary", + "description": "Aggregate counts over the same filter body as task search (by status, overdue, high priority). All facets are optional.", + "tags": [ + "crm" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SearchTasks" + } + } + } + }, + "responses": { + "200": { + "description": "Aggregate task counts.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TasksSummary" + } + } + } + }, + "400": { + "description": "Invalid filter body.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Missing required scope or permission.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/crm/tasks/{id}": { + "get": { + "operationId": "crm_get_task", + "summary": "Get task", + "description": "Fetch a single CRM task.", + "tags": [ + "crm" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Task ID.", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "The task.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CRMTask" + } + } + } + }, + "400": { + "description": "Malformed path parameter.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Missing required scope or permission.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Task not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "patch": { + "operationId": "crm_update_task", + "summary": "Update task", + "description": "Update a CRM task. Setting status to completed stamps the completion timestamp.", + "tags": [ + "crm" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Task ID.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateCRMTask" + } + } + } + }, + "responses": { + "200": { + "description": "Updated task.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CRMTask" + } + } + } + }, + "400": { + "description": "Invalid request body or path parameter.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Missing required scope or permission.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Task not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "delete": { + "operationId": "crm_delete_task", + "summary": "Delete task", + "description": "Delete a CRM task.", + "tags": [ + "crm" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Task ID.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "responses": { + "204": { + "description": "Task deleted." + }, + "400": { + "description": "Malformed path parameter.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Missing required scope or permission.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Task not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/api-keys": { + "get": { + "operationId": "api-keys_list", + "summary": "List API keys", + "description": "Returns the organization's API keys, newest first. The plaintext secret is never included.", + "tags": [ + "api-keys" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque pagination token from the previous page's pagination.next_cursor. Omit for the first page.", + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Page size, 1 to 100. Defaults to 50.", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50 + } + } + ], + "responses": { + "200": { + "description": "A page of API keys.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIKeyList" + } + } + } + }, + "400": { + "description": "Invalid cursor or limit, or no organization selected.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthenticated.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Missing API_KEYS scope or manage_api_keys org permission.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "post": { + "operationId": "api-keys_create", + "summary": "Create an API key", + "description": "Creates a new key and returns the plaintext secret exactly once. Unknown permission bits are rejected.", + "tags": [ + "api-keys" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateAPIKey" + } + } + } + }, + "responses": { + "201": { + "description": "The created key, including the one-time secret.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIKeyWithSecret" + } + } + } + }, + "400": { + "description": "Invalid body, no organization selected, or permission bitmask contains unknown bits.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthenticated.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Missing API_KEYS scope or manage_api_keys org permission.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/api-keys/permissions": { + "get": { + "operationId": "api-keys_list_permissions", + "summary": "List available permissions", + "description": "Returns the catalog of permission bits plus the read_only and full_access presets.", + "tags": [ + "api-keys" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "The permission catalog and presets.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIPermissionCatalog" + } + } + } + }, + "401": { + "description": "Unauthenticated.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Missing API_KEYS scope or manage_api_keys org permission.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/api-keys/usage/summary": { + "get": { + "operationId": "api-keys_usage_summary", + "summary": "Usage summary", + "description": "Org-level usage strip: key counts by status plus a 24-hour request, error, and latency rollup.", + "tags": [ + "api-keys" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "The usage summary object.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIKeyUsageSummary" + } + } + } + }, + "400": { + "description": "No organization selected.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthenticated.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Missing API_KEYS scope or manage_api_keys org permission.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/api-keys/usage/analytics": { + "get": { + "operationId": "api-keys_usage_analytics", + "summary": "Org-wide usage analytics", + "description": "Time-bucketed request series plus a per-endpoint breakdown for the whole organization. For this org-wide form api_key_id is the all-zero UUID.", + "tags": [ + "api-keys" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "from", + "in": "query", + "required": false, + "description": "Start of the window (RFC3339). Defaults to 24 hours before to.", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "to", + "in": "query", + "required": false, + "description": "End of the window (RFC3339). Defaults to now.", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "interval", + "in": "query", + "required": false, + "description": "Bucket granularity.", + "schema": { + "type": "string", + "enum": [ + "minute", + "hour", + "day" + ] + } + } + ], + "responses": { + "200": { + "description": "The analytics payload.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIKeyAnalytics" + } + } + } + }, + "400": { + "description": "No organization selected.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthenticated.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Missing API_KEYS scope or manage_api_keys org permission.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/api-keys/{id}": { + "get": { + "operationId": "api-keys_get", + "summary": "Get an API key", + "description": "Returns a single key by id. The secret is never included.", + "tags": [ + "api-keys" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "The API key.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIKey" + } + } + } + }, + "400": { + "description": "No organization selected.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthenticated.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Missing API_KEYS scope or manage_api_keys org permission.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Invalid UUID or key not found in this organization.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "patch": { + "operationId": "api-keys_update", + "summary": "Update an API key", + "description": "Updates the mutable fields of a key. Every field is optional; only the fields you send are changed. The secret cannot be rotated here.", + "tags": [ + "api-keys" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateAPIKey" + } + } + } + }, + "responses": { + "200": { + "description": "The updated API key.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIKey" + } + } + } + }, + "400": { + "description": "Invalid body, no organization selected, or permission bitmask contains unknown bits.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthenticated.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Missing API_KEYS scope or manage_api_keys org permission.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Invalid UUID or key not found in this organization.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "delete": { + "operationId": "api-keys_revoke", + "summary": "Revoke an API key", + "description": "Revokes a key immediately. The key stops authenticating right away; this is not reversible.", + "tags": [ + "api-keys" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "reason", + "in": "query", + "required": false, + "description": "Optional revocation note stored on the key. Defaults to \"Revoked by user\".", + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "responses": { + "200": { + "description": "Revocation status envelope.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIKeyRevokeResult" + } + } + } + }, + "400": { + "description": "No organization selected.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthenticated.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Missing API_KEYS scope or manage_api_keys org permission.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Invalid UUID or key not found in this organization.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/api-keys/{id}/analytics": { + "get": { + "operationId": "api-keys_analytics", + "summary": "Per-key usage analytics", + "description": "Time-bucketed request series plus a per-endpoint breakdown for a single key. Pass the literal id value `all` for the org-wide aggregate.", + "tags": [ + "api-keys" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The API key id, or the literal `all` for the org-wide aggregate.", + "schema": { + "type": "string" + } + }, + { + "name": "from", + "in": "query", + "required": false, + "description": "Start of the window (RFC3339). Defaults to 24 hours before to.", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "to", + "in": "query", + "required": false, + "description": "End of the window (RFC3339). Defaults to now.", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "interval", + "in": "query", + "required": false, + "description": "Bucket granularity.", + "schema": { + "type": "string", + "enum": [ + "minute", + "hour", + "day" + ] + } + } + ], + "responses": { + "200": { + "description": "The analytics payload.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIKeyAnalytics" + } + } + } + }, + "400": { + "description": "No organization selected.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthenticated.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Missing API_KEYS scope or manage_api_keys org permission.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Invalid UUID (when not `all`) or key not found in this organization.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/api-keys/{id}/logs": { + "get": { + "operationId": "api-keys_list_logs", + "summary": "List per-key usage logs", + "description": "Returns the recent raw request entries for a single key, newest first.", + "tags": [ + "api-keys" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque pagination token from the previous page's pagination.next_cursor.", + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Page size, 1 to 200. Defaults to 50.", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 200, + "default": 50 + } + } + ], + "responses": { + "200": { + "description": "A page of usage log entries.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIKeyUsageLogList" + } + } + } + }, + "400": { + "description": "Invalid cursor, or no organization selected.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthenticated.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Missing API_KEYS scope or manage_api_keys org permission.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Invalid UUID or key not found in this organization.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/webhooks": { + "get": { + "operationId": "webhooks_list", + "tags": [ + "webhooks" + ], + "summary": "List webhook endpoints", + "description": "Returns every webhook endpoint configured for the caller's organization, plus the canonical `event_types` vocabulary for building a picker. Secrets are never returned. This endpoint does NOT use the `data` + `pagination` cursor envelope. Requires the `WEBHOOKS` scope and the `manage_settings` org permission.", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "The organization's webhook endpoints and the full event vocabulary.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookEndpointList" + } + } + } + }, + "400": { + "description": "Invalid request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Insufficient scope or permission.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "post": { + "operationId": "webhooks_create", + "tags": [ + "webhooks" + ], + "summary": "Create a webhook endpoint", + "description": "Creates a new event subscription. The `url` must be HTTPS and resolve to a publicly routable host (loopback, private, and link-local targets are rejected unless the server runs with unsafe webhook URLs enabled for local or self-hosted development). The response is the only time the signing `secret` (prefixed `whsec_`) is returned, so capture it immediately. Requires the `WEBHOOKS` scope and the `manage_settings` org permission.", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookEndpointRequest" + } + } + } + }, + "responses": { + "201": { + "description": "The created endpoint, including the one-time `secret`.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookEndpointWithSecret" + } + } + } + }, + "400": { + "description": "Invalid payload, non-HTTPS or non-routable url, or unknown event type.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Insufficient scope or permission.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/webhooks/{id}": { + "patch": { + "operationId": "webhooks_update", + "tags": [ + "webhooks" + ], + "summary": "Update a webhook endpoint", + "description": "Replaces the endpoint's url, description, event filter, and enabled state with the values sent (send the complete desired state; `event_types` is overwritten, not merged). The signing secret is not changed here; use the rotate-secret endpoint. Requires the `WEBHOOKS` scope and the `manage_settings` org permission.", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The endpoint id to update.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookEndpointRequest" + } + } + } + }, + "responses": { + "200": { + "description": "The updated endpoint (no secret).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookEndpoint" + } + } + } + }, + "400": { + "description": "Invalid payload, invalid endpoint id, non-HTTPS or non-routable url, or unknown event type.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Insufficient scope or permission.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Endpoint not found or not owned by your organization.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "delete": { + "operationId": "webhooks_delete", + "tags": [ + "webhooks" + ], + "summary": "Delete a webhook endpoint", + "description": "Deletes a subscription and cascades to its delivery history. Requires the `WEBHOOKS` scope and the `manage_settings` org permission.", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The endpoint id to delete.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "responses": { + "204": { + "description": "Deleted. Empty body." + }, + "400": { + "description": "Invalid endpoint id.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Insufficient scope or permission.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Endpoint not found or not owned by your organization.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/webhooks/{id}/rotate-secret": { + "post": { + "operationId": "webhooks_rotate_secret", + "tags": [ + "webhooks" + ], + "summary": "Rotate the signing secret", + "description": "Issues a new HMAC signing secret and returns it once. In-flight deliveries already signed continue to verify against the old secret until they settle; new deliveries use the new secret. Update your `X-Warmbly-Signature` verifier promptly. Requires the `WEBHOOKS` scope and the `manage_settings` org permission.", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The endpoint id to rotate.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "responses": { + "200": { + "description": "The new signing secret. Returned only once.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookSecretResponse" + } + } + } + }, + "400": { + "description": "Invalid endpoint id.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Insufficient scope or permission.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Endpoint not found or not owned by your organization.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/webhooks/{id}/deliveries": { + "get": { + "operationId": "webhooks_list_deliveries", + "tags": [ + "webhooks" + ], + "summary": "List delivery attempts", + "description": "Returns recent delivery attempts for an endpoint, newest first. Each row updates in place across retries, so an event that retried several times appears as one record whose `attempt_count` and `status` reflect the latest state. This endpoint does NOT use the `data` + `pagination` cursor envelope; it returns a `deliveries` array bounded by `limit`. Requires the `WEBHOOKS` scope and the `manage_settings` org permission.", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The endpoint id whose deliveries to list.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Max rows to return. Between 1 and 200. Defaults to 50. Out-of-range values return 400.", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 200, + "default": 50 + } + } + ], + "responses": { + "200": { + "description": "Recent delivery attempts for the endpoint.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookDeliveryList" + } + } + } + }, + "400": { + "description": "Invalid endpoint id or a limit outside 1 to 200.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Insufficient scope or permission.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Endpoint not found or not owned by your organization.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/analytics/dashboard": { + "get": { + "operationId": "analytics_dashboard", + "tags": [ + "analytics" + ], + "summary": "Get dashboard analytics", + "description": "Org-wide dashboard overview: aggregate stats, recent activity, top campaigns, account health, and a daily trend series.", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "period", + "in": "query", + "required": false, + "description": "One of 7d, 30d, 90d. Any other value falls back to 7d.", + "schema": { + "type": "string", + "enum": [ + "7d", + "30d", + "90d" + ], + "default": "7d" + } + } + ], + "responses": { + "200": { + "description": "Dashboard analytics overview.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DashboardAnalytics" + } + } + } + }, + "400": { + "description": "Invalid request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Insufficient permissions.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/analytics/deliverability": { + "get": { + "operationId": "analytics_deliverability", + "tags": [ + "analytics" + ], + "summary": "Get deliverability dashboard", + "description": "Deliverability posture over a window: bounce/complaint/open/click/reply counts and rates, suppression and dead-letter pressure, reply-intent breakdown, seed inbox-placement, health band, daily timeseries, and per-mailbox/per-campaign breakdowns.", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "from", + "in": "query", + "required": false, + "description": "Window start as an RFC 3339 timestamp. Defaults to 7 days ago (UTC).", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "to", + "in": "query", + "required": false, + "description": "Window end as an RFC 3339 timestamp. Defaults to now (UTC).", + "schema": { + "type": "string", + "format": "date-time" + } + } + ], + "responses": { + "200": { + "description": "Deliverability dashboard.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeliverabilityDashboard" + } + } + } + }, + "400": { + "description": "Invalid request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Insufficient permissions.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/analytics/warmup": { + "get": { + "operationId": "analytics_warmup", + "tags": [ + "analytics" + ], + "summary": "Get warmup analytics", + "description": "Warmup send and reply statistics over a date range, with a summary and per-day series. Optionally scoped to a single mailbox.", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "from", + "in": "query", + "required": true, + "description": "Range start (YYYY-MM-DD).", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "name": "to", + "in": "query", + "required": true, + "description": "Range end (YYYY-MM-DD).", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "name": "email_id", + "in": "query", + "required": false, + "description": "Limit to one email account. Invalid UUIDs are ignored.", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Warmup analytics.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WarmupAnalytics" + } + } + } + }, + "400": { + "description": "Missing or invalid range.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Insufficient permissions.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/analytics/campaigns/compare": { + "get": { + "operationId": "analytics_campaigns_compare", + "tags": [ + "analytics" + ], + "summary": "Compare campaigns", + "description": "Side-by-side performance for up to 10 campaigns over a date range. Every requested campaign must belong to the caller.", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "ids", + "in": "query", + "required": true, + "description": "Comma-separated campaign UUIDs. Invalid entries are dropped; capped at 10. At least one valid id is required.", + "schema": { + "type": "string" + } + }, + { + "name": "from", + "in": "query", + "required": true, + "description": "Range start (YYYY-MM-DD).", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "name": "to", + "in": "query", + "required": true, + "description": "Range end (YYYY-MM-DD).", + "schema": { + "type": "string", + "format": "date" + } + } + ], + "responses": { + "200": { + "description": "Campaign comparison.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CampaignComparison" + } + } + } + }, + "400": { + "description": "Missing or invalid ids/range.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Insufficient permissions.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "A requested campaign was not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/analytics/campaigns/{id}": { + "get": { + "operationId": "analytics_campaign_get", + "tags": [ + "analytics" + ], + "summary": "Get campaign analytics", + "description": "A single campaign's performance summary plus per-step stats. The campaign must belong to the caller.", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Campaign id.", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Campaign analytics.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CampaignAnalytics" + } + } + } + }, + "400": { + "description": "Invalid request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Insufficient permissions.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Campaign not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/analytics/campaigns/{id}/daily": { + "get": { + "operationId": "analytics_campaign_daily", + "tags": [ + "analytics" + ], + "summary": "Get campaign daily stats", + "description": "Per-day send, open, click, and reply counts for one campaign over a date range. The campaign must belong to the caller.", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Campaign id.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "from", + "in": "query", + "required": true, + "description": "Range start (YYYY-MM-DD).", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "name": "to", + "in": "query", + "required": true, + "description": "Range end (YYYY-MM-DD).", + "schema": { + "type": "string", + "format": "date" + } + } + ], + "responses": { + "200": { + "description": "Per-day series under a data envelope.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CampaignDailyStats" + } + } + } + }, + "400": { + "description": "Missing or invalid range.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Insufficient permissions.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Campaign not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/analytics/campaigns/{id}/hourly": { + "get": { + "operationId": "analytics_campaign_hourly", + "tags": [ + "analytics" + ], + "summary": "Get campaign hourly stats", + "description": "Per-hour stats for one campaign on a single day. The campaign must belong to the caller.", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Campaign id.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "date", + "in": "query", + "required": false, + "description": "Day to report (YYYY-MM-DD). Defaults to today.", + "schema": { + "type": "string", + "format": "date" + } + } + ], + "responses": { + "200": { + "description": "Per-hour series under a data envelope with the resolved date echoed back.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CampaignHourlyStats" + } + } + } + }, + "400": { + "description": "Invalid date.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Insufficient permissions.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Campaign not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/analytics/accounts": { + "get": { + "operationId": "analytics_accounts_list", + "tags": [ + "analytics" + ], + "summary": "List account statuses", + "description": "Health and usage status of every email account the caller owns. Returned under a data envelope (no cursor; all accounts included). Accounts that fail to build are skipped.", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Account statuses.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountStatusList" + } + } + } + }, + "400": { + "description": "Invalid request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Insufficient permissions.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/analytics/accounts/{id}": { + "get": { + "operationId": "analytics_account_get", + "tags": [ + "analytics" + ], + "summary": "Get account status", + "description": "Detailed status for one email account: combined health score (folding in warmup-pool reputation), active errors, today's usage, warmup status, and warmup-pool health. The account must belong to the caller.", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Email account id.", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Account status detail.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountStatusDetail" + } + } + } + }, + "400": { + "description": "Invalid request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Insufficient permissions.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Account not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/analytics/usage": { + "get": { + "operationId": "analytics_usage", + "tags": [ + "analytics" + ], + "summary": "Get usage overview", + "description": "Account, campaign, contact, and API usage counters for the caller.", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "period", + "in": "query", + "required": false, + "description": "One of day, week, month. Any other value falls back to day.", + "schema": { + "type": "string", + "enum": [ + "day", + "week", + "month" + ], + "default": "day" + } + } + ], + "responses": { + "200": { + "description": "Usage overview.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsageOverview" + } + } + } + }, + "400": { + "description": "Invalid request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Insufficient permissions.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/audit-logs": { + "get": { + "operationId": "analytics_audit_logs_list", + "tags": [ + "analytics" + ], + "summary": "List audit logs", + "description": "Organization-wide activity trail for the caller's current organization. The organization is always taken from the session, never from a parameter. Auth: scope READ_AUDIT_LOGS, org permission view_analytics.", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "description": "Page size. Defaults to 50; must be between 10 and 200 or a 400 is returned.", + "schema": { + "type": "integer", + "minimum": 10, + "maximum": 200, + "default": 50 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque cursor from pagination.next_cursor. Invalid cursors return 400.", + "schema": { + "type": "string" + } + }, + { + "name": "actor_id", + "in": "query", + "required": false, + "description": "Filter to a single acting member.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "entity_id", + "in": "query", + "required": false, + "description": "Filter to a single entity.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "entity_type", + "in": "query", + "required": false, + "description": "Filter by entity type (for example campaign, contact, email_account, api_key, webhook).", + "schema": { + "type": "string" + } + }, + { + "name": "action", + "in": "query", + "required": false, + "description": "Filter by action (for example create, update, delete, send, revoke).", + "schema": { + "type": "string" + } + }, + { + "name": "date", + "in": "query", + "required": false, + "description": "Single-day filter (YYYY-MM-DD), expanded to that whole UTC day.", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "name": "start_date", + "in": "query", + "required": false, + "description": "Range start. RFC 3339 or YYYY-MM-DD. Overrides date.", + "schema": { + "type": "string" + } + }, + { + "name": "end_date", + "in": "query", + "required": false, + "description": "Range end. RFC 3339 or YYYY-MM-DD. Overrides date.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Audit log page (data plus pagination).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuditLogList" + } + } + } + }, + "400": { + "description": "Invalid cursor, limit, or filter.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Insufficient permissions.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/integrations/catalog": { + "get": { + "operationId": "integrations_catalog_list", + "summary": "List the integration catalog", + "description": "Static metadata for every provider Warmbly supports, annotated with whether each OAuth provider has server-side credentials wired (`configured`).", + "tags": [ + "integrations" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Provider catalog.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IntegrationCatalogList" + } + } + } + }, + "400": { + "description": "Bad request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden (missing INTEGRATIONS scope).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/integrations/connections": { + "get": { + "operationId": "integrations_connections_list", + "summary": "List connections", + "description": "This org's connection rows. Secrets are never serialized. Returns a bare `connections` array, not the cursor-paginated envelope.", + "tags": [ + "integrations" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Connections.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IntegrationConnectionList" + } + } + } + }, + "400": { + "description": "Bad request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "post": { + "operationId": "integrations_connections_create", + "summary": "Create a connection", + "description": "Creates a credential-based connection for `api_key` / `webhook` providers (e.g. Close, Discord). OAuth providers are rejected with a hint to start the authorize flow instead. Inbound providers (Calendly, Cal.com) include `inbound_webhook_url` once. Requires the `manage_settings` org permission for JWT callers.", + "tags": [ + "integrations" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IntegrationConnectionCreate" + } + } + } + }, + "responses": { + "201": { + "description": "Connection created.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IntegrationConnection" + } + } + } + }, + "400": { + "description": "Bad request (e.g. OAuth provider, invalid provider).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden (paid-plan feature or missing permission).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/integrations/connections/{id}": { + "get": { + "operationId": "integrations_connections_get", + "summary": "Get a connection", + "description": "One connection plus its event subscriptions and up to 20 recent sync runs.", + "tags": [ + "integrations" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + }, + "description": "Connection id." + } + ], + "responses": { + "200": { + "description": "Connection detail.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IntegrationConnectionDetail" + } + } + } + }, + "400": { + "description": "Bad request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "delete": { + "operationId": "integrations_connections_delete", + "summary": "Disconnect", + "description": "Removes a connection row. Requires `manage_settings` for JWT callers.", + "tags": [ + "integrations" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + }, + "description": "Connection id." + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "responses": { + "204": { + "description": "Disconnected." + }, + "400": { + "description": "Bad request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/integrations/connections/{id}/config": { + "patch": { + "operationId": "integrations_connections_update_config", + "summary": "Update connection config", + "description": "Saves a connection's onboarding/capability snapshot and its sync direction. Requires `manage_settings` for JWT callers.", + "tags": [ + "integrations" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + }, + "description": "Connection id." + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IntegrationConnectionConfigUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Updated connection.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "connection" + ], + "properties": { + "connection": { + "$ref": "#/components/schemas/IntegrationConnection" + } + } + } + } + } + }, + "400": { + "description": "Bad request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/integrations/connections/{id}/events": { + "get": { + "operationId": "integrations_event_subscriptions_list", + "summary": "List event subscriptions", + "description": "The event-to-action routes configured on a connection.", + "tags": [ + "integrations" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + }, + "description": "Connection id." + } + ], + "responses": { + "200": { + "description": "Event subscriptions.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IntegrationEventSubscriptionList" + } + } + } + }, + "400": { + "description": "Bad request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "post": { + "operationId": "integrations_event_subscriptions_create", + "summary": "Create an event subscription", + "description": "Routes a Warmbly event to a provider action on this connection. Requires `manage_settings` for JWT callers.", + "tags": [ + "integrations" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + }, + "description": "Connection id." + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IntegrationEventSubscriptionCreate" + } + } + } + }, + "responses": { + "201": { + "description": "Event subscription created.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IntegrationEventSubscription" + } + } + } + }, + "400": { + "description": "Bad request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden (paid-plan feature or missing permission).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/integrations/connections/{id}/events/{eventId}": { + "delete": { + "operationId": "integrations_event_subscriptions_delete", + "summary": "Delete an event subscription", + "description": "Removes one event subscription. Requires `manage_settings` for JWT callers.", + "tags": [ + "integrations" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + }, + "description": "Connection id." + }, + { + "name": "eventId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + }, + "description": "Event subscription id." + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "responses": { + "204": { + "description": "Deleted." + }, + "400": { + "description": "Bad request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/integrations/connections/{id}/field-mappings": { + "get": { + "operationId": "integrations_field_mappings_list", + "summary": "List field mappings", + "description": "The Warmbly-field to provider-field maps configured for a connection.", + "tags": [ + "integrations" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + }, + "description": "Connection id." + } + ], + "responses": { + "200": { + "description": "Field mappings.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IntegrationFieldMappingList" + } + } + } + }, + "400": { + "description": "Bad request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "put": { + "operationId": "integrations_field_mappings_replace", + "summary": "Replace field mappings", + "description": "Swaps the connection-default field map for an object wholesale. A full replace is naturally idempotent so no `Idempotency-Key` is required. Requires `manage_settings` for JWT callers.", + "tags": [ + "integrations" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + }, + "description": "Connection id." + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IntegrationFieldMappingReplace" + } + } + } + }, + "responses": { + "200": { + "description": "Full mapping set after the replace.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IntegrationFieldMappingList" + } + } + } + }, + "400": { + "description": "Bad request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/integrations/connections/{id}/runs": { + "get": { + "operationId": "integrations_sync_runs_list", + "summary": "List sync runs", + "description": "Up to 50 recent observability records for a connection (connect, token refresh, event dispatch, manual push).", + "tags": [ + "integrations" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + }, + "description": "Connection id." + } + ], + "responses": { + "200": { + "description": "Sync runs.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IntegrationSyncRunList" + } + } + } + }, + "400": { + "description": "Bad request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/integrations/connections/{id}/webhook-secret": { + "get": { + "operationId": "integrations_webhook_secret_get", + "summary": "Get the connection webhook secret", + "description": "Returns (generating on first call) the HMAC signing secret for an automation connection so you can verify Warmbly's outbound webhook signatures. Requires `manage_settings` for JWT callers.", + "tags": [ + "integrations" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + }, + "description": "Connection id." + } + ], + "responses": { + "200": { + "description": "Signing secret.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IntegrationWebhookSecret" + } + } + } + }, + "400": { + "description": "Bad request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/integrations/connections/{id}/test": { + "post": { + "operationId": "integrations_connection_test", + "summary": "Test a connection", + "description": "Fires a synthetic event through the connection's notify/webhook automations so you can confirm the channel is wired. Requires `manage_settings` for JWT callers.", + "tags": [ + "integrations" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + }, + "description": "Connection id." + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "responses": { + "200": { + "description": "Test event dispatched.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "sent" + ], + "properties": { + "sent": { + "type": "boolean" + } + } + } + } + } + }, + "400": { + "description": "Bad request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/integrations/connections/{id}/push": { + "post": { + "operationId": "integrations_connection_push", + "summary": "Push contacts to a CRM", + "description": "Synchronously upserts the given org contacts into a connected CRM (HubSpot, Pipedrive, Salesforce, Close). Retries are naturally safe (every upsert is keyed by email), so no `Idempotency-Key` is required. Requires the `use_integrations` org permission for JWT callers. A connection whose token can no longer be refreshed returns 409.", + "tags": [ + "integrations" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + }, + "description": "Connection id." + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IntegrationPushRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Per-record push results.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IntegrationPushResult" + } + } + } + }, + "400": { + "description": "Bad request (no/too many/invalid contact ids).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden (paid-plan feature or missing permission).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Connection or matching contacts not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Connection needs to be reconnected (token not refreshable).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/integrations/bookings": { + "get": { + "operationId": "integrations_bookings_list", + "summary": "List meeting bookings (integrations view)", + "description": "Up to 50 recent booked meetings, surfaced on the integrations page. For the full Meetings list with filters and pagination, use `GET /meetings`.", + "tags": [ + "integrations" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Recent bookings.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MeetingBookingList" + } + } + } + }, + "400": { + "description": "Bad request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/automations": { + "get": { + "operationId": "automations_list", + "summary": "List automations", + "description": "This org's automation flows (the visual flow builder). Returns a bare `automations` array.", + "tags": [ + "integrations" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Automations.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AutomationList" + } + } + } + }, + "400": { + "description": "Bad request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "post": { + "operationId": "automations_create", + "summary": "Create an automation", + "description": "Creates a new automation flow: a trigger event plus a graph of condition and action nodes. Action nodes may reference provider actions (e.g. `slack.notify`, `hubspot.upsert_contact`) or Warmbly-native actions (e.g. `warmbly.add_tag`, `warmbly.label_email`) that need no external connection.", + "tags": [ + "integrations" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AutomationWrite" + } + } + } + }, + "responses": { + "201": { + "description": "Automation created.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "automation" + ], + "properties": { + "automation": { + "$ref": "#/components/schemas/Automation" + } + } + } + } + } + }, + "400": { + "description": "Bad request (invalid graph, missing trigger).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden (paid-plan feature or missing permission).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/automations/{id}": { + "get": { + "operationId": "automations_get", + "summary": "Get an automation", + "description": "One automation with its full graph.", + "tags": [ + "integrations" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + }, + "description": "Automation id." + } + ], + "responses": { + "200": { + "description": "Automation.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "automation" + ], + "properties": { + "automation": { + "$ref": "#/components/schemas/Automation" + } + } + } + } + } + }, + "400": { + "description": "Bad request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "patch": { + "operationId": "automations_update", + "summary": "Update an automation", + "description": "Replaces an automation's name, enabled state, trigger, filter, and graph. The body shape matches the create payload.", + "tags": [ + "integrations" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + }, + "description": "Automation id." + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AutomationWrite" + } + } + } + }, + "responses": { + "200": { + "description": "Updated automation.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "automation" + ], + "properties": { + "automation": { + "$ref": "#/components/schemas/Automation" + } + } + } + } + } + }, + "400": { + "description": "Bad request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "delete": { + "operationId": "automations_delete", + "summary": "Delete an automation", + "description": "Removes an automation. Returns 409 when the automation is still referenced by campaign steps.", + "tags": [ + "integrations" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + }, + "description": "Automation id." + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "responses": { + "200": { + "description": "Deleted.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "deleted" + ], + "properties": { + "deleted": { + "type": "boolean" + } + } + } + } + } + }, + "400": { + "description": "Bad request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Still referenced by campaign steps.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/automations/{id}/test": { + "post": { + "operationId": "automations_test", + "summary": "Test an automation", + "description": "Runs the automation against sample (or provided) data without side effects and returns the walked trace plus per-action previews.", + "tags": [ + "integrations" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + }, + "description": "Automation id." + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AutomationDryRunRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Dry-run trace plus resolved event data.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AutomationDryRunResponse" + } + } + } + }, + "400": { + "description": "Bad request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/automations/{id}/runs": { + "get": { + "operationId": "automations_runs_list", + "summary": "List automation runs", + "description": "Recent run history for an automation (per fired event or manual launch), with per-node outcomes.", + "tags": [ + "integrations" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + }, + "description": "Automation id." + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 50 + }, + "description": "Max runs to return. Defaults to 50." + } + ], + "responses": { + "200": { + "description": "Automation runs.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AutomationRunList" + } + } + } + }, + "400": { + "description": "Bad request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/teams": { + "get": { + "operationId": "account-org_teams_list", + "summary": "List teams", + "description": "Returns the current organization's teams, each hydrated with its members. Requires a selected organization.", + "tags": [ + "account-org" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "The organization's teams.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TeamCollection" + } + } + } + }, + "400": { + "description": "No organization selected or invalid request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Key lacks the READ_CRM scope or caller lacks view_contacts.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "post": { + "operationId": "account-org_teams_create", + "summary": "Create a team", + "description": "Creates a team (members start empty). Requires a selected organization.", + "tags": [ + "account-org" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TeamCreate" + } + } + } + }, + "responses": { + "201": { + "description": "The created team.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Team" + } + } + } + }, + "400": { + "description": "Validation error or no organization selected.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Key lacks the WRITE_CRM scope or caller lacks manage_team.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/teams/{id}": { + "get": { + "operationId": "account-org_teams_get", + "summary": "Get a team", + "description": "Returns a single team with its members.", + "tags": [ + "account-org" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The team id.", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "The team.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Team" + } + } + } + }, + "400": { + "description": "Invalid id or no organization selected.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Key lacks the READ_CRM scope or caller lacks view_contacts.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Team not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "patch": { + "operationId": "account-org_teams_update", + "summary": "Update a team", + "description": "Partial-updates a team's name or color. Omitted fields are left untouched.", + "tags": [ + "account-org" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The team id.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TeamUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "The updated team.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Team" + } + } + } + }, + "400": { + "description": "Validation error, invalid id, or no organization selected.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Key lacks the WRITE_CRM scope or caller lacks manage_team.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Team not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "delete": { + "operationId": "account-org_teams_delete", + "summary": "Delete a team", + "description": "Deletes a team.", + "tags": [ + "account-org" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The team id.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "responses": { + "204": { + "description": "Team deleted." + }, + "400": { + "description": "Invalid id or no organization selected.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Key lacks the WRITE_CRM scope or caller lacks manage_team.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Team not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/teams/{id}/members": { + "post": { + "operationId": "account-org_teams_add_member", + "summary": "Add a team member", + "description": "Adds an existing organization member to the team and returns the updated team.", + "tags": [ + "account-org" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The team id.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TeamAddMember" + } + } + } + }, + "responses": { + "200": { + "description": "The updated team, including the new member.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Team" + } + } + } + }, + "400": { + "description": "Validation error, invalid id, or no organization selected.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Key lacks the WRITE_CRM scope or caller lacks manage_team.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Team not found, or the user is not an organization member.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/teams/{id}/members/{userId}": { + "delete": { + "operationId": "account-org_teams_remove_member", + "summary": "Remove a team member", + "description": "Removes a member from the team.", + "tags": [ + "account-org" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The team id.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "userId", + "in": "path", + "required": true, + "description": "The member's user id.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "responses": { + "204": { + "description": "Member removed." + }, + "400": { + "description": "Invalid id or no organization selected.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Key lacks the WRITE_CRM scope or caller lacks manage_team.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Team or membership not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/plans": { + "get": { + "operationId": "account-org_plans_list", + "summary": "List plans", + "description": "Returns the available public subscription plans. Open to any authenticated caller (JWT or API key); auth exists only to deter scraping.", + "tags": [ + "account-org" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "The public subscription plans.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlanList" + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/timezones": { + "get": { + "operationId": "account-org_timezones_list", + "summary": "List timezones", + "description": "Returns the supported timezone identifiers (for campaign schedule windows and the like). Open to any authenticated caller (JWT or API key).", + "tags": [ + "account-org" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "The supported timezones, sorted by UTC offset.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TimezoneOption" + } + } + } + } + }, + "401": { + "description": "Missing or invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/outreach/settings": { + "get": { + "operationId": "deliverability-ops_get_settings", + "summary": "Get outreach settings", + "description": "Returns the organization's advanced outreach settings (bounce pipeline, task reliability, A/B testing, reply-intent, send-time optimization, preflight, dashboard).", + "tags": [ + "deliverability-ops" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "The advanced outreach settings object (not envelope-wrapped).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdvancedOutreachSettings" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden (missing WRITE_CAMPAIGNS scope or manage_settings permission)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "patch": { + "operationId": "deliverability-ops_update_settings", + "summary": "Update outreach settings", + "description": "Replaces the organization's advanced outreach settings with the supplied object (upserted, not deep-merged). Returns no body.", + "tags": [ + "deliverability-ops" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpsertOutreachSettingsRequest" + } + } + } + }, + "responses": { + "204": { + "description": "Settings updated" + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/deliverability/events": { + "post": { + "operationId": "deliverability-ops_ingest_event", + "summary": "Ingest a deliverability event", + "description": "Posts a single deliverability signal (bounce, complaint, unsubscribe, open, click, reply) into the platform. API-key callable so downstream pipelines can report events. Supply idempotency_key to make retries safe. Requires WRITE_CAMPAIGNS scope and send_campaigns permission.", + "tags": [ + "deliverability-ops" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IngestDeliverabilityEventRequest" + } + } + } + }, + "responses": { + "202": { + "description": "Event accepted and queued for processing (no body)." + }, + "400": { + "description": "Invalid event payload (e.g. missing event_type or recipient_email)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/tasks/dlq": { + "get": { + "operationId": "deliverability-ops_list_dead_letters", + "summary": "List task dead letters", + "description": "Lists tasks that exhausted their retry budget and landed in the dead-letter queue. Not cursor-paginated: returns up to `limit` rows in one response. Requires SEND_CAMPAIGNS scope and send_campaigns permission.", + "tags": [ + "deliverability-ops" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "status", + "in": "query", + "required": false, + "description": "Optional status filter (e.g. pending, replayed).", + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Max rows to return, 1 to 200 (default 100).", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 200, + "default": 100 + } + } + ], + "responses": { + "200": { + "description": "Dead-letter records under a `data` array.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TaskDeadLetterList" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/tasks/dlq/{id}/replay": { + "post": { + "operationId": "deliverability-ops_replay_dead_letter", + "summary": "Replay a task dead letter", + "description": "Re-dispatches a dead-lettered task. Because a replay can transmit real mail this requires SEND_CAMPAIGNS scope and send_campaigns permission.", + "tags": [ + "deliverability-ops" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The dead-letter record ID (the `id` field, not `task_id`).", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "responses": { + "200": { + "description": "Replay dispatched.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReplayDeadLetterResponse" + } + } + } + }, + "400": { + "description": "Invalid dead-letter id", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Dead-letter record not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/warmup/routing": { + "get": { + "operationId": "deliverability-ops_list_routing_rules", + "summary": "List warmup routing rules", + "description": "Returns every warmup routing rule for the organization, ordered by priority ascending. Not cursor-paginated. Requires WARMUP_ROUTING scope and manage_settings permission.", + "tags": [ + "deliverability-ops" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Routing rules under a `rules` array (never null).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WarmupRoutingRuleList" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "post": { + "operationId": "deliverability-ops_create_routing_rule", + "summary": "Create a warmup routing rule", + "description": "Creates a routing rule for the organization. Both sender and recipient sides are matched; a rule applies only when both match. Match values are lowercased and trimmed on write. Requires WARMUP_ROUTING scope and manage_settings permission.", + "tags": [ + "deliverability-ops" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WarmupRoutingRuleInput" + } + } + } + }, + "responses": { + "201": { + "description": "Rule created.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WarmupRoutingRule" + } + } + } + }, + "400": { + "description": "Invalid payload (e.g. missing name, bad match type, missing required match value, negative weight)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/warmup/routing/{id}": { + "patch": { + "operationId": "deliverability-ops_update_routing_rule", + "summary": "Update a warmup routing rule", + "description": "Replaces a rule by ID. The body is the same full payload as create (all fields applied, not deep-merged). Requires WARMUP_ROUTING scope and manage_settings permission.", + "tags": [ + "deliverability-ops" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The rule ID.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WarmupRoutingRuleInput" + } + } + } + }, + "responses": { + "200": { + "description": "Rule updated.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WarmupRoutingRule" + } + } + } + }, + "400": { + "description": "Invalid payload or rule id", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Rule not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "delete": { + "operationId": "deliverability-ops_delete_routing_rule", + "summary": "Delete a warmup routing rule", + "description": "Removes a routing rule by ID. Requires WARMUP_ROUTING scope and manage_settings permission.", + "tags": [ + "deliverability-ops" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The rule ID.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "responses": { + "204": { + "description": "Rule deleted" + }, + "400": { + "description": "Invalid rule id", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Rule not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/templates": { + "get": { + "operationId": "deliverability-ops_list_templates", + "summary": "List reply templates", + "description": "Lists the organization's reply templates, ordered by position. Optional `q` filter matches name and subject (case-insensitive). Not cursor-paginated. Requires READ_TEMPLATES scope and view_campaigns permission.", + "tags": [ + "deliverability-ops" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "q", + "in": "query", + "required": false, + "description": "Optional case-insensitive search over name and subject.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Reply templates under a `data` array.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReplyTemplateList" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "post": { + "operationId": "deliverability-ops_create_template", + "summary": "Create a reply template", + "description": "Creates a reply template owned by the calling user, appended to the end of the org's list. Requires WRITE_TEMPLATES scope and manage_campaigns permission.", + "tags": [ + "deliverability-ops" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateReplyTemplate" + } + } + } + }, + "responses": { + "200": { + "description": "Template created.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReplyTemplate" + } + } + } + }, + "400": { + "description": "Invalid payload (e.g. missing name or name over 255 chars)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/templates/reorder": { + "patch": { + "operationId": "deliverability-ops_reorder_templates", + "summary": "Reorder reply templates", + "description": "Repositions templates to match the supplied ID order (1-indexed). IDs omitted from the list are left untouched. Returns the full reordered list. Requires WRITE_TEMPLATES scope and manage_campaigns permission.", + "tags": [ + "deliverability-ops" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReorderReplyTemplates" + } + } + } + }, + "responses": { + "200": { + "description": "Reordered list under a `data` array.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReplyTemplateList" + } + } + } + }, + "400": { + "description": "Invalid payload (e.g. missing ids)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/templates/score": { + "post": { + "operationId": "deliverability-ops_score_template", + "summary": "Score template content", + "description": "Returns an advisory deliverability content score (0 to 100, higher is safer) for a subject and body, plus the issues found. Advisory only and never blocks sending. Scores content in the request body, not a stored template. Requires READ_TEMPLATES scope and view_campaigns permission.", + "tags": [ + "deliverability-ops" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScoreTemplateRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Content score and advisory issues.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TemplateScoreResult" + } + } + } + }, + "400": { + "description": "Invalid request body", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/templates/{id}": { + "get": { + "operationId": "deliverability-ops_get_template", + "summary": "Get a reply template", + "description": "Retrieves a single reply template by ID. Requires READ_TEMPLATES scope and view_campaigns permission.", + "tags": [ + "deliverability-ops" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The template ID.", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "The reply template.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReplyTemplate" + } + } + } + }, + "400": { + "description": "Invalid template id", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Template not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "patch": { + "operationId": "deliverability-ops_update_template", + "summary": "Update a reply template", + "description": "Updates a reply template. All fields optional; omitted fields are left unchanged. Requires WRITE_TEMPLATES scope and manage_campaigns permission.", + "tags": [ + "deliverability-ops" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The template ID.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateReplyTemplate" + } + } + } + }, + "responses": { + "200": { + "description": "Template updated.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReplyTemplate" + } + } + } + }, + "400": { + "description": "Invalid payload or template id", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Template not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "delete": { + "operationId": "deliverability-ops_delete_template", + "summary": "Delete a reply template", + "description": "Deletes a reply template by ID. Requires WRITE_TEMPLATES scope and manage_campaigns permission.", + "tags": [ + "deliverability-ops" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The template ID.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "responses": { + "204": { + "description": "Template deleted" + }, + "400": { + "description": "Invalid template id", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Template not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/templates/{id}/duplicate": { + "post": { + "operationId": "deliverability-ops_duplicate_template", + "summary": "Duplicate a reply template", + "description": "Clones a template, appending \" (copy)\" to the name and placing the clone at the end of the org's list. Requires WRITE_TEMPLATES scope and manage_campaigns permission.", + "tags": [ + "deliverability-ops" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The source template ID.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "responses": { + "200": { + "description": "The newly created template.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReplyTemplate" + } + } + } + }, + "400": { + "description": "Invalid template id", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Source template not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/templates/{id}/render": { + "post": { + "operationId": "deliverability-ops_render_template", + "summary": "Render a reply template", + "description": "Expands {{.Key}} placeholders in the template's subject and body using a caller-supplied variable map. The body is optional; an empty map renders all placeholders empty. Requires READ_TEMPLATES scope and view_campaigns permission.", + "tags": [ + "deliverability-ops" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The template ID.", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RenderReplyTemplateRequest" + } + } + } + }, + "responses": { + "200": { + "description": "The rendered subject and body.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RenderedReplyTemplate" + } + } + } + }, + "400": { + "description": "Invalid template id or body", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Template not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + } + }, + "components": { + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "description": "A Warmbly API key (wmbly_...) sent as a Bearer token." + } + }, + "parameters": { + "IdempotencyKey": { + "name": "Idempotency-Key", + "in": "header", + "required": false, + "description": "Optional client-generated key (1-255 chars). A retried request with the same key, method, path, and body replays the original response instead of acting twice.", + "schema": { + "type": "string", + "maxLength": 255 + } + } + }, + "schemas": { + "Error": { + "type": "object", + "description": "Standard error envelope returned for every 4xx/5xx response.", + "required": [ + "error", + "code" + ], + "properties": { + "error": { + "type": "string", + "description": "Human-readable message. Do not branch on this." + }, + "message": { + "type": "string", + "description": "Alias of error in some responses." + }, + "code": { + "type": "string", + "description": "Stable machine-readable code, e.g. bad_request, unauthorized, forbidden, not_found, conflict, rate_limit_exceeded, internal_error." + }, + "request_id": { + "type": "string", + "description": "Correlates the response with server logs. Also returned as the X-Request-Id header." + } + } + }, + "Pagination": { + "type": "object", + "description": "Keyset pagination metadata. next_cursor is an opaque token (not a record id).", + "required": [ + "has_more" + ], + "properties": { + "total": { + "type": [ + "integer", + "null" + ], + "description": "Total matching rows when cheaply known, else null." + }, + "next_cursor": { + "type": [ + "string", + "null" + ], + "description": "Opaque cursor for the next page, or null on the last page." + }, + "has_more": { + "type": "boolean" + } + } + }, + "AuthCredentials": { + "type": "object", + "description": "Email/password credentials with a Cloudflare Turnstile token. Used for login start and registration start.", + "required": [ + "email", + "password" + ], + "properties": { + "email": { + "type": "string", + "format": "email", + "description": "Account email address." + }, + "password": { + "type": "string", + "format": "password", + "description": "Account password." + }, + "turnstile": { + "type": "string", + "description": "Cloudflare Turnstile challenge token." + } + } + }, + "ConfirmRequest": { + "type": "object", + "description": "Confirms an in-flight login or registration: the opaque session handle plus the emailed one-time code.", + "required": [ + "session", + "code" + ], + "properties": { + "session": { + "type": "string", + "description": "Opaque session handle from the matching start call." + }, + "code": { + "type": "string", + "description": "One-time confirmation code emailed to the user." + }, + "turnstile": { + "type": "string", + "description": "Cloudflare Turnstile challenge token." + } + } + }, + "AuthSession": { + "type": "object", + "description": "Opaque session handle returned by login/register start; pass it to the matching confirm call.", + "required": [ + "session" + ], + "properties": { + "session": { + "type": "string", + "description": "Opaque session handle." + } + } + }, + "TokenPair": { + "type": "object", + "description": "An access/refresh token pair with absolute expiry timestamps.", + "required": [ + "access_token", + "access_token_expires_at", + "refresh_token", + "refresh_token_expires_at" + ], + "properties": { + "access_token": { + "type": "string", + "description": "Bearer access token for authenticated session requests." + }, + "access_token_expires_at": { + "type": "string", + "format": "date-time", + "description": "When the access token expires." + }, + "refresh_token": { + "type": "string", + "description": "Refresh token used to obtain a new pair." + }, + "refresh_token_expires_at": { + "type": "string", + "format": "date-time", + "description": "When the refresh token expires." + } + } + }, + "LoginResult": { + "type": "object", + "description": "Result of /auth/login/confirm: either a full token pair (the TokenPair fields are present) or a 2FA challenge (two_fa_required=true with a pending_token). The two cases are mutually exclusive.", + "properties": { + "access_token": { + "type": "string", + "description": "Present only when 2FA is not required." + }, + "access_token_expires_at": { + "type": "string", + "format": "date-time" + }, + "refresh_token": { + "type": "string" + }, + "refresh_token_expires_at": { + "type": "string", + "format": "date-time" + }, + "two_fa_required": { + "type": "boolean", + "description": "True when a 2FA challenge must be completed via /auth/2fa/verify." + }, + "pending_token": { + "type": "string", + "description": "Single-use pending token to pass to /auth/2fa/verify. Present only when two_fa_required is true." + }, + "expires_in": { + "type": "integer", + "description": "Seconds until the pending token expires. Present only when two_fa_required is true." + } + } + }, + "RefreshRequest": { + "type": "object", + "description": "Request body for /auth/refresh.", + "required": [ + "refresh_token" + ], + "properties": { + "refresh_token": { + "type": "string", + "description": "A valid, unexpired refresh token." + } + } + }, + "ResetPasswordStartRequest": { + "type": "object", + "description": "Request body for /auth/reset-password.", + "required": [ + "email" + ], + "properties": { + "email": { + "type": "string", + "format": "email", + "description": "Email of the account to reset." + }, + "turnstile": { + "type": "string", + "description": "Cloudflare Turnstile challenge token." + } + } + }, + "ResetPasswordConfirmRequest": { + "type": "object", + "description": "Request body for /auth/reset-password/confirm.", + "required": [ + "session", + "password" + ], + "properties": { + "session": { + "type": "string", + "description": "Opaque reset session handle." + }, + "password": { + "type": "string", + "format": "password", + "description": "The new password." + }, + "turnstile": { + "type": "string", + "description": "Cloudflare Turnstile challenge token." + } + } + }, + "ChangePasswordRequest": { + "type": "object", + "description": "Request body for /auth/me/password.", + "required": [ + "current_password", + "new_password" + ], + "properties": { + "current_password": { + "type": "string", + "format": "password", + "description": "The user's current password." + }, + "new_password": { + "type": "string", + "format": "password", + "description": "The new password." + } + } + }, + "UpdateProfileRequest": { + "type": "object", + "description": "Editable profile fields for the authenticated user.", + "properties": { + "first_name": { + "type": "string" + }, + "last_name": { + "type": "string" + } + } + }, + "TwoFAVerifyRequest": { + "type": "object", + "description": "Request body for /auth/2fa/verify.", + "required": [ + "pending_token", + "code" + ], + "properties": { + "pending_token": { + "type": "string", + "description": "Single-use pending token from the login result." + }, + "code": { + "type": "string", + "description": "A current TOTP code or a recovery code." + } + } + }, + "TwoFACodeRequest": { + "type": "object", + "description": "A single TOTP or recovery code. Used to confirm enrollment or disable 2FA.", + "properties": { + "code": { + "type": "string", + "description": "A current TOTP code or a recovery code." + } + } + }, + "TwoFAStatus": { + "type": "object", + "description": "Whether 2FA is enabled for the user.", + "required": [ + "enabled" + ], + "properties": { + "enabled": { + "type": "boolean" + } + } + }, + "TwoFAEnrollStart": { + "type": "object", + "description": "The TOTP secret and otpauth provisioning URI, returned once at enrollment start.", + "required": [ + "secret", + "otpauth_uri" + ], + "properties": { + "secret": { + "type": "string", + "description": "Base32 (no padding) TOTP secret." + }, + "otpauth_uri": { + "type": "string", + "description": "otpauth://totp/... provisioning URI for authenticator apps." + } + } + }, + "TwoFARecoveryCodes": { + "type": "object", + "description": "One-time recovery codes, returned once when 2FA is enabled.", + "required": [ + "recovery_codes" + ], + "properties": { + "recovery_codes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Single-use recovery codes." + } + } + }, + "OkResponse": { + "type": "object", + "description": "Generic success acknowledgement.", + "required": [ + "ok" + ], + "properties": { + "ok": { + "type": "boolean" + } + } + }, + "PasskeyLoginChallenge": { + "type": "object", + "description": "WebAuthn assertion options plus the opaque login session handle for /auth/passkey/login/finish. The publicKey options follow the WebAuthn PublicKeyCredentialRequestOptions shape.", + "properties": { + "session": { + "type": "string", + "description": "Opaque login session handle." + }, + "publicKey": { + "type": "object", + "additionalProperties": true, + "description": "WebAuthn PublicKeyCredentialRequestOptions (passed to navigator.credentials.get)." + } + } + }, + "PasskeyLoginFinishRequest": { + "type": "object", + "description": "Request body for /auth/passkey/login/finish.", + "required": [ + "session", + "credential" + ], + "properties": { + "session": { + "type": "string", + "description": "Opaque login session handle from begin." + }, + "credential": { + "type": "object", + "additionalProperties": true, + "description": "The WebAuthn assertion (PublicKeyCredential JSON) from navigator.credentials.get." + } + } + }, + "PasskeyRenameRequest": { + "type": "object", + "description": "Request body for renaming a passkey.", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "description": "New display name for the passkey." + } + } + }, + "PasskeyCredential": { + "type": "object", + "description": "A registered passkey (WebAuthn credential).", + "required": [ + "id", + "name", + "credential_id", + "transports", + "backup_state", + "created_at" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string", + "description": "User-assigned display name." + }, + "provider": { + "type": "string", + "description": "Originating provider, when known." + }, + "credential_id": { + "type": "string", + "description": "The WebAuthn credential id." + }, + "transports": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Supported authenticator transports (e.g. internal, usb, hybrid)." + }, + "backup_state": { + "type": "boolean", + "description": "Whether the credential is backed up / multi-device." + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "last_used_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + } + } + }, + "PasskeyCredentialList": { + "type": "array", + "description": "A plain array of registered passkeys (not a paginated wrapper).", + "items": { + "$ref": "#/components/schemas/PasskeyCredential" + } + }, + "Session": { + "type": "object", + "description": "A customer-facing view of an authenticated session.", + "required": [ + "id", + "current", + "auth_provider", + "created_at", + "last_active_at" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "current": { + "type": "boolean", + "description": "True if this is the session making the request." + }, + "browser": { + "type": "string" + }, + "os": { + "type": "string" + }, + "location_city": { + "type": "string" + }, + "location_region": { + "type": "string" + }, + "location_country": { + "type": "string" + }, + "country_code": { + "type": "string" + }, + "auth_provider": { + "type": "string", + "description": "How this session authenticated: email, google, apple, or webauthn." + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "last_active_at": { + "type": "string", + "format": "date-time" + } + } + }, + "SessionList": { + "type": "array", + "description": "A plain array of active sessions (not a paginated wrapper); the current session is floated to the top.", + "items": { + "$ref": "#/components/schemas/Session" + } + }, + "UserLabelGroup": { + "type": "object", + "description": "A per-user label group (folder, tag, or category).", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + } + }, + "additionalProperties": true + }, + "User": { + "type": "object", + "description": "The authenticated user profile returned by /auth/me.", + "required": [ + "id", + "first_name", + "last_name", + "email", + "roles", + "is_admin", + "folders", + "tags", + "categories", + "created_at", + "updated_at" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "first_name": { + "type": "string" + }, + "last_name": { + "type": "string" + }, + "email": { + "type": "string", + "format": "email" + }, + "avatar_url": { + "type": [ + "string", + "null" + ] + }, + "roles": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "description": "Organization role ids assigned to the user." + }, + "referral_source": { + "type": [ + "string", + "null" + ] + }, + "onboarding_completed_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "max_organizations": { + "type": "integer" + }, + "free_trial_used": { + "type": "boolean" + }, + "admin_permissions": { + "type": "integer", + "description": "Raw platform-admin permission bitmask." + }, + "is_admin": { + "type": "boolean", + "description": "True if the user has any platform-admin permission." + }, + "deletion_scheduled_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "deletion_scheduled_for": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "folders": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserLabelGroup" + }, + "description": "Per-user folders (always an array)." + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserLabelGroup" + }, + "description": "Per-user tags (always an array)." + }, + "categories": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserLabelGroup" + }, + "description": "Per-user categories (always an array)." + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "Mailbox": { + "type": "object", + "description": "A connected sender mailbox (email account).", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "user_id": { + "type": "string", + "format": "uuid" + }, + "organization_id": { + "type": "string", + "format": "uuid" + }, + "worker_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "email": { + "type": "string", + "description": "The mailbox address." + }, + "name": { + "type": "string", + "description": "Display name on outgoing mail." + }, + "signature_plain": { + "type": "string" + }, + "signature_html": { + "type": "string" + }, + "signature_sync": { + "type": "boolean" + }, + "signature_code": { + "type": "boolean" + }, + "provider": { + "type": "string", + "enum": [ + "gmail", + "outlook", + "smtp_imap" + ] + }, + "status": { + "type": "string", + "enum": [ + "active", + "inactive", + "revoked" + ] + }, + "last_synced_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "last_id": { + "type": "integer" + }, + "campaign_limit": { + "type": "integer", + "description": "Daily cold-campaign cap for this mailbox." + }, + "min_wait_time": { + "type": "integer", + "description": "Minimum seconds between sends." + }, + "reply_to": { + "type": "string" + }, + "tracking_domain": { + "type": "string" + }, + "tracking_domain_verified": { + "type": "boolean" + }, + "tracking_domain_verified_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "warmup": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "Warmup anchor timestamp; null when warmup has never been enabled." + }, + "warmup_paused_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "Non-null when warmup is enabled but paused." + }, + "warmup_base": { + "type": "integer" + }, + "warmup_max": { + "type": "integer" + }, + "warmup_increase": { + "type": "integer" + }, + "warmup_reply_rate": { + "type": "integer" + }, + "warmup_tag": { + "type": "string" + }, + "warmup_pool_type": { + "type": "string", + "enum": [ + "free", + "premium" + ] + }, + "warmup_start_time": { + "type": "string", + "description": "Daily warmup window start, HH:MM." + }, + "warmup_end_time": { + "type": "string", + "description": "Daily warmup window end, HH:MM." + }, + "warmup_days": { + "type": "integer" + }, + "timezone": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "email", + "provider", + "status", + "campaign_limit", + "min_wait_time", + "created_at", + "updated_at" + ] + }, + "MailboxList": { + "type": "object", + "description": "A page of mailboxes.", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Mailbox" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + } + }, + "required": [ + "data", + "pagination" + ] + }, + "MailboxUpdate": { + "type": "object", + "description": "Mailbox settings patch. All fields optional; only present fields are applied.", + "properties": { + "name": { + "type": "string", + "description": "Display name on outgoing mail." + }, + "signature_plain": { + "type": "string" + }, + "signature_html": { + "type": "string" + }, + "signature_sync": { + "type": "boolean" + }, + "signature_code": { + "type": "boolean", + "description": "Treat the HTML signature as raw code." + }, + "status": { + "type": "string", + "enum": [ + "active", + "inactive", + "revoked" + ] + }, + "campaign_limit": { + "type": "integer", + "description": "Daily cold-campaign cap (validated up to 100)." + }, + "min_wait_time": { + "type": "integer", + "description": "Minimum seconds between sends." + }, + "reply_to": { + "type": "string" + }, + "warmup": { + "type": "boolean", + "description": "Enable or disable warmup." + }, + "warmup_base": { + "type": "integer", + "description": "Warmup starting volume per day." + }, + "warmup_max": { + "type": "integer", + "description": "Warmup daily ceiling." + }, + "warmup_increase": { + "type": "integer", + "description": "Per-day warmup ramp increment." + }, + "warmup_reply_rate": { + "type": "integer", + "description": "Percentage of warmup threads to reply to." + }, + "warmup_tag": { + "type": "string" + }, + "warmup_start_time": { + "type": "string", + "description": "Daily warmup window start, HH:MM." + }, + "warmup_end_time": { + "type": "string", + "description": "Daily warmup window end, HH:MM." + }, + "warmup_days": { + "type": "integer" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tag ids assigned to the mailbox." + } + } + }, + "MailboxTrackingDomain": { + "type": "object", + "description": "The resolved custom tracking-domain state for a mailbox.", + "properties": { + "tracking_domain": { + "type": "string" + }, + "tracking_domain_verified": { + "type": "boolean" + }, + "tracking_domain_verified_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "Null until the CNAME resolves to the tracking host." + } + }, + "required": [ + "tracking_domain", + "tracking_domain_verified" + ] + }, + "MailboxAuthCheck": { + "type": "object", + "description": "On-demand SPF/DKIM/DMARC check for the mailbox's sending domain.", + "properties": { + "domain": { + "type": "string" + }, + "spf_found": { + "type": "boolean" + }, + "spf_record": { + "type": "string", + "description": "Omitted when SPF is not found." + }, + "dkim_found": { + "type": "boolean" + }, + "dkim_selectors": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Omitted when DKIM is not found." + }, + "dmarc_found": { + "type": "boolean" + }, + "dmarc_policy": { + "type": "string", + "description": "Omitted when DMARC is not found." + }, + "all_aligned": { + "type": "boolean" + }, + "summary": { + "type": "string" + } + }, + "required": [ + "domain", + "spf_found", + "dkim_found", + "dmarc_found", + "all_aligned", + "summary" + ] + }, + "MailboxVerifyRequest": { + "type": "object", + "description": "Address to verify. Required if the email query param is not set.", + "properties": { + "email": { + "type": "string", + "description": "The address to verify." + } + } + }, + "MailboxVerifyResult": { + "type": "object", + "description": "Result of a single-address verification.", + "properties": { + "email": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "valid", + "risky", + "invalid", + "unknown" + ] + }, + "reason": { + "type": "string" + }, + "is_catch_all": { + "type": "boolean" + }, + "has_mx": { + "type": "boolean" + }, + "checked_at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "email", + "status", + "reason", + "is_catch_all", + "has_mx", + "checked_at" + ] + }, + "MailboxWarmupBanStatus": { + "type": "object", + "description": "Whether a mailbox is blocked from the shared warmup pool.", + "properties": { + "email_account_id": { + "type": "string", + "format": "uuid" + }, + "blocked": { + "type": "boolean" + }, + "health_state": { + "type": "string", + "description": "Rolling warmup health.", + "enum": [ + "healthy", + "watch", + "throttled", + "quarantined", + "blocked" + ] + }, + "reason": { + "type": "string", + "description": "Omitted when not blocked." + }, + "blocked_at": { + "type": "string", + "format": "date-time", + "description": "Omitted when not blocked." + }, + "blocked_until": { + "type": "string", + "format": "date-time", + "description": "Omitted when not blocked." + }, + "can_appeal": { + "type": "boolean" + }, + "pending_appeal": { + "type": "boolean" + } + }, + "required": [ + "email_account_id", + "blocked", + "health_state", + "can_appeal", + "pending_appeal" + ] + }, + "MailboxWarmupAppealRequest": { + "type": "object", + "description": "A warmup-ban appeal.", + "properties": { + "reason": { + "type": "string", + "description": "The owner's explanation for the appeal." + } + } + }, + "MailboxWarmupAppealResult": { + "type": "object", + "properties": { + "appeal_id": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "appeal_id" + ] + }, + "MailboxSendRequest": { + "type": "object", + "description": "A one-off send from a specific mailbox.", + "properties": { + "to": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Recipient addresses." + }, + "cc": { + "type": "array", + "items": { + "type": "string" + } + }, + "bcc": { + "type": "array", + "items": { + "type": "string" + } + }, + "subject": { + "type": "string" + }, + "body_html": { + "type": "string" + }, + "body_plain": { + "type": "string" + }, + "in_reply_to": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Message ids this email replies to." + }, + "thread_id": { + "type": "string", + "description": "Thread id to attach the message to." + }, + "send_mode": { + "type": "string", + "enum": [ + "instant", + "smart", + "scheduled" + ], + "default": "instant", + "description": "instant (default), smart (next per-mailbox scheduler gap), or scheduled (use scheduled_at)." + }, + "scheduled_at": { + "type": "string", + "format": "date-time", + "description": "Required when send_mode is scheduled. Must be in the future." + } + }, + "required": [ + "to", + "subject" + ] + }, + "MailboxSendResult": { + "type": "object", + "description": "The queued send task.", + "properties": { + "task_id": { + "type": "string", + "format": "uuid", + "description": "Identifies the queued send task." + }, + "scheduled_at": { + "type": "string", + "format": "date-time", + "description": "Resolved dispatch time." + }, + "send_mode": { + "type": "string", + "enum": [ + "instant", + "smart", + "scheduled" + ] + } + }, + "required": [ + "task_id", + "scheduled_at", + "send_mode" + ] + }, + "CampaignStatus": { + "type": "string", + "description": "Campaign lifecycle status.", + "enum": [ + "draft", + "scheduled", + "active", + "paused", + "paused_no_accounts", + "completed", + "stopped" + ] + }, + "ScheduleWindows": { + "type": "array", + "description": "Per-day sending schedule. 7-element array indexed by time.Weekday (Sunday = 0); each day is a list of {start, end} minute intervals. When non-empty it supersedes days/start_time/end_time.", + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "start": { + "type": "integer", + "description": "Minutes from midnight." + }, + "end": { + "type": "integer", + "description": "Minutes from midnight." + } + } + } + } + }, + "Campaign": { + "type": "object", + "required": [ + "id", + "name", + "status", + "created_at", + "updated_at" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "user_id": { + "type": "string", + "description": "Creator user id." + }, + "organization_id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/CampaignStatus" + }, + "stop_on_reply": { + "type": "boolean" + }, + "open_tracking": { + "type": "boolean" + }, + "link_tracking": { + "type": "boolean" + }, + "text_only": { + "type": "boolean" + }, + "daily_limit": { + "type": "integer" + }, + "unsubscribe_header": { + "type": "boolean" + }, + "risky_emails": { + "type": "boolean" + }, + "cc": { + "type": "array", + "items": { + "type": "string" + } + }, + "bcc": { + "type": "array", + "items": { + "type": "string" + } + }, + "start_date": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "end_date": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "timezone": { + "type": "string", + "description": "IANA timezone." + }, + "days": { + "type": "integer", + "description": "Legacy weekday bitmask (0-127), superseded by schedule_windows." + }, + "start_time": { + "type": "string", + "description": "Legacy daily start (HH:MM)." + }, + "end_time": { + "type": "string", + "description": "Legacy daily end (HH:MM)." + }, + "schedule_windows": { + "$ref": "#/components/schemas/ScheduleWindows" + }, + "email_tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Mailbox tag ids resolving the sender pool (tags strategy)." + }, + "folders": { + "type": "array", + "items": { + "type": "string" + } + }, + "contact_order_by": { + "type": "string" + }, + "contact_order_dir": { + "type": "string", + "enum": [ + "asc", + "desc" + ] + }, + "contact_order_field": { + "type": [ + "string", + "null" + ] + }, + "sender_strategy": { + "type": "string", + "enum": [ + "tags", + "explicit" + ] + }, + "rotation_mode": { + "type": "string", + "description": "How volume spreads across mailboxes." + }, + "ramp_enabled": { + "type": "boolean" + }, + "ramp_start": { + "type": "integer" + }, + "ramp_increment": { + "type": "integer" + }, + "ramp_ceiling": { + "type": "integer" + }, + "ramp_level": { + "type": "integer", + "description": "Server-managed current ramp level." + }, + "esp_match_mode": { + "type": "string", + "enum": [ + "off", + "prefer", + "strict" + ] + }, + "max_new_leads_per_day": { + "type": "integer", + "description": "0 = unlimited." + }, + "prioritize_new_leads": { + "type": "boolean" + }, + "tracking_domain": { + "type": "string" + }, + "tracking_domain_verified": { + "type": "boolean" + }, + "tracking_domain_verified_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "last_status_change_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "created_at": { + "type": "string", + "format": "date-time" + } + } + }, + "CampaignList": { + "type": "object", + "required": [ + "data", + "pagination" + ], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Campaign" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + } + } + }, + "CampaignCreate": { + "type": "object", + "required": [ + "name" + ], + "description": "Only name is required; every other field is optional and applied only when sent.", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "stop_on_reply": { + "type": "boolean" + }, + "open_tracking": { + "type": "boolean" + }, + "link_tracking": { + "type": "boolean" + }, + "text_only": { + "type": "boolean" + }, + "daily_limit": { + "type": "integer" + }, + "unsubscribe_header": { + "type": "boolean" + }, + "risky_emails": { + "type": "boolean" + }, + "cc": { + "type": "array", + "items": { + "type": "string" + } + }, + "bcc": { + "type": "array", + "items": { + "type": "string" + } + }, + "start_date": { + "type": "string", + "format": "date-time" + }, + "end_date": { + "type": "string", + "format": "date-time" + }, + "timezone": { + "type": "string" + }, + "days": { + "type": "integer", + "description": "Legacy weekday bitmask (0-127), superseded by schedule_windows." + }, + "start_time": { + "type": "string", + "description": "Legacy daily start (HH:MM)." + }, + "end_time": { + "type": "string", + "description": "Legacy daily end (HH:MM)." + }, + "schedule_windows": { + "$ref": "#/components/schemas/ScheduleWindows" + }, + "email_tag_ids": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Mailbox tag ids that resolve the sender pool (tags strategy)." + }, + "folder_ids": { + "type": "array", + "items": { + "type": "string" + } + }, + "sender_strategy": { + "type": "string", + "enum": [ + "tags", + "explicit" + ] + }, + "rotation_mode": { + "type": "string" + }, + "senders": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CampaignSenderInput" + }, + "description": "Explicit-strategy mailbox pool." + }, + "ramp_enabled": { + "type": "boolean" + }, + "ramp_start": { + "type": "integer" + }, + "ramp_increment": { + "type": "integer" + }, + "ramp_ceiling": { + "type": "integer" + }, + "esp_match_mode": { + "type": "string", + "enum": [ + "off", + "prefer", + "strict" + ] + }, + "max_new_leads_per_day": { + "type": "integer", + "description": "0 = unlimited." + }, + "prioritize_new_leads": { + "type": "boolean" + }, + "tracking_domain": { + "type": "string" + }, + "sequences": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CampaignStepCreate" + }, + "description": "Initial sequence steps in order." + }, + "variants": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CampaignABVariantCreate" + }, + "description": "A/B variants for the first step." + }, + "advanced_overrides": { + "$ref": "#/components/schemas/AdvancedOutreachSettings" + } + } + }, + "CampaignUpdate": { + "type": "object", + "description": "Every field optional; any field sent is applied, omitted fields unchanged.", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/CampaignStatus" + }, + "stop_on_reply": { + "type": "boolean" + }, + "open_tracking": { + "type": "boolean" + }, + "link_tracking": { + "type": "boolean" + }, + "text_only": { + "type": "boolean" + }, + "daily_limit": { + "type": "integer" + }, + "unsubscribe_header": { + "type": "boolean" + }, + "risky_emails": { + "type": "boolean" + }, + "cc": { + "type": "array", + "items": { + "type": "string" + } + }, + "bcc": { + "type": "array", + "items": { + "type": "string" + } + }, + "start_date": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "end_date": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "timezone": { + "type": "string" + }, + "days": { + "type": "integer" + }, + "start_time": { + "type": "string" + }, + "end_time": { + "type": "string" + }, + "schedule_windows": { + "$ref": "#/components/schemas/ScheduleWindows" + }, + "email_tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "folders": { + "type": "array", + "items": { + "type": "string" + } + }, + "contact_order_by": { + "type": "string" + }, + "contact_order_dir": { + "type": "string", + "enum": [ + "asc", + "desc" + ] + }, + "contact_order_field": { + "type": "string" + }, + "sender_strategy": { + "type": "string", + "enum": [ + "tags", + "explicit" + ] + }, + "rotation_mode": { + "type": "string" + }, + "ramp_enabled": { + "type": "boolean" + }, + "ramp_start": { + "type": "integer" + }, + "ramp_increment": { + "type": "integer" + }, + "ramp_ceiling": { + "type": "integer" + }, + "esp_match_mode": { + "type": "string", + "enum": [ + "off", + "prefer", + "strict" + ] + }, + "max_new_leads_per_day": { + "type": "integer" + }, + "prioritize_new_leads": { + "type": "boolean" + }, + "tracking_domain": { + "type": "string" + } + } + }, + "CampaignSender": { + "type": "object", + "required": [ + "email_account_id", + "weight", + "enabled" + ], + "properties": { + "email_account_id": { + "type": "string", + "format": "uuid" + }, + "weight": { + "type": "integer" + }, + "last_sent_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "enabled": { + "type": "boolean" + } + } + }, + "CampaignSenderInput": { + "type": "object", + "required": [ + "email_account_id" + ], + "properties": { + "email_account_id": { + "type": "string", + "format": "uuid" + }, + "weight": { + "type": "integer" + }, + "enabled": { + "type": "boolean" + } + } + }, + "CampaignSenderList": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CampaignSender" + } + } + } + }, + "CampaignSendersReplace": { + "type": "object", + "required": [ + "senders" + ], + "properties": { + "senders": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CampaignSenderInput" + }, + "description": "The full new sender pool." + } + } + }, + "CampaignStep": { + "type": "object", + "description": "A sequence step (email or action/condition node).", + "required": [ + "id", + "kind", + "position", + "created_at", + "updated_at" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "subject": { + "type": "string" + }, + "body_plain": { + "type": "string" + }, + "body_html": { + "type": "string" + }, + "body_sync": { + "type": "boolean", + "description": "Keep plain and HTML bodies in sync." + }, + "body_code": { + "type": "boolean", + "description": "Treat the body as raw code." + }, + "wait_after": { + "type": "integer", + "description": "Minutes to wait after this step before the next." + }, + "position": { + "type": "integer" + }, + "kind": { + "type": "string", + "enum": [ + "email", + "action", + "wait" + ] + }, + "conditions": { + "type": "object", + "description": "Branching tree ({branches: [...]}).", + "additionalProperties": true + }, + "action": { + "type": "object", + "description": "Typed config for non-email nodes.", + "additionalProperties": true + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "created_at": { + "type": "string", + "format": "date-time" + } + } + }, + "CampaignStepCreate": { + "type": "object", + "description": "Initial step shape used inside campaign create (sequences[]).", + "properties": { + "name": { + "type": "string" + }, + "subject": { + "type": "string" + }, + "body_plain": { + "type": "string" + }, + "body_html": { + "type": "string" + }, + "wait_after": { + "type": "integer" + }, + "kind": { + "type": "string", + "enum": [ + "email", + "action", + "wait" + ] + } + } + }, + "CampaignStepUpdate": { + "type": "object", + "description": "Patch a step. All fields optional.", + "properties": { + "name": { + "type": "string" + }, + "subject": { + "type": "string" + }, + "body_plain": { + "type": "string" + }, + "body_html": { + "type": "string" + }, + "body_sync": { + "type": "boolean" + }, + "body_code": { + "type": "boolean" + }, + "wait_after": { + "type": "integer", + "description": "Minutes to wait after this step (spacing model; no standalone wait node for email steps)." + }, + "conditions": { + "type": "object", + "description": "Branching tree ({branches: [...]}). Send {} or empty branches to clear branching.", + "additionalProperties": true + }, + "kind": { + "type": "string", + "enum": [ + "email", + "action", + "wait" + ] + }, + "action": { + "type": "object", + "description": "Typed config for non-email nodes; type is the switch (wait, add_tag, remove_tag, unsubscribe, notify, create_task, create_deal, move_deal_stage, run_automation, end).", + "additionalProperties": true + } + } + }, + "CampaignABVariant": { + "type": "object", + "required": [ + "id", + "campaign_id", + "name", + "created_at", + "updated_at" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "campaign_id": { + "type": "string", + "format": "uuid" + }, + "step_id": { + "type": [ + "string", + "null" + ], + "format": "uuid", + "description": "Step the variant is scoped to; null is campaign-level." + }, + "name": { + "type": "string" + }, + "weight": { + "type": "integer" + }, + "subject": { + "type": "string" + }, + "body_html": { + "type": "string" + }, + "body_plain": { + "type": "string" + }, + "is_control": { + "type": "boolean" + }, + "is_active": { + "type": "boolean" + }, + "metadata": { + "type": "object", + "additionalProperties": true + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "CampaignABVariantList": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CampaignABVariant" + } + } + } + }, + "CampaignABVariantCreate": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + }, + "step_id": { + "type": "string", + "format": "uuid", + "description": "Step to scope the variant to; omit for campaign-level." + }, + "weight": { + "type": "integer" + }, + "subject": { + "type": "string" + }, + "body_html": { + "type": "string" + }, + "body_plain": { + "type": "string" + }, + "is_control": { + "type": "boolean" + }, + "is_active": { + "type": "boolean" + }, + "metadata": { + "type": "object", + "additionalProperties": true + } + } + }, + "CampaignABVariantUpdate": { + "type": "object", + "description": "Patch a variant. All fields optional.", + "properties": { + "name": { + "type": "string" + }, + "weight": { + "type": "integer" + }, + "subject": { + "type": "string" + }, + "body_html": { + "type": "string" + }, + "body_plain": { + "type": "string" + }, + "is_control": { + "type": "boolean" + }, + "is_active": { + "type": "boolean" + }, + "metadata": { + "type": "object", + "additionalProperties": true + } + } + }, + "ABWinnerAnalysis": { + "type": "object", + "required": [ + "campaign_id", + "variants" + ], + "properties": { + "campaign_id": { + "type": "string", + "format": "uuid" + }, + "variants": { + "type": "array", + "items": { + "type": "object", + "properties": { + "variant_id": { + "type": "string", + "format": "uuid" + }, + "variant_name": { + "type": "string" + }, + "total_sent": { + "type": "integer" + }, + "opened": { + "type": "integer" + }, + "clicked": { + "type": "integer" + }, + "replied": { + "type": "integer" + }, + "bounced": { + "type": "integer" + }, + "open_rate": { + "type": "number" + }, + "click_rate": { + "type": "number" + }, + "reply_rate": { + "type": "number" + }, + "bounce_rate": { + "type": "number" + } + } + } + }, + "winner_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "winner_name": { + "type": [ + "string", + "null" + ] + }, + "winning_rule": { + "type": "string" + }, + "confidence": { + "type": "string", + "description": "Winner confidence (e.g. low, medium, high)." + } + } + }, + "CampaignAttachment": { + "type": "object", + "required": [ + "id", + "campaign_id", + "filename", + "created_at" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "campaign_id": { + "type": "string", + "format": "uuid" + }, + "step_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "filename": { + "type": "string" + }, + "size": { + "type": "integer", + "description": "Size in bytes." + }, + "mime_type": { + "type": "string" + }, + "url": { + "type": "string", + "description": "Short-lived presigned download URL." + }, + "created_at": { + "type": "string", + "format": "date-time" + } + } + }, + "CampaignAttachmentList": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CampaignAttachment" + } + } + } + }, + "CampaignLog": { + "type": "object", + "required": [ + "id", + "campaign_id", + "event_type", + "created_at" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "campaign_id": { + "type": "string", + "format": "uuid" + }, + "event_type": { + "type": "string", + "description": "e.g. campaign_started." + }, + "message": { + "type": "string" + }, + "metadata": { + "type": "object", + "additionalProperties": true + }, + "created_at": { + "type": "string", + "format": "date-time" + } + } + }, + "CampaignLogList": { + "type": "object", + "required": [ + "data", + "pagination" + ], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CampaignLog" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + } + } + }, + "CampaignAdvancedSettings": { + "type": "object", + "required": [ + "campaign_id", + "overrides" + ], + "properties": { + "campaign_id": { + "type": "string", + "format": "uuid" + }, + "overrides": { + "$ref": "#/components/schemas/AdvancedOutreachSettings" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "CampaignAdvancedUpdate": { + "type": "object", + "required": [ + "settings" + ], + "properties": { + "settings": { + "$ref": "#/components/schemas/AdvancedOutreachSettings" + } + } + }, + "AdvancedOutreachSettings": { + "type": "object", + "description": "Advanced outreach overrides for a campaign.", + "properties": { + "bounce_pipeline": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "auto_suppress_on_bounce": { + "type": "boolean" + }, + "auto_suppress_on_complaint": { + "type": "boolean" + }, + "auto_suppress_on_unsubscribe": { + "type": "boolean" + }, + "auto_pause_campaign_on_spike": { + "type": "boolean" + }, + "pause_bounce_rate_threshold": { + "type": "number" + }, + "pause_complaint_rate_threshold": { + "type": "number" + } + } + }, + "task_reliability": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "dlq_enabled": { + "type": "boolean" + }, + "max_attempts": { + "type": "integer" + }, + "execution_window_seconds": { + "type": "integer" + } + } + }, + "ab_testing": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "default_winning_rule": { + "type": "string" + }, + "auto_promote_winner": { + "type": "boolean" + }, + "min_sample_size": { + "type": "integer" + } + } + }, + "reply_intent": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "positive_keywords": { + "type": "array", + "items": { + "type": "string" + } + }, + "negative_keywords": { + "type": "array", + "items": { + "type": "string" + } + }, + "out_of_office_keywords": { + "type": "array", + "items": { + "type": "string" + } + }, + "question_keywords": { + "type": "array", + "items": { + "type": "string" + } + }, + "auto_create_crm_task": { + "type": "boolean" + }, + "auto_pause_on_negative": { + "type": "boolean" + }, + "auto_suppress_on_unsubscribe_keyword": { + "type": "boolean" + } + } + }, + "send_time_optimization": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "use_contact_timezone": { + "type": "boolean" + }, + "default_contact_timezone": { + "type": "string" + }, + "preferred_hours": { + "type": "array", + "items": { + "type": "integer" + } + }, + "weekend_weight_multiplier": { + "type": "number" + } + } + }, + "preflight": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "check_tracking_domain": { + "type": "boolean" + }, + "check_unsubscribe_header": { + "type": "boolean" + }, + "check_ab_variant_configured": { + "type": "boolean" + }, + "check_daily_limit": { + "type": "boolean" + }, + "check_schedule_window": { + "type": "boolean" + } + } + }, + "dashboard": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "show_suppression_log": { + "type": "boolean" + }, + "show_intent_summary": { + "type": "boolean" + }, + "show_dlq_stats": { + "type": "boolean" + } + } + } + } + }, + "PreflightReport": { + "type": "object", + "required": [ + "campaign_id", + "passed", + "score", + "checks" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "organization_id": { + "type": "string", + "format": "uuid" + }, + "campaign_id": { + "type": "string", + "format": "uuid" + }, + "passed": { + "type": "boolean" + }, + "score": { + "type": "integer" + }, + "checks": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "passed": { + "type": "boolean" + }, + "severity": { + "type": "string", + "description": "e.g. warning, error." + }, + "message": { + "type": "string" + }, + "remediation": { + "type": "string" + } + } + } + }, + "recommendations": { + "type": "array", + "items": { + "type": "string" + } + }, + "created_at": { + "type": "string", + "format": "date-time" + } + } + }, + "CampaignTestEmailRequest": { + "type": "object", + "required": [ + "account_id", + "recipient" + ], + "properties": { + "account_id": { + "type": "string", + "format": "uuid", + "description": "Sending mailbox id." + }, + "recipient": { + "type": "string", + "format": "email", + "description": "Where to send the test." + }, + "step_id": { + "type": "string", + "format": "uuid", + "description": "Step to render and send; defaults to the first step." + } + } + }, + "CampaignTestEmailResult": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "recipient": { + "type": "string", + "format": "email" + }, + "subject": { + "type": "string" + }, + "account_id": { + "type": "string", + "format": "uuid" + } + } + }, + "CampaignStartResult": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "started" + } + } + }, + "CampaignStopResult": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "stopped" + } + } + }, + "TrackingDomainStatus": { + "type": "object", + "properties": { + "tracking_domain": { + "type": "string" + }, + "tracking_domain_verified": { + "type": "boolean" + }, + "tracking_domain_verified_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + } + } + }, + "TemplatePreviewRequest": { + "type": "object", + "properties": { + "subject": { + "type": "string" + }, + "body_html": { + "type": "string" + }, + "body_plain": { + "type": "string" + }, + "contact": { + "type": "object", + "description": "Override fields on the built-in sample contact.", + "properties": { + "first_name": { + "type": "string" + }, + "last_name": { + "type": "string" + }, + "email": { + "type": "string", + "format": "email" + }, + "company": { + "type": "string" + }, + "phone": { + "type": "string" + }, + "custom_fields": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "TemplatePreview": { + "type": "object", + "properties": { + "subject": { + "type": "string" + }, + "body_html": { + "type": "string" + }, + "body_plain": { + "type": "string" + }, + "errors": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Template parse errors that would block sending. Omitted when empty." + }, + "unresolved": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Literal {{...}} tokens left after render. Omitted when empty." + } + } + }, + "GenerationWriteRequest": { + "type": "object", + "required": [ + "prompt" + ], + "properties": { + "prompt": { + "type": "string", + "maxLength": 8000, + "description": "The instruction to generate from." + }, + "tone": { + "type": "string", + "description": "Desired tone (e.g. friendly, direct)." + } + } + }, + "GenerationWriteResult": { + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "credits_remaining": { + "type": "integer" + }, + "model": { + "type": "string" + } + } + }, + "MiniCategory": { + "type": "object", + "description": "Denormalised category chip attached to a contact.", + "required": [ + "id", + "title", + "color" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "title": { + "type": "string" + }, + "color": { + "type": "string", + "description": "Hex color, e.g. #0ea5e9." + } + } + }, + "MiniCampaign": { + "type": "object", + "description": "Denormalised campaign reference attached to a contact.", + "required": [ + "id", + "name" + ], + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "ContactCampaignProgress": { + "type": "object", + "description": "A contact's aggregate processing state inside one campaign. Present on search results only when filtering by exactly one campaign.", + "required": [ + "status", + "sent", + "opened", + "clicked", + "replied", + "bounced" + ], + "properties": { + "status": { + "type": "string", + "enum": [ + "pending", + "active", + "replied", + "bounced", + "unsubscribed" + ] + }, + "sent": { + "type": "integer" + }, + "opened": { + "type": "integer" + }, + "clicked": { + "type": "integer" + }, + "replied": { + "type": "integer" + }, + "bounced": { + "type": "integer" + }, + "current_step": { + "type": "string", + "description": "Label of the step the contact is on now. Empty when nothing sent yet." + }, + "last_activity_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + } + } + }, + "Contact": { + "type": "object", + "description": "A contact record.", + "required": [ + "id", + "first_name", + "last_name", + "email", + "company", + "phone", + "custom_fields", + "subscribed", + "campaigns", + "categories", + "verification_status", + "verification_reason", + "is_catch_all", + "esp_provider", + "updated_at", + "created_at" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "first_name": { + "type": "string" + }, + "last_name": { + "type": "string" + }, + "email": { + "type": "string", + "format": "email" + }, + "company": { + "type": "string" + }, + "phone": { + "type": "string" + }, + "custom_fields": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Arbitrary string key/value custom fields." + }, + "subscribed": { + "type": "boolean" + }, + "campaigns": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MiniCampaign" + } + }, + "categories": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MiniCategory" + } + }, + "verification_status": { + "type": "string", + "enum": [ + "valid", + "risky", + "invalid", + "unknown" + ], + "description": "Pre-send verification state." + }, + "verification_reason": { + "type": "string" + }, + "is_catch_all": { + "type": "boolean" + }, + "verification_checked_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "esp_provider": { + "type": "string", + "description": "Recipient ESP derived from the domain: '' | gmail | outlook | other." + }, + "esp_resolved_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "campaign_lead": { + "allOf": [ + { + "$ref": "#/components/schemas/ContactCampaignProgress" + } + ], + "description": "Present only when search filters by exactly one campaign." + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "created_at": { + "type": "string", + "format": "date-time" + } + } + }, + "ContactList": { + "type": "object", + "description": "A page of contacts.", + "required": [ + "data", + "pagination" + ], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Contact" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + } + } + }, + "ContactEngagement": { + "type": "object", + "description": "Aggregate email engagement summary for a single contact.", + "required": [ + "total_sent", + "total_opened", + "total_clicked", + "total_replied", + "total_bounced", + "total_complained" + ], + "properties": { + "total_sent": { + "type": "integer" + }, + "total_opened": { + "type": "integer" + }, + "total_clicked": { + "type": "integer" + }, + "total_replied": { + "type": "integer" + }, + "total_bounced": { + "type": "integer" + }, + "total_complained": { + "type": "integer" + }, + "last_sent_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "last_opened_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "last_clicked_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "last_replied_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "last_bounced_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + } + } + }, + "ContactSuppression": { + "type": "object", + "description": "Suppression state for the contact's email. Null when not suppressed.", + "required": [ + "reason", + "source", + "created_at" + ], + "properties": { + "reason": { + "type": "string" + }, + "source": { + "type": "string", + "enum": [ + "bounce", + "complaint", + "unsubscribe" + ] + }, + "expires_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "created_at": { + "type": "string", + "format": "date-time" + } + } + }, + "ContactDetail": { + "description": "The hydrated contact 360 payload: the contact plus engagement and suppression.", + "allOf": [ + { + "$ref": "#/components/schemas/Contact" + }, + { + "type": "object", + "required": [ + "engagement" + ], + "properties": { + "engagement": { + "$ref": "#/components/schemas/ContactEngagement" + }, + "suppression": { + "allOf": [ + { + "$ref": "#/components/schemas/ContactSuppression" + } + ] + } + } + } + ] + }, + "ContactSearchRequest": { + "type": "object", + "description": "Faceted contact search filters. All fields optional.", + "properties": { + "query": { + "type": "string", + "description": "Text search across name, email, company." + }, + "custom_field_filters": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ContactCustomFieldFilter" + } + }, + "campaign_ids": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Contact must be in ALL of these campaigns." + }, + "category_ids": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Contact must have ALL of these categories." + }, + "min_campaigns": { + "type": "integer", + "description": "Minimum number of associated campaigns." + }, + "max_campaigns": { + "type": "integer", + "description": "Maximum number of associated campaigns." + }, + "subscribed": { + "type": "boolean" + }, + "created_after": { + "type": "string", + "format": "date-time" + }, + "created_before": { + "type": "string", + "format": "date-time" + }, + "updated_after": { + "type": "string", + "format": "date-time" + }, + "updated_before": { + "type": "string", + "format": "date-time" + }, + "sort_by": { + "type": "string", + "description": "Sort column, e.g. first_name, campaign_count." + }, + "reverse": { + "type": "boolean", + "description": "Descending when true." + } + } + }, + "ContactCustomFieldFilter": { + "type": "object", + "description": "A single custom-field filter clause.", + "required": [ + "name", + "value", + "type" + ], + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "equal", + "starts_with", + "ends_with", + "contains" + ] + } + } + }, + "ContactCreate": { + "type": "object", + "description": "A contact to create.", + "required": [ + "email" + ], + "properties": { + "email": { + "type": "string", + "format": "email" + }, + "first_name": { + "type": "string" + }, + "last_name": { + "type": "string" + }, + "company": { + "type": "string" + }, + "phone": { + "type": "string" + }, + "campaigns": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "description": "Campaign IDs to add the contact to." + }, + "categories": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "description": "Category IDs to assign." + }, + "custom_fields": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + }, + "ContactUpdate": { + "type": "object", + "description": "Partial update for a single contact. Only present fields change.", + "properties": { + "first_name": { + "type": "string" + }, + "last_name": { + "type": "string" + }, + "company": { + "type": "string" + }, + "phone": { + "type": "string" + }, + "custom_fields": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Replaces the custom-fields map." + }, + "subscribed": { + "type": "boolean" + }, + "campaigns": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "description": "Set the full campaign membership (omit to leave as-is)." + }, + "categories": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "description": "Set the full category list (omit to leave as-is)." + }, + "add_categories": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "description": "Diff-style add (ignored when categories is set)." + }, + "remove_categories": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "description": "Diff-style remove (ignored when categories is set)." + } + } + }, + "ContactBulkUpdateRequest": { + "type": "object", + "description": "One set of edits applied across many contacts.", + "required": [ + "contacts" + ], + "properties": { + "contacts": { + "type": "array", + "minItems": 1, + "maxItems": 1000, + "items": { + "type": "string", + "format": "uuid" + }, + "description": "Contact IDs to edit (1 to 1000)." + }, + "add_campaigns": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + "remove_campaigns": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + "add_categories": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + "remove_categories": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + "fields": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ContactBulkFieldOp" + } + }, + "subscribe": { + "type": "boolean", + "description": "Set subscription status for all listed contacts." + } + } + }, + "ContactBulkFieldOp": { + "type": "object", + "description": "A custom-field operation applied during a bulk update.", + "required": [ + "type", + "key" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "ADD", + "EDIT", + "DELETE", + "RENAME" + ] + }, + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, + "ContactExportRequest": { + "type": "object", + "description": "Body for a contact export.", + "required": [ + "format", + "scope" + ], + "properties": { + "format": { + "type": "string", + "enum": [ + "csv", + "xlsx", + "json" + ] + }, + "scope": { + "type": "string", + "enum": [ + "all", + "filtered", + "selected" + ] + }, + "contact_ids": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "description": "Contact IDs when scope is selected." + }, + "filters": { + "allOf": [ + { + "$ref": "#/components/schemas/ContactSearchRequest" + } + ], + "description": "A search filter body when scope is filtered." + }, + "fields": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Column identifiers in display order (built-ins like email, first_name, or custom:). Empty uses defaults." + }, + "filename": { + "type": "string", + "description": "Filename without extension. Sanitized server-side; empty falls back to contacts-." + } + } + }, + "ContactImportColumnMapping": { + "type": "object", + "description": "Maps a source column index to a target contact field.", + "required": [ + "index", + "target" + ], + "properties": { + "index": { + "type": "integer", + "description": "Zero-based source column index." + }, + "target": { + "type": "string", + "enum": [ + "ignore", + "email", + "first_name", + "last_name", + "company", + "phone", + "subscribed", + "categories" + ], + "description": "Target field, or a custom: string for a custom field." + }, + "custom_key": { + "type": "string", + "description": "Custom-field key when target is custom:." + } + } + }, + "ContactImportPreview": { + "type": "object", + "description": "Detected columns and a sample for building a column mapping.", + "required": [ + "filename", + "format", + "total_rows", + "columns", + "has_header", + "sample_rows", + "suggested_mapping" + ], + "properties": { + "filename": { + "type": "string" + }, + "format": { + "type": "string", + "description": "csv or xlsx." + }, + "total_rows": { + "type": "integer" + }, + "columns": { + "type": "array", + "items": { + "type": "string" + } + }, + "has_header": { + "type": "boolean" + }, + "sample_rows": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Capped at 20 rows." + }, + "suggested_mapping": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ContactImportColumnMapping" + } + } + } + }, + "ContactImportRowError": { + "type": "object", + "description": "A single failed import row.", + "required": [ + "line", + "reason" + ], + "properties": { + "line": { + "type": "integer" + }, + "email": { + "type": "string" + }, + "values": { + "type": "array", + "items": { + "type": "string" + } + }, + "reason": { + "type": "string" + } + } + }, + "ContactImportResult": { + "type": "object", + "description": "Per-row results of an import commit.", + "required": [ + "total", + "imported", + "updated", + "skipped", + "failed", + "started_at", + "ended_at" + ], + "properties": { + "total": { + "type": "integer" + }, + "imported": { + "type": "integer" + }, + "updated": { + "type": "integer" + }, + "skipped": { + "type": "integer" + }, + "failed": { + "type": "integer" + }, + "started_at": { + "type": "string", + "format": "date-time" + }, + "ended_at": { + "type": "string", + "format": "date-time" + }, + "errors": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ContactImportRowError" + } + } + } + }, + "ContactLookupResult": { + "type": "object", + "description": "Resolved contact for a sender address; contact is null when nothing matches.", + "required": [ + "contact" + ], + "properties": { + "contact": { + "allOf": [ + { + "$ref": "#/components/schemas/Contact" + } + ] + } + } + }, + "ContactSentEmail": { + "type": "object", + "description": "One email sent (or attempted) to a contact.", + "required": [ + "task_id", + "status", + "message_id", + "subject", + "sent_at" + ], + "properties": { + "task_id": { + "type": "string", + "format": "uuid" + }, + "status": { + "type": "string" + }, + "message_id": { + "type": "string" + }, + "subject": { + "type": "string" + }, + "sent_at": { + "type": "string", + "format": "date-time" + }, + "email_account_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "email_account_email": { + "type": [ + "string", + "null" + ] + }, + "email_account_name": { + "type": [ + "string", + "null" + ] + }, + "campaign_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "campaign_name": { + "type": [ + "string", + "null" + ] + }, + "step_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "step_name": { + "type": [ + "string", + "null" + ] + }, + "opened_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "clicked_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "replied_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "bounced_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + } + } + }, + "ContactSentEmailList": { + "type": "object", + "description": "A page of emails sent to a contact.", + "required": [ + "data", + "pagination" + ], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ContactSentEmail" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + } + } + }, + "ContactTimelineEvent": { + "type": "object", + "description": "One entry in a contact's merged activity feed. Fields not relevant to the event type are omitted.", + "required": [ + "type", + "at" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "email_sent", + "email_opened", + "email_clicked", + "email_replied", + "email_bounced", + "reply_received", + "deliverability", + "suppressed", + "note", + "meeting_booked", + "meeting_rescheduled", + "meeting_canceled" + ] + }, + "at": { + "type": "string", + "format": "date-time" + }, + "email_account_id": { + "type": "string", + "format": "uuid" + }, + "email_account_email": { + "type": "string" + }, + "email_account_name": { + "type": "string" + }, + "campaign_id": { + "type": "string", + "format": "uuid" + }, + "campaign_name": { + "type": "string" + }, + "step_id": { + "type": "string", + "format": "uuid" + }, + "step_name": { + "type": "string" + }, + "task_id": { + "type": "string", + "format": "uuid" + }, + "subject": { + "type": "string" + }, + "reason": { + "type": "string", + "description": "Deliverability / suppression / meeting cancellation reason." + }, + "source": { + "type": "string", + "description": "Suppression: bounce/complaint/unsubscribe; meeting: calendly/cal_com." + }, + "provider": { + "type": "string" + }, + "intent": { + "type": "string", + "description": "reply_intent classification." + }, + "content": { + "type": "string", + "description": "Note body." + }, + "scheduled_for": { + "type": "string", + "format": "date-time", + "description": "When the call is set for (meeting events)." + }, + "join_url": { + "type": "string" + }, + "meeting_state": { + "type": "string", + "enum": [ + "booked", + "rescheduled", + "canceled" + ] + }, + "user_id": { + "type": "string", + "format": "uuid", + "description": "Note author." + } + } + }, + "ContactTimelineResult": { + "type": "object", + "description": "A page of timeline events. Paginate via has_more and the `before` query param; this list does not use a cursor envelope.", + "required": [ + "data", + "has_more" + ], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ContactTimelineEvent" + } + }, + "has_more": { + "type": "boolean" + } + } + }, + "ContactActivity": { + "type": "object", + "description": "One structured CRM activity-log entry.", + "required": [ + "id", + "contact_id", + "organization_id", + "activity_type", + "metadata", + "created_at" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "contact_id": { + "type": "string", + "format": "uuid" + }, + "organization_id": { + "type": "string", + "format": "uuid" + }, + "user_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "activity_type": { + "type": "string", + "enum": [ + "email_sent", + "email_opened", + "email_clicked", + "email_replied", + "email_bounced", + "note_added", + "note_updated", + "deal_created", + "deal_stage_changed", + "deal_won", + "deal_lost", + "task_created", + "task_completed", + "contact_created", + "contact_updated", + "campaign_added", + "campaign_removed" + ] + }, + "metadata": { + "type": "object", + "additionalProperties": true + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "user": { + "type": "object", + "additionalProperties": true, + "description": "Joined user record, when present." + } + } + }, + "ContactActivityList": { + "type": "object", + "description": "A page of CRM activities.", + "required": [ + "data", + "pagination" + ], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ContactActivity" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + } + } + }, + "ContactNote": { + "type": "object", + "description": "A CRM note attached to a contact.", + "required": [ + "id", + "contact_id", + "organization_id", + "user_id", + "content", + "created_at", + "updated_at" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "contact_id": { + "type": "string", + "format": "uuid" + }, + "organization_id": { + "type": "string", + "format": "uuid" + }, + "user_id": { + "type": "string", + "format": "uuid" + }, + "content": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "user": { + "type": "object", + "additionalProperties": true, + "description": "Joined author record, when present." + } + } + }, + "ContactNoteList": { + "type": "object", + "description": "A page of contact notes.", + "required": [ + "data", + "pagination" + ], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ContactNote" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + } + } + }, + "ContactNoteCreate": { + "type": "object", + "description": "Body to create a contact note.", + "required": [ + "content" + ], + "properties": { + "content": { + "type": "string", + "minLength": 1, + "maxLength": 10000, + "description": "Note body (1 to 10,000 characters)." + } + } + }, + "ContactNoteUpdate": { + "type": "object", + "description": "Body to edit a contact note.", + "properties": { + "content": { + "type": "string", + "maxLength": 10000, + "description": "New note body." + } + } + }, + "Deal": { + "type": "object", + "description": "A CRM deal. Nullable attribution fields are omitted when unset.", + "required": [ + "id", + "organization_id", + "pipeline_id", + "stage_id", + "name", + "currency", + "status", + "created_at", + "updated_at" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "organization_id": { + "type": "string", + "format": "uuid" + }, + "pipeline_id": { + "type": "string", + "format": "uuid" + }, + "stage_id": { + "type": "string", + "format": "uuid" + }, + "contact_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "name": { + "type": "string" + }, + "value": { + "type": [ + "number", + "null" + ] + }, + "currency": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "open", + "won", + "lost" + ] + }, + "expected_close_date": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "won_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "lost_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "lost_reason": { + "type": [ + "string", + "null" + ] + }, + "assigned_to": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "campaign_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "source_mailbox_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "UniboxLabel": { + "type": "object", + "description": "A conversation label (one of the caller's categories).", + "required": [ + "id", + "title", + "color" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "title": { + "type": "string" + }, + "color": { + "type": "string", + "description": "Hex color, e.g. `#16a34a`." + } + } + }, + "UniboxThread": { + "type": "object", + "description": "One inbox row, summarising the newest message of a thread plus thread-level rollups.", + "required": [ + "id", + "email_id", + "thread_id", + "subject", + "internal_date", + "seen", + "message_count", + "has_unread" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "UUID of the newest message in the thread." + }, + "email_id": { + "type": "string", + "format": "uuid", + "description": "UUID of the mailbox the message landed in." + }, + "thread_id": { + "type": "string", + "description": "Thread identifier." + }, + "from_addr": { + "type": "array", + "items": { + "type": "string" + } + }, + "to_addr": { + "type": "array", + "items": { + "type": "string" + } + }, + "subject": { + "type": "string" + }, + "snippet": { + "type": "string" + }, + "internal_date": { + "type": "string", + "format": "date-time" + }, + "seen": { + "type": "boolean", + "description": "Whether the newest message is read." + }, + "message_count": { + "type": "integer", + "description": "Number of messages in the thread." + }, + "has_unread": { + "type": "boolean", + "description": "Whether the thread has any unread message." + }, + "labels": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UniboxLabel" + } + } + } + }, + "UniboxThreadList": { + "type": "object", + "required": [ + "data", + "pagination" + ], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UniboxThread" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + } + } + }, + "UniboxMessage": { + "type": "object", + "description": "A full message row inside a thread (envelope plus body).", + "required": [ + "id", + "email_id", + "thread_id", + "subject", + "internal_date", + "seen" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "email_id": { + "type": "string", + "format": "uuid", + "description": "UUID of the mailbox the message landed in." + }, + "mailbox": { + "type": "integer", + "description": "IMAP mailbox/folder index." + }, + "thread_id": { + "type": "string" + }, + "message_id": { + "type": "string", + "description": "RFC Message-ID header." + }, + "gmail_id": { + "type": "string" + }, + "parent_id": { + "type": "string" + }, + "uid": { + "type": "integer" + }, + "mod_seq": { + "type": "integer" + }, + "flags": { + "type": "array", + "items": { + "type": "string" + } + }, + "bcc": { + "type": "array", + "items": { + "type": "string" + } + }, + "cc": { + "type": "array", + "items": { + "type": "string" + } + }, + "from_addr": { + "type": "array", + "items": { + "type": "string" + } + }, + "in_reply_to": { + "type": "array", + "items": { + "type": "string" + } + }, + "reply_to": { + "type": "array", + "items": { + "type": "string" + } + }, + "to_addr": { + "type": "array", + "items": { + "type": "string" + } + }, + "subject": { + "type": "string" + }, + "size": { + "type": "integer", + "description": "Raw message size in bytes." + }, + "internal_date": { + "type": "string", + "format": "date-time" + }, + "sent_date": { + "type": "string", + "format": "date-time" + }, + "snippet": { + "type": "string" + }, + "seen": { + "type": "boolean" + }, + "body_plain": { + "type": "string" + }, + "body_html": { + "type": "string" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "created_at": { + "type": "string", + "format": "date-time" + } + } + }, + "UniboxMessageList": { + "type": "object", + "required": [ + "data", + "pagination" + ], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UniboxMessage" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + } + } + }, + "UniboxEmail": { + "type": "object", + "description": "A single message fetched by id. Note the field names differ from the thread message shape (e.g. `from`/`to`/`date`).", + "required": [ + "id", + "thread_id", + "subject" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "gmail_id": { + "type": "string" + }, + "uid": { + "type": "integer" + }, + "parent_id": { + "type": "string" + }, + "thread_id": { + "type": "string" + }, + "flags": { + "type": "array", + "items": { + "type": "string" + } + }, + "bcc": { + "type": "array", + "items": { + "type": "string" + } + }, + "cc": { + "type": "array", + "items": { + "type": "string" + } + }, + "date": { + "type": "string", + "format": "date-time", + "description": "Sent date." + }, + "from": { + "type": "array", + "items": { + "type": "string" + } + }, + "in_reply_to": { + "type": "array", + "items": { + "type": "string" + } + }, + "message_id": { + "type": "string", + "description": "RFC Message-ID header." + }, + "ReplyTo": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Reply-To addresses (serialized as `ReplyTo`)." + }, + "to": { + "type": "array", + "items": { + "type": "string" + } + }, + "subject": { + "type": "string" + }, + "size": { + "type": "integer" + }, + "internal_date": { + "type": "string", + "format": "date-time" + }, + "mod_seq": { + "type": "integer" + }, + "body_plain": { + "type": "string" + }, + "body_html": { + "type": "string" + } + } + }, + "UniboxCount": { + "type": "object", + "required": [ + "count" + ], + "properties": { + "count": { + "type": "integer", + "description": "Org-wide unread message count." + } + } + }, + "UniboxOverviewMailbox": { + "type": "object", + "required": [ + "id", + "email", + "unread", + "total" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "email": { + "type": "string" + }, + "name": { + "type": "string" + }, + "unread": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, + "UniboxOverviewBucket": { + "type": "object", + "description": "A tag or conversation-label breakdown bucket.", + "required": [ + "id", + "title", + "unread", + "total" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "title": { + "type": "string" + }, + "color": { + "type": "string" + }, + "unread": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, + "UniboxOverview": { + "type": "object", + "required": [ + "total", + "unread", + "today", + "week", + "snoozed", + "awaiting_reply", + "scheduled_pending" + ], + "properties": { + "total": { + "type": "integer" + }, + "unread": { + "type": "integer" + }, + "today": { + "type": "integer" + }, + "week": { + "type": "integer" + }, + "snoozed": { + "type": "integer" + }, + "awaiting_reply": { + "type": "integer" + }, + "scheduled_pending": { + "type": "integer" + }, + "scheduled_pending_max": { + "type": "integer", + "description": "Max queued scheduled sends allowed." + }, + "mailboxes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UniboxOverviewMailbox" + } + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UniboxOverviewBucket" + } + }, + "categories": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UniboxOverviewBucket" + } + }, + "generated_at": { + "type": "string", + "format": "date-time" + }, + "window_today_start": { + "type": "string", + "format": "date-time" + }, + "window_week_start": { + "type": "string", + "format": "date-time" + } + } + }, + "UniboxLabelList": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UniboxLabel" + } + } + } + }, + "UniboxSetThreadLabelsRequest": { + "type": "object", + "required": [ + "thread_id" + ], + "properties": { + "thread_id": { + "type": "string", + "description": "The thread to label." + }, + "category_ids": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "description": "Full desired set of category UUIDs. An empty array clears all labels." + } + } + }, + "UniboxMarkSeenRequest": { + "type": "object", + "description": "Also the echoed response body.", + "required": [ + "email_ids" + ], + "properties": { + "email_ids": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "maxItems": 500, + "description": "Message UUIDs to update (max 500)." + }, + "seen": { + "type": "boolean", + "description": "`true` marks as read, `false` marks as unread." + } + } + }, + "UniboxReplyRequest": { + "type": "object", + "required": [ + "email_account_id", + "to", + "subject" + ], + "properties": { + "email_account_id": { + "type": "string", + "format": "uuid", + "description": "UUID of the sending mailbox." + }, + "to": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "description": "Recipient addresses (at least one)." + }, + "cc": { + "type": "array", + "items": { + "type": "string" + } + }, + "bcc": { + "type": "array", + "items": { + "type": "string" + } + }, + "subject": { + "type": "string" + }, + "body_html": { + "type": "string" + }, + "body_plain": { + "type": "string" + }, + "in_reply_to": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Message-ID(s) this reply threads under." + }, + "thread_id": { + "type": "string" + }, + "send_mode": { + "type": "string", + "enum": [ + "instant", + "smart", + "scheduled" + ], + "default": "instant", + "description": "`instant`, `smart` (next mailbox gap), or `scheduled`." + }, + "scheduled_at": { + "type": "string", + "format": "date-time", + "description": "Required when `send_mode` is `scheduled`; must be in the future." + } + } + }, + "UniboxReplyResult": { + "type": "object", + "required": [ + "task_id", + "scheduled_at", + "send_mode" + ], + "properties": { + "task_id": { + "type": "string", + "format": "uuid" + }, + "scheduled_at": { + "type": "string", + "format": "date-time", + "description": "When the send is scheduled to fire." + }, + "send_mode": { + "type": "string", + "enum": [ + "instant", + "smart", + "scheduled" + ] + } + } + }, + "UniboxSnoozeRequest": { + "type": "object", + "required": [ + "thread_id", + "snoozed_until" + ], + "properties": { + "thread_id": { + "type": "string", + "description": "The thread to snooze." + }, + "snoozed_until": { + "type": "string", + "format": "date-time", + "description": "RFC 3339 timestamp to un-hide the thread." + } + } + }, + "UniboxSnooze": { + "type": "object", + "required": [ + "id", + "user_id", + "thread_id", + "snoozed_until" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "user_id": { + "type": "string", + "format": "uuid", + "description": "Snoozes are attached to the calling user." + }, + "thread_id": { + "type": "string" + }, + "snoozed_until": { + "type": "string", + "format": "date-time" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "UniboxSnoozeList": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UniboxSnooze" + } + } + } + }, + "UniboxScheduledSend": { + "type": "object", + "description": "A preview of a queued, not-yet-sent outbound message.", + "required": [ + "task_id", + "scheduled_at", + "account_id", + "subject" + ], + "properties": { + "task_id": { + "type": "string", + "format": "uuid" + }, + "scheduled_at": { + "type": "string", + "format": "date-time" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "account_id": { + "type": "string", + "format": "uuid", + "description": "UUID of the sending mailbox." + }, + "account_email": { + "type": "string" + }, + "account_name": { + "type": "string" + }, + "to": { + "type": "array", + "items": { + "type": "string" + } + }, + "subject": { + "type": "string" + }, + "snippet": { + "type": "string" + }, + "thread_id": { + "type": "string" + } + } + }, + "UniboxScheduledList": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UniboxScheduledSend" + } + } + } + }, + "Pipeline": { + "type": "object", + "description": "A sales pipeline with its ordered stages.", + "required": [ + "id", + "organization_id", + "name", + "position", + "created_at", + "updated_at" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "organization_id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "position": { + "type": "integer" + }, + "stages": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PipelineStage" + } + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "PipelineStage": { + "type": "object", + "description": "A stage within a pipeline.", + "required": [ + "id", + "pipeline_id", + "name", + "color", + "position", + "created_at", + "updated_at" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "pipeline_id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "color": { + "type": "string", + "description": "Hex color." + }, + "position": { + "type": "integer" + }, + "deal_count": { + "type": "integer", + "description": "Count of deals in this stage (populated by the pipeline list/get queries)." + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "CreatePipeline": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Pipeline name." + }, + "stages": { + "type": "array", + "description": "Stages to create with the pipeline, in order.", + "items": { + "$ref": "#/components/schemas/CreatePipelineStage" + } + } + } + }, + "CreatePipelineStage": { + "type": "object", + "required": [ + "name", + "color" + ], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Stage name." + }, + "color": { + "type": "string", + "description": "Stage color (hex)." + } + } + }, + "UpdatePipeline": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "New pipeline name." + } + } + }, + "UpdatePipelineStage": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "New stage name." + }, + "color": { + "type": "string", + "description": "New stage color (hex)." + } + } + }, + "DealList": { + "type": "object", + "description": "Keyset-paginated page of deals.", + "required": [ + "data", + "pagination" + ], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Deal" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + } + } + }, + "CreateDeal": { + "type": "object", + "required": [ + "pipeline_id", + "stage_id", + "name" + ], + "properties": { + "pipeline_id": { + "type": "string", + "format": "uuid", + "description": "Pipeline the deal belongs to." + }, + "stage_id": { + "type": "string", + "format": "uuid", + "description": "Initial stage." + }, + "contact_id": { + "type": "string", + "format": "uuid", + "description": "Linked contact." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Deal name." + }, + "value": { + "type": "number", + "description": "Monetary value." + }, + "currency": { + "type": "string", + "description": "ISO currency code." + }, + "expected_close_date": { + "type": "string", + "format": "date-time", + "description": "Expected close date." + }, + "assigned_to": { + "type": "string", + "format": "uuid", + "description": "Owner (org member user ID)." + }, + "campaign_id": { + "type": "string", + "format": "uuid", + "description": "Attributed campaign." + }, + "source_mailbox_id": { + "type": "string", + "format": "uuid", + "description": "Sending mailbox that produced the originating reply." + } + } + }, + "UpdateDeal": { + "type": "object", + "description": "All fields optional. Moving stage_id records a stage-change activity; setting status to won/lost stamps the close timestamp.", + "properties": { + "stage_id": { + "type": "string", + "format": "uuid", + "description": "Move the deal to this stage." + }, + "contact_id": { + "type": "string", + "format": "uuid", + "description": "Linked contact." + }, + "name": { + "type": "string", + "description": "Deal name." + }, + "value": { + "type": "number", + "description": "Monetary value." + }, + "currency": { + "type": "string", + "description": "ISO currency code." + }, + "status": { + "type": "string", + "enum": [ + "open", + "won", + "lost" + ] + }, + "expected_close_date": { + "type": "string", + "format": "date-time" + }, + "lost_reason": { + "type": "string", + "description": "Reason recorded when marking lost." + }, + "assigned_to": { + "type": "string", + "format": "uuid", + "description": "Owner (org member user ID)." + } + } + }, + "SearchDeals": { + "type": "object", + "description": "Faceted deal filter body shared by deal search and deal summary. All facets optional; an empty body matches every deal.", + "properties": { + "query": { + "type": "string", + "description": "Case-insensitive match on deal name." + }, + "statuses": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "open", + "won", + "lost" + ] + } + }, + "pipeline_ids": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + "stage_ids": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + "assigned_to": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "description": "Owner is any of these user IDs." + }, + "campaign_ids": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + "min_value": { + "type": "number", + "description": "Value greater than or equal to." + }, + "max_value": { + "type": "number", + "description": "Value less than or equal to." + }, + "close_after": { + "type": "string", + "format": "date-time", + "description": "Expected close date on or after." + }, + "close_before": { + "type": "string", + "format": "date-time", + "description": "Expected close date on or before." + }, + "created_after": { + "type": "string", + "format": "date-time" + }, + "created_before": { + "type": "string", + "format": "date-time" + }, + "sort_by": { + "type": "string", + "enum": [ + "created_at", + "updated_at", + "value", + "expected_close_date", + "name" + ] + }, + "reverse": { + "type": "boolean", + "description": "true sorts ascending, false (default) descending." + } + } + }, + "DealsSearchResult": { + "type": "object", + "description": "Offset-paginated deal search result with an exact total.", + "required": [ + "data", + "pagination" + ], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Deal" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + } + } + }, + "DealsSummary": { + "type": "object", + "description": "Aggregate counts and value sums over a deal search filter body. mixed_currency true means top-level value sums should be treated as approximate.", + "required": [ + "total", + "open_count", + "open_value", + "won_count", + "won_value", + "lost_count", + "lost_value", + "currency", + "stages", + "mixed_currency" + ], + "properties": { + "total": { + "type": "integer", + "format": "int64" + }, + "open_count": { + "type": "integer", + "format": "int64" + }, + "open_value": { + "type": "number" + }, + "won_count": { + "type": "integer", + "format": "int64" + }, + "won_value": { + "type": "number" + }, + "lost_count": { + "type": "integer", + "format": "int64" + }, + "lost_value": { + "type": "number" + }, + "currency": { + "type": "string" + }, + "stages": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DealStageSummary" + } + }, + "mixed_currency": { + "type": "boolean" + } + } + }, + "DealStageSummary": { + "type": "object", + "required": [ + "stage_id", + "count", + "value" + ], + "properties": { + "stage_id": { + "type": "string", + "format": "uuid" + }, + "count": { + "type": "integer", + "format": "int64" + }, + "value": { + "type": "number", + "description": "Open-deal value in this stage." + } + } + }, + "CRMTaskType": { + "type": "object", + "description": "A user-managed CRM task type (the kind of work a task represents).", + "required": [ + "id", + "organization_id", + "name", + "color", + "position", + "created_at", + "updated_at" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "organization_id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "color": { + "type": "string", + "description": "Hex color." + }, + "position": { + "type": "integer" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "CRMTaskTypeList": { + "type": "object", + "description": "Task types (no pagination envelope).", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CRMTaskType" + } + } + } + }, + "CreateCRMTaskType": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 60, + "description": "Type name." + }, + "color": { + "type": "string", + "description": "Type color (hex)." + } + } + }, + "UpdateCRMTaskType": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "New type name." + }, + "color": { + "type": "string", + "description": "New type color (hex)." + }, + "position": { + "type": "integer", + "description": "New ordering position." + } + } + }, + "CRMTask": { + "type": "object", + "description": "A CRM task (follow-up work attached to contacts and deals).", + "required": [ + "id", + "organization_id", + "created_by", + "title", + "priority", + "type", + "status", + "created_at", + "updated_at" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "organization_id": { + "type": "string", + "format": "uuid" + }, + "contact_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "deal_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "assigned_to": { + "type": [ + "string", + "null" + ], + "format": "uuid", + "description": "Assignee user ID." + }, + "assigned_team_id": { + "type": [ + "string", + "null" + ], + "format": "uuid", + "description": "Assignee team ID." + }, + "created_by": { + "type": "string", + "format": "uuid" + }, + "title": { + "type": "string" + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "due_date": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "priority": { + "type": "string", + "enum": [ + "low", + "medium", + "high", + "urgent" + ] + }, + "type": { + "type": "string", + "description": "Task type name." + }, + "status": { + "type": "string", + "enum": [ + "pending", + "in_progress", + "completed", + "cancelled" + ] + }, + "completed_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "CRMTaskList": { + "type": "object", + "description": "Keyset-paginated page of CRM tasks.", + "required": [ + "data", + "pagination" + ], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CRMTask" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + } + } + }, + "CreateCRMTask": { + "type": "object", + "required": [ + "title" + ], + "properties": { + "title": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Task title." + }, + "contact_id": { + "type": "string", + "format": "uuid", + "description": "Linked contact." + }, + "deal_id": { + "type": "string", + "format": "uuid", + "description": "Linked deal." + }, + "assigned_to": { + "type": "string", + "format": "uuid", + "description": "Assignee user ID." + }, + "assigned_team_id": { + "type": "string", + "format": "uuid", + "description": "Assignee team ID." + }, + "description": { + "type": "string", + "description": "Free-text description." + }, + "due_date": { + "type": "string", + "format": "date-time", + "description": "Due date." + }, + "priority": { + "type": "string", + "enum": [ + "low", + "medium", + "high", + "urgent" + ] + }, + "type": { + "type": "string", + "description": "Task type name (matches a configured task type)." + } + } + }, + "UpdateCRMTask": { + "type": "object", + "description": "All fields optional. Setting status to completed stamps the completion timestamp.", + "properties": { + "title": { + "type": "string", + "description": "Task title." + }, + "assigned_to": { + "type": "string", + "format": "uuid", + "description": "Assignee user ID." + }, + "assigned_team_id": { + "type": "string", + "format": "uuid", + "description": "Assignee team ID." + }, + "description": { + "type": "string", + "description": "Free-text description." + }, + "due_date": { + "type": "string", + "format": "date-time", + "description": "Due date." + }, + "priority": { + "type": "string", + "enum": [ + "low", + "medium", + "high", + "urgent" + ] + }, + "type": { + "type": "string", + "description": "Task type name." + }, + "status": { + "type": "string", + "enum": [ + "pending", + "in_progress", + "completed", + "cancelled" + ] + } + } + }, + "SearchTasks": { + "type": "object", + "description": "Faceted task filter body shared by task search and task summary. All facets optional; an empty body matches every task.", + "properties": { + "query": { + "type": "string", + "description": "Case-insensitive match on task title." + }, + "statuses": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "pending", + "in_progress", + "completed", + "cancelled" + ] + } + }, + "priorities": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "low", + "medium", + "high", + "urgent" + ] + } + }, + "types": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Task type name is any of these." + }, + "assigned_to": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "description": "Assignee user ID is any of these." + }, + "team_ids": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "description": "Task team is any of these, or the assignee belongs to one." + }, + "contact_id": { + "type": "string", + "format": "uuid", + "description": "Linked contact." + }, + "deal_id": { + "type": "string", + "format": "uuid", + "description": "Linked deal." + }, + "due_after": { + "type": "string", + "format": "date-time", + "description": "Due on or after." + }, + "due_before": { + "type": "string", + "format": "date-time", + "description": "Due on or before." + }, + "overdue": { + "type": "boolean", + "description": "Only tasks past due and not completed or cancelled." + }, + "sort_by": { + "type": "string", + "enum": [ + "created_at", + "due_date", + "priority", + "title", + "updated_at" + ] + }, + "reverse": { + "type": "boolean", + "description": "true sorts ascending, false (default) descending." + } + } + }, + "TasksSearchResult": { + "type": "object", + "description": "Offset-paginated task search result with an exact total.", + "required": [ + "data", + "pagination" + ], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CRMTask" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + } + } + }, + "TasksSummary": { + "type": "object", + "description": "Aggregate task counts over a task search filter body.", + "required": [ + "total", + "pending_count", + "in_progress_count", + "completed_count", + "cancelled_count", + "overdue_count", + "high_priority_count" + ], + "properties": { + "total": { + "type": "integer", + "format": "int64" + }, + "pending_count": { + "type": "integer", + "format": "int64" + }, + "in_progress_count": { + "type": "integer", + "format": "int64" + }, + "completed_count": { + "type": "integer", + "format": "int64" + }, + "cancelled_count": { + "type": "integer", + "format": "int64" + }, + "overdue_count": { + "type": "integer", + "format": "int64" + }, + "high_priority_count": { + "type": "integer", + "format": "int64" + } + } + }, + "APIKey": { + "type": "object", + "description": "An API key without its plaintext secret.", + "required": [ + "id", + "user_id", + "organization_id", + "name", + "key_prefix", + "key_suffix", + "permissions", + "rate_limit_per_minute", + "status", + "created_at", + "updated_at" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "user_id": { + "type": "string", + "format": "uuid", + "description": "The member who created the key." + }, + "organization_id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string", + "description": "Human-readable label." + }, + "description": { + "type": "string", + "description": "Free-form note about the key's purpose." + }, + "key_prefix": { + "type": "string", + "description": "Short display prefix of the secret (e.g. wmbly_3f)." + }, + "key_suffix": { + "type": "string", + "description": "Short display suffix of the secret." + }, + "permissions": { + "type": "integer", + "format": "int64", + "description": "uint64 permission bitmask. Combine bits with bitwise OR; the key may perform a request only when its mask contains every required bit. See GET /api-keys/permissions for bit names and values." + }, + "allowed_ips": { + "type": "array", + "items": { + "type": "string" + }, + "description": "If non-empty, the key is usable only from these source IPs." + }, + "allowed_email_accounts": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "description": "If non-empty, mailbox-scoped routes accept only these email account ids." + }, + "rate_limit_per_minute": { + "type": "integer", + "description": "Per-key sliding-window request cap. 0 means the default (60 r/m)." + }, + "status": { + "$ref": "#/components/schemas/APIKeyStatus" + }, + "last_used_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "last_request_ip": { + "type": [ + "string", + "null" + ] + }, + "expires_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "When the key stops working. Null for a non-expiring key." + }, + "revoked_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "revoked_reason": { + "type": [ + "string", + "null" + ] + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "APIKeyStatus": { + "type": "string", + "enum": [ + "active", + "revoked", + "expired" + ], + "description": "Lifecycle status of the key." + }, + "APIKeyWithSecret": { + "type": "object", + "description": "An API key plus the one-time plaintext secret, returned only on creation.", + "allOf": [ + { + "$ref": "#/components/schemas/APIKey" + }, + { + "type": "object", + "required": [ + "secret" + ], + "properties": { + "secret": { + "type": "string", + "description": "The full plaintext key. Returned only once, at creation; it cannot be recovered later." + } + } + } + ] + }, + "APIKeyList": { + "type": "object", + "required": [ + "data", + "pagination" + ], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIKey" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + } + } + }, + "CreateAPIKey": { + "type": "object", + "required": [ + "name", + "permissions" + ], + "properties": { + "name": { + "type": "string", + "maxLength": 255, + "description": "Human-readable label." + }, + "description": { + "type": "string", + "description": "Free-form note about the key's purpose." + }, + "permissions": { + "type": "integer", + "format": "int64", + "description": "uint64 permission bitmask. Must contain only defined bits; unknown bits are rejected." + }, + "allowed_ips": { + "type": "array", + "items": { + "type": "string" + }, + "description": "If set, the key is usable only from these source IPs. Omit or leave empty to allow any IP." + }, + "allowed_email_accounts": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "description": "If set, mailbox-scoped routes accept only these email account ids." + }, + "rate_limit_per_minute": { + "type": "integer", + "description": "Per-key request cap. Omit or send 0 to use the default (60 r/m)." + }, + "expires_at": { + "type": "string", + "format": "date-time", + "description": "When the key should stop working (RFC3339). Omit for a non-expiring key." + } + } + }, + "UpdateAPIKey": { + "type": "object", + "description": "Every field is optional; only the fields you send are changed.", + "properties": { + "name": { + "type": "string", + "description": "New label." + }, + "description": { + "type": "string", + "description": "New description." + }, + "permissions": { + "type": "integer", + "format": "int64", + "description": "Replacement uint64 permission bitmask." + }, + "allowed_ips": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Replacement IP allowlist." + }, + "allowed_email_accounts": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "description": "Replacement mailbox allowlist." + }, + "rate_limit_per_minute": { + "type": "integer", + "description": "New per-key rate cap. 0 means use the default." + } + } + }, + "APIKeyRevokeResult": { + "type": "object", + "required": [ + "status" + ], + "properties": { + "status": { + "type": "string", + "enum": [ + "revoked" + ] + } + } + }, + "APIPermission": { + "type": "object", + "required": [ + "name", + "value", + "description", + "category" + ], + "properties": { + "name": { + "type": "string", + "description": "Bit name, e.g. READ_EMAILS." + }, + "value": { + "type": "integer", + "format": "int64", + "description": "Numeric bit value." + }, + "description": { + "type": "string" + }, + "category": { + "type": "string", + "enum": [ + "read", + "write", + "bulk", + "special" + ] + } + } + }, + "APIPermissionCatalog": { + "type": "object", + "required": [ + "permissions", + "presets" + ], + "properties": { + "permissions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIPermission" + } + }, + "presets": { + "type": "object", + "required": [ + "read_only", + "full_access" + ], + "properties": { + "read_only": { + "type": "integer", + "format": "int64", + "description": "Bitmask granting all read scopes." + }, + "full_access": { + "type": "integer", + "format": "int64", + "description": "Bitmask granting every scope." + } + } + } + } + }, + "APIKeyUsageSummary": { + "type": "object", + "description": "Org-level usage strip. The 24h fields cover the last 24 hours.", + "required": [ + "active_keys", + "revoked_keys", + "expired_keys", + "requests_24h", + "errors_24h", + "avg_latency_ms_24h" + ], + "properties": { + "active_keys": { + "type": "integer" + }, + "revoked_keys": { + "type": "integer" + }, + "expired_keys": { + "type": "integer" + }, + "requests_24h": { + "type": "integer", + "format": "int64" + }, + "errors_24h": { + "type": "integer", + "format": "int64" + }, + "avg_latency_ms_24h": { + "type": "number" + }, + "last_call_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + } + } + }, + "APIKeyUsageBucket": { + "type": "object", + "description": "One point on a time-bucketed request graph.", + "required": [ + "bucket", + "total", + "success", + "client_errors", + "server_errors", + "avg_latency_ms" + ], + "properties": { + "bucket": { + "type": "string", + "format": "date-time" + }, + "total": { + "type": "integer", + "format": "int64" + }, + "success": { + "type": "integer", + "format": "int64", + "description": "2xx responses." + }, + "client_errors": { + "type": "integer", + "format": "int64", + "description": "4xx responses." + }, + "server_errors": { + "type": "integer", + "format": "int64", + "description": "5xx responses." + }, + "avg_latency_ms": { + "type": "number" + } + } + }, + "APIKeyEndpointStat": { + "type": "object", + "description": "One row in the per-endpoint breakdown.", + "required": [ + "endpoint", + "method", + "count", + "error_count", + "avg_latency_ms" + ], + "properties": { + "endpoint": { + "type": "string" + }, + "method": { + "type": "string" + }, + "count": { + "type": "integer", + "format": "int64" + }, + "error_count": { + "type": "integer", + "format": "int64" + }, + "avg_latency_ms": { + "type": "number" + } + } + }, + "APIKeyAnalytics": { + "type": "object", + "required": [ + "api_key_id", + "from", + "to", + "interval", + "buckets", + "endpoints", + "total", + "errors" + ], + "properties": { + "api_key_id": { + "type": "string", + "format": "uuid", + "description": "The key id, or the all-zero UUID for the org-wide aggregate." + }, + "from": { + "type": "string", + "format": "date-time" + }, + "to": { + "type": "string", + "format": "date-time" + }, + "interval": { + "type": "string", + "enum": [ + "minute", + "hour", + "day" + ] + }, + "buckets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIKeyUsageBucket" + } + }, + "endpoints": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIKeyEndpointStat" + } + }, + "total": { + "type": "integer", + "format": "int64" + }, + "errors": { + "type": "integer", + "format": "int64" + } + } + }, + "APIKeyUsageLog": { + "type": "object", + "description": "One recorded request made with the key.", + "required": [ + "id", + "api_key_id", + "endpoint", + "method", + "ip_address", + "user_agent", + "response_code", + "response_time_ms", + "created_at" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "api_key_id": { + "type": "string", + "format": "uuid" + }, + "endpoint": { + "type": "string" + }, + "method": { + "type": "string" + }, + "ip_address": { + "type": "string" + }, + "user_agent": { + "type": "string" + }, + "response_code": { + "type": "integer" + }, + "response_time_ms": { + "type": "integer" + }, + "created_at": { + "type": "string", + "format": "date-time" + } + } + }, + "APIKeyUsageLogList": { + "type": "object", + "required": [ + "data", + "pagination" + ], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIKeyUsageLog" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + } + } + }, + "WebhookEventType": { + "type": "string", + "description": "Canonical event name carried in the subscription filter and the delivery payload. An endpoint with an empty `event_types` filter receives all of these.", + "enum": [ + "email_account.connected", + "email_account.removed", + "campaign.email_sent", + "campaign.email_delivered", + "campaign.email_opened", + "campaign.email_clicked", + "campaign.email_bounced", + "campaign.reply_received", + "campaign.unsubscribed", + "campaign.started", + "campaign.paused", + "campaign.completed", + "campaign.deliverability_warning", + "campaign.action", + "warmup.email_sent", + "warmup.health_changed", + "warmup.placement_in_spam", + "warmup.quarantined", + "warmup.blocked", + "deliverability.bounce", + "deliverability.complaint", + "meeting.booked", + "meeting.rescheduled", + "meeting.canceled" + ] + }, + "WebhookEndpoint": { + "type": "object", + "description": "A customer's subscription to events. Returned without the signing secret on all reads and updates.", + "required": [ + "id", + "organization_id", + "url", + "description", + "event_types", + "enabled", + "consecutive_failures", + "created_at", + "updated_at" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "organization_id": { + "type": "string", + "format": "uuid" + }, + "url": { + "type": "string", + "format": "uri", + "description": "HTTPS URL that receives POST callbacks." + }, + "description": { + "type": "string", + "description": "Free-text label for your own reference." + }, + "event_types": { + "type": "array", + "description": "Subscribed event names. An empty array means all events.", + "items": { + "$ref": "#/components/schemas/WebhookEventType" + } + }, + "enabled": { + "type": "boolean", + "description": "Whether the endpoint is active." + }, + "last_success_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "Time of the last successful delivery, or null." + }, + "last_failure_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "Time of the last failed delivery, or null." + }, + "last_failure_reason": { + "type": [ + "string", + "null" + ], + "description": "Reason for the last failure, or null." + }, + "consecutive_failures": { + "type": "integer", + "description": "Current run of consecutive delivery failures." + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "WebhookEndpointWithSecret": { + "allOf": [ + { + "$ref": "#/components/schemas/WebhookEndpoint" + }, + { + "type": "object", + "required": [ + "secret" + ], + "properties": { + "secret": { + "type": "string", + "description": "The `whsec_`-prefixed HMAC signing secret. Returned only at create and rotate time." + } + } + } + ] + }, + "WebhookEndpointRequest": { + "type": "object", + "description": "Create/update body. The same body shape is used for both; on update all fields are replaced with the values sent (send the complete desired state). The secret is server-generated and cannot be set here.", + "required": [ + "url" + ], + "properties": { + "url": { + "type": "string", + "format": "uri", + "description": "HTTPS URL that will receive POST callbacks. Must be publicly routable." + }, + "description": { + "type": "string", + "description": "Free-text label for your own reference." + }, + "event_types": { + "type": "array", + "description": "Event names to subscribe to. Each must be a known type. An empty or omitted array subscribes to all events. On update this overwrites the existing filter (not merged).", + "items": { + "$ref": "#/components/schemas/WebhookEventType" + } + }, + "enabled": { + "type": "boolean", + "default": true, + "description": "Whether the endpoint is active. Defaults to true." + } + } + }, + "WebhookEndpointList": { + "type": "object", + "description": "List response. Not a `data` + `pagination` cursor envelope: it returns the configured endpoints plus the full event vocabulary.", + "required": [ + "endpoints", + "event_types" + ], + "properties": { + "endpoints": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WebhookEndpoint" + } + }, + "event_types": { + "type": "array", + "description": "Every event Warmbly can emit, for building a picker.", + "items": { + "$ref": "#/components/schemas/WebhookEventType" + } + } + } + }, + "WebhookSecretResponse": { + "type": "object", + "required": [ + "secret" + ], + "properties": { + "secret": { + "type": "string", + "description": "The new `whsec_`-prefixed HMAC signing secret. Returned only once." + } + } + }, + "WebhookDeliveryStatus": { + "type": "string", + "description": "Lifecycle state of a delivery attempt. `abandoned` means retries were exhausted.", + "enum": [ + "pending", + "in_flight", + "delivered", + "failed", + "abandoned" + ] + }, + "WebhookDelivery": { + "type": "object", + "description": "One delivery-history record. The row updates in place across retries; `attempt_count` and `status` reflect the latest state.", + "required": [ + "id", + "endpoint_id", + "organization_id", + "event_type", + "event_id", + "payload", + "status", + "attempt_count", + "max_attempts", + "next_attempt_at", + "created_at", + "updated_at" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "endpoint_id": { + "type": "string", + "format": "uuid" + }, + "organization_id": { + "type": "string", + "format": "uuid" + }, + "event_type": { + "$ref": "#/components/schemas/WebhookEventType" + }, + "event_id": { + "type": "string", + "format": "uuid", + "description": "Stable across retries. Matches the `X-Warmbly-Event-Id` header and the payload `id`." + }, + "payload": { + "$ref": "#/components/schemas/WebhookPayload" + }, + "status": { + "$ref": "#/components/schemas/WebhookDeliveryStatus" + }, + "attempt_count": { + "type": "integer", + "description": "Number of delivery attempts made so far." + }, + "max_attempts": { + "type": "integer", + "description": "Maximum attempts before the delivery is abandoned (default 8)." + }, + "next_attempt_at": { + "type": "string", + "format": "date-time", + "description": "Scheduled time of the next retry." + }, + "last_attempt_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "response_status": { + "type": [ + "integer", + "null" + ], + "description": "HTTP status returned by the subscriber on the last attempt, or null." + }, + "response_body_excerpt": { + "type": [ + "string", + "null" + ], + "description": "First 1024 bytes of the subscriber's response, or null." + }, + "error_reason": { + "type": [ + "string", + "null" + ], + "description": "Connection or timeout error from the last attempt, or null." + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "WebhookDeliveryList": { + "type": "object", + "description": "Delivery list response, newest first. Not a `data` + `pagination` cursor envelope; bounded by the `limit` query param.", + "required": [ + "deliveries" + ], + "properties": { + "deliveries": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WebhookDelivery" + } + } + } + }, + "WebhookPayload": { + "type": "object", + "description": "The JSON body POSTed to a subscriber endpoint. Signed with HMAC-SHA256: the `X-Warmbly-Signature` header is `t=,v1=`, where `v1 = hex(hmac_sha256(secret, \".\" + rawBody))`. Other headers: `X-Warmbly-Event` (event type), `X-Warmbly-Event-Id` (the event id for dedupe), `User-Agent: Warmbly-Webhooks/1.0`.", + "required": [ + "id", + "event_type", + "organization_id", + "created_at", + "data" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "Unique event id, stable across retries. Matches `X-Warmbly-Event-Id`." + }, + "event_type": { + "$ref": "#/components/schemas/WebhookEventType" + }, + "organization_id": { + "type": "string", + "format": "uuid" + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "RFC 3339 UTC timestamp of when the event was dispatched." + }, + "data": { + "type": "object", + "additionalProperties": true, + "description": "Event-specific payload. Shape depends on `event_type`." + } + } + }, + "DashboardAnalytics": { + "type": "object", + "description": "Org-wide dashboard overview.", + "required": [ + "period", + "overall_stats", + "recent_activity", + "top_campaigns", + "account_health", + "daily_trend" + ], + "properties": { + "period": { + "type": "string", + "enum": [ + "7d", + "30d", + "90d" + ] + }, + "overall_stats": { + "$ref": "#/components/schemas/OverallStats" + }, + "recent_activity": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RecentActivity" + } + }, + "top_campaigns": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TopCampaign" + } + }, + "account_health": { + "$ref": "#/components/schemas/AccountHealthSummary" + }, + "daily_trend": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DailyTrendPoint" + } + } + } + }, + "OverallStats": { + "type": "object", + "properties": { + "total_emails_sent": { + "type": "integer" + }, + "total_opens": { + "type": "integer" + }, + "machine_opens": { + "type": "integer" + }, + "total_clicks": { + "type": "integer" + }, + "total_replies": { + "type": "integer" + }, + "total_bounces": { + "type": "integer" + }, + "open_rate": { + "type": "number" + }, + "click_rate": { + "type": "number" + }, + "reply_rate": { + "type": "number" + }, + "bounce_rate": { + "type": "number" + }, + "active_campaigns": { + "type": "integer" + }, + "active_accounts": { + "type": "integer" + } + } + }, + "RecentActivity": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Activity type, for example replied, opened, clicked." + }, + "campaign_id": { + "type": "string", + "format": "uuid" + }, + "campaign_name": { + "type": "string" + }, + "contact_email": { + "type": "string" + }, + "contact_id": { + "type": "string", + "format": "uuid" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "TopCampaign": { + "type": "object", + "properties": { + "campaign_id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "status": { + "type": "string" + }, + "emails_sent": { + "type": "integer" + }, + "open_rate": { + "type": "number" + }, + "click_rate": { + "type": "number" + }, + "reply_rate": { + "type": "number" + } + } + }, + "AccountHealthSummary": { + "type": "object", + "properties": { + "total_accounts": { + "type": "integer" + }, + "healthy_accounts": { + "type": "integer" + }, + "warning_accounts": { + "type": "integer" + }, + "error_accounts": { + "type": "integer" + } + } + }, + "DailyTrendPoint": { + "type": "object", + "properties": { + "date": { + "type": "string", + "format": "date" + }, + "sent": { + "type": "integer" + }, + "opens": { + "type": "integer" + }, + "clicks": { + "type": "integer" + }, + "replies": { + "type": "integer" + } + } + }, + "DeliverabilityDashboard": { + "type": "object", + "description": "Deliverability posture over a window. spam_placement_rate and inbox_placement_rate are omitted when there are no seed samples.", + "required": [ + "from", + "to", + "band" + ], + "properties": { + "from": { + "type": "string", + "format": "date-time" + }, + "to": { + "type": "string", + "format": "date-time" + }, + "events_total": { + "type": "integer" + }, + "bounce_count": { + "type": "integer" + }, + "complaint_count": { + "type": "integer" + }, + "unsubscribe_count": { + "type": "integer" + }, + "reply_count": { + "type": "integer" + }, + "open_count": { + "type": "integer" + }, + "click_count": { + "type": "integer" + }, + "suppressed_recipients": { + "type": "integer" + }, + "dlq_pending": { + "type": "integer" + }, + "intent_positive": { + "type": "integer" + }, + "intent_negative": { + "type": "integer" + }, + "intent_out_of_office": { + "type": "integer" + }, + "intent_question": { + "type": "integer" + }, + "intent_neutral": { + "type": "integer" + }, + "emails_sent": { + "type": "integer" + }, + "bounce_rate": { + "type": "number" + }, + "complaint_rate": { + "type": "number" + }, + "open_rate": { + "type": "number" + }, + "click_rate": { + "type": "number" + }, + "reply_rate": { + "type": "number" + }, + "spam_placement_rate": { + "type": "number", + "description": "Omitted when there are no seed samples in the window." + }, + "inbox_placement_rate": { + "type": "number", + "description": "Omitted when there are no seed samples in the window." + }, + "placement_samples": { + "type": "integer" + }, + "band": { + "type": "string", + "description": "Overall health band.", + "enum": [ + "healthy", + "watch", + "throttled", + "quarantined", + "blocked" + ] + }, + "timeseries": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DeliverabilityDayPoint" + } + }, + "by_mailbox": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DeliverabilityMailbox" + } + }, + "by_campaign": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DeliverabilityCampaign" + } + } + } + }, + "DeliverabilityDayPoint": { + "type": "object", + "properties": { + "date": { + "type": "string", + "format": "date" + }, + "sent": { + "type": "integer" + }, + "bounces": { + "type": "integer" + }, + "complaints": { + "type": "integer" + }, + "opens": { + "type": "integer" + }, + "clicks": { + "type": "integer" + }, + "replies": { + "type": "integer" + }, + "unsubscribes": { + "type": "integer" + } + } + }, + "DeliverabilityMailbox": { + "type": "object", + "properties": { + "email_account_id": { + "type": "string", + "format": "uuid" + }, + "email": { + "type": "string" + }, + "sent": { + "type": "integer" + }, + "bounces": { + "type": "integer" + }, + "complaints": { + "type": "integer" + }, + "bounce_rate": { + "type": "number" + }, + "complaint_rate": { + "type": "number" + }, + "band": { + "type": "string" + } + } + }, + "DeliverabilityCampaign": { + "type": "object", + "properties": { + "campaign_id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "sent": { + "type": "integer" + }, + "bounces": { + "type": "integer" + }, + "complaints": { + "type": "integer" + }, + "bounce_rate": { + "type": "number" + }, + "complaint_rate": { + "type": "number" + }, + "band": { + "type": "string" + } + } + }, + "WarmupAnalytics": { + "type": "object", + "description": "Warmup send and reply statistics over a date range. email_account_id is the zero UUID when no email_id filter is supplied.", + "required": [ + "email_account_id", + "date_range", + "summary", + "daily_stats" + ], + "properties": { + "email_account_id": { + "type": "string", + "format": "uuid" + }, + "email": { + "type": "string" + }, + "date_range": { + "$ref": "#/components/schemas/DateRange" + }, + "summary": { + "$ref": "#/components/schemas/WarmupSummary" + }, + "daily_stats": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WarmupDayPoint" + } + } + } + }, + "WarmupSummary": { + "type": "object", + "properties": { + "total_sent": { + "type": "integer" + }, + "total_replied": { + "type": "integer" + }, + "average_daily": { + "type": "number" + }, + "reply_rate": { + "type": "number" + }, + "target_progress": { + "type": "number" + }, + "days_active": { + "type": "integer" + } + } + }, + "WarmupDayPoint": { + "type": "object", + "properties": { + "date": { + "type": "string", + "format": "date" + }, + "emails_sent": { + "type": "integer" + }, + "emails_replied": { + "type": "integer" + }, + "target_volume": { + "type": "integer" + } + } + }, + "DateRange": { + "type": "object", + "properties": { + "from": { + "type": "string", + "format": "date-time" + }, + "to": { + "type": "string", + "format": "date-time" + } + } + }, + "CampaignAnalytics": { + "type": "object", + "description": "A single campaign's performance summary plus per-step stats.", + "required": [ + "campaign_id", + "name", + "status", + "summary", + "steps" + ], + "properties": { + "campaign_id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "status": { + "type": "string" + }, + "date_range": { + "$ref": "#/components/schemas/DateRange" + }, + "summary": { + "$ref": "#/components/schemas/CampaignAnalyticsSummary" + }, + "steps": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CampaignStepStats" + } + } + } + }, + "CampaignAnalyticsSummary": { + "type": "object", + "description": "machine_opens is the subset of unique_opens from automated fetchers; human opens are unique_opens minus machine_opens.", + "properties": { + "total_contacts": { + "type": "integer" + }, + "emails_sent": { + "type": "integer" + }, + "emails_pending": { + "type": "integer" + }, + "unique_opens": { + "type": "integer" + }, + "machine_opens": { + "type": "integer" + }, + "unique_clicks": { + "type": "integer" + }, + "replies": { + "type": "integer" + }, + "bounces": { + "type": "integer" + }, + "unsubscribes": { + "type": "integer" + }, + "open_rate": { + "type": "number" + }, + "click_rate": { + "type": "number" + }, + "reply_rate": { + "type": "number" + }, + "bounce_rate": { + "type": "number" + } + } + }, + "CampaignStepStats": { + "type": "object", + "properties": { + "step_id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "position": { + "type": "integer" + }, + "emails_sent": { + "type": "integer" + }, + "opens": { + "type": "integer" + }, + "clicks": { + "type": "integer" + }, + "replies": { + "type": "integer" + }, + "bounces": { + "type": "integer" + } + } + }, + "CampaignDailyStats": { + "type": "object", + "description": "Per-day campaign series under a data envelope.", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CampaignDayPoint" + } + } + } + }, + "CampaignDayPoint": { + "type": "object", + "properties": { + "date": { + "type": "string", + "format": "date" + }, + "sent": { + "type": "integer" + }, + "opens": { + "type": "integer" + }, + "clicks": { + "type": "integer" + }, + "replies": { + "type": "integer" + } + } + }, + "CampaignHourlyStats": { + "type": "object", + "description": "Per-hour campaign series under a data envelope with the resolved date echoed back.", + "required": [ + "data", + "date" + ], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CampaignHourPoint" + } + }, + "date": { + "type": "string", + "format": "date" + } + } + }, + "CampaignHourPoint": { + "type": "object", + "properties": { + "hour": { + "type": "integer", + "minimum": 0, + "maximum": 23 + }, + "sent": { + "type": "integer" + }, + "opens": { + "type": "integer" + }, + "clicks": { + "type": "integer" + }, + "replies": { + "type": "integer" + } + } + }, + "CampaignComparison": { + "type": "object", + "description": "Side-by-side performance for up to 10 campaigns over a date range.", + "required": [ + "campaigns", + "period" + ], + "properties": { + "campaigns": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CampaignComparisonItem" + } + }, + "period": { + "$ref": "#/components/schemas/DateRange" + } + } + }, + "CampaignComparisonItem": { + "type": "object", + "properties": { + "campaign_id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "status": { + "type": "string" + }, + "emails_sent": { + "type": "integer" + }, + "open_rate": { + "type": "number" + }, + "click_rate": { + "type": "number" + }, + "reply_rate": { + "type": "number" + }, + "bounce_rate": { + "type": "number" + } + } + }, + "AccountStatus": { + "type": "object", + "description": "Summary health and usage status of an email account.", + "required": [ + "id", + "email", + "provider", + "status", + "health", + "daily_usage" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "email": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "status": { + "type": "string" + }, + "last_synced_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "health": { + "$ref": "#/components/schemas/AccountHealth" + }, + "errors": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AccountError" + } + }, + "daily_usage": { + "$ref": "#/components/schemas/AccountDailyUsage" + }, + "in_campaign": { + "type": "boolean" + } + } + }, + "AccountStatusDetail": { + "allOf": [ + { + "$ref": "#/components/schemas/AccountStatus" + }, + { + "type": "object", + "description": "warmup_status is present only when warmup has ever been enabled; warmup_health is present only when the mailbox is in a warmup pool.", + "properties": { + "warmup_status": { + "$ref": "#/components/schemas/WarmupStatus" + }, + "warmup_health": { + "$ref": "#/components/schemas/WarmupHealth" + } + } + } + ] + }, + "AccountHealth": { + "type": "object", + "properties": { + "status": { + "type": "string", + "description": "For example healthy, warning, error." + }, + "score": { + "type": "integer" + }, + "issues": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "AccountError": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "error_code": { + "type": "string" + }, + "severity": { + "type": "string" + }, + "title": { + "type": "string" + }, + "message": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + } + } + }, + "AccountDailyUsage": { + "type": "object", + "properties": { + "date": { + "type": "string", + "format": "date" + }, + "campaign_sent": { + "type": "integer" + }, + "campaign_limit": { + "type": "integer" + }, + "warmup_sent": { + "type": "integer" + }, + "warmup_limit": { + "type": "integer" + } + } + }, + "WarmupStatus": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "paused": { + "type": "boolean" + }, + "started_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "current_volume": { + "type": "integer" + }, + "target_volume": { + "type": "integer" + }, + "max_volume": { + "type": "integer" + }, + "reply_rate": { + "type": "number" + }, + "days_active": { + "type": "integer" + } + } + }, + "WarmupHealth": { + "type": "object", + "properties": { + "state": { + "type": "string", + "enum": [ + "healthy", + "watch", + "throttled", + "quarantined", + "blocked" + ] + }, + "score": { + "type": "integer" + }, + "spam_score": { + "type": "integer" + }, + "evaluated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "AccountStatusList": { + "type": "object", + "description": "All of the caller's account statuses under a data envelope (no cursor).", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AccountStatus" + } + } + } + }, + "UsageOverview": { + "type": "object", + "description": "Account, campaign, contact, and API usage counters for the caller.", + "required": [ + "period" + ], + "properties": { + "user_id": { + "type": "string", + "format": "uuid" + }, + "period": { + "type": "string", + "enum": [ + "day", + "week", + "month" + ] + }, + "email_accounts": { + "type": "object", + "properties": { + "total": { + "type": "integer" + }, + "active": { + "type": "integer" + }, + "in_warmup": { + "type": "integer" + }, + "with_errors": { + "type": "integer" + } + } + }, + "campaigns": { + "type": "object", + "properties": { + "total": { + "type": "integer" + }, + "active": { + "type": "integer" + }, + "paused": { + "type": "integer" + }, + "draft": { + "type": "integer" + }, + "emails_sent": { + "type": "integer" + } + } + }, + "contacts": { + "type": "object", + "properties": { + "total": { + "type": "integer" + }, + "subscribed": { + "type": "integer" + }, + "added_today": { + "type": "integer" + } + } + }, + "api": { + "type": "object", + "properties": { + "total_calls": { + "type": "integer" + }, + "daily_limit": { + "type": "integer" + }, + "top_endpoints": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + }, + "AuditLog": { + "type": "object", + "description": "One organization audit-trail entry. actor is null when the acting user has since been deleted; entity_id, changes, and metadata are omitted when empty.", + "required": [ + "id", + "org_id", + "action", + "entity_type", + "action_date" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "org_id": { + "type": "string", + "format": "uuid" + }, + "user_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "actor": { + "$ref": "#/components/schemas/AuditActor" + }, + "action_date": { + "type": "string", + "format": "date-time" + }, + "action": { + "type": "string" + }, + "entity_type": { + "type": "string" + }, + "entity_id": { + "type": "string", + "format": "uuid" + }, + "ip_address": { + "type": "string" + }, + "user_agent": { + "type": "string" + }, + "changes": { + "type": "object", + "additionalProperties": true, + "description": "Field-level change set; secret values are never recorded." + }, + "metadata": { + "type": "object", + "additionalProperties": true + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "AuditActor": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "first_name": { + "type": "string" + }, + "last_name": { + "type": "string" + }, + "email": { + "type": "string" + } + } + }, + "AuditLogList": { + "type": "object", + "description": "A page of audit logs with an opaque cursor.", + "required": [ + "data", + "pagination" + ], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AuditLog" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + } + } + }, + "IntegrationCatalogEntry": { + "type": "object", + "description": "Static metadata for one provider the dashboard renders even when no connection exists.", + "required": [ + "provider", + "name", + "auth_method", + "supports_push", + "configured" + ], + "properties": { + "provider": { + "type": "string", + "description": "Provider id.", + "enum": [ + "hubspot", + "salesforce", + "pipedrive", + "close", + "zapier", + "make", + "n8n", + "slack", + "discord", + "calendly", + "cal_com", + "google_sheets" + ] + }, + "name": { + "type": "string" + }, + "tagline": { + "type": "string" + }, + "category": { + "type": "string", + "enum": [ + "crm", + "automation", + "notifications", + "meetings", + "data" + ] + }, + "docs_url": { + "type": "string" + }, + "auth_method": { + "type": "string", + "enum": [ + "oauth", + "api_key", + "webhook" + ] + }, + "badge_color": { + "type": "string" + }, + "beta": { + "type": "boolean" + }, + "webhook_hint": { + "type": "string" + }, + "highlights": { + "type": "array", + "items": { + "type": "string" + } + }, + "scopes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "OAuth scopes requested at authorize time." + }, + "events": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Warmbly events this provider can react to." + }, + "action_types": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Provider action identifiers with a real backend handler." + }, + "supports_push": { + "type": "boolean", + "description": "Whether this provider can be the target of the synchronous push-contacts action." + }, + "capability": { + "type": [ + "object", + "null" + ], + "additionalProperties": true, + "description": "Configurable-action descriptor the dashboard renders onboarding + field-mapping UI from." + }, + "configured": { + "type": "boolean", + "description": "Whether the server has OAuth client credentials wired for this provider." + } + } + }, + "IntegrationCatalogList": { + "type": "object", + "required": [ + "catalog" + ], + "properties": { + "catalog": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IntegrationCatalogEntry" + } + } + } + }, + "IntegrationConnection": { + "type": "object", + "description": "One org's link to one provider. Secrets are never serialized.", + "required": [ + "id", + "organization_id", + "provider", + "label", + "status", + "auth_method", + "sync_direction", + "health", + "created_at", + "updated_at" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "organization_id": { + "type": "string", + "format": "uuid" + }, + "provider": { + "type": "string" + }, + "label": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "pending", + "authorizing", + "connected", + "degraded", + "reauth_required", + "disconnected" + ] + }, + "auth_method": { + "type": "string", + "enum": [ + "oauth", + "api_key", + "webhook" + ] + }, + "display_fields": { + "type": "object", + "additionalProperties": true, + "description": "Non-secret display fields." + }, + "config_capabilities": { + "type": "object", + "additionalProperties": true, + "description": "Per-connection onboarding/capability snapshot." + }, + "sync_direction": { + "type": "string", + "enum": [ + "push", + "pull", + "both" + ] + }, + "connected_by_user_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "external_account_id": { + "type": "string" + }, + "external_account_name": { + "type": "string" + }, + "granted_scopes": { + "type": "array", + "items": { + "type": "string" + } + }, + "token_expires_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "health": { + "type": "string", + "enum": [ + "unknown", + "healthy", + "degraded", + "down" + ] + }, + "health_detail": { + "type": [ + "string", + "null" + ] + }, + "health_checked_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "last_synced_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "last_error": { + "type": [ + "string", + "null" + ] + }, + "last_error_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "inbound_webhook_url": { + "type": "string", + "description": "Returned only at create time for inbound providers (Calendly, Cal.com)." + } + } + }, + "IntegrationConnectionList": { + "type": "object", + "required": [ + "connections" + ], + "properties": { + "connections": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IntegrationConnection" + } + } + } + }, + "IntegrationConnectionDetail": { + "type": "object", + "description": "A connection plus its event subscriptions and recent sync runs (the detail drawer payload).", + "required": [ + "connection", + "events", + "runs" + ], + "properties": { + "connection": { + "$ref": "#/components/schemas/IntegrationConnection" + }, + "events": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IntegrationEventSubscription" + } + }, + "runs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IntegrationSyncRun" + } + } + } + }, + "IntegrationConnectionCreate": { + "type": "object", + "required": [ + "provider" + ], + "properties": { + "provider": { + "type": "string", + "description": "A valid api_key/webhook provider id (e.g. close, discord). OAuth providers are rejected." + }, + "label": { + "type": "string", + "description": "Friendly name shown on the connection card." + }, + "config": { + "type": "object", + "additionalProperties": true, + "description": "Provider-specific config (e.g. the pasted API key or webhook URL)." + } + } + }, + "IntegrationConnectionConfigUpdate": { + "type": "object", + "properties": { + "config_capabilities": { + "type": "object", + "additionalProperties": true, + "description": "Per-connection capability snapshot (picker selections, enabled use-cases)." + }, + "sync_direction": { + "type": "string", + "enum": [ + "push", + "pull", + "both" + ] + } + } + }, + "IntegrationEventSubscription": { + "type": "object", + "description": "Routes a Warmbly event to a provider (or native) action on a connection.", + "required": [ + "id", + "connection_id", + "event_type", + "action", + "enabled", + "created_at", + "updated_at" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "connection_id": { + "type": "string", + "format": "uuid" + }, + "organization_id": { + "type": "string", + "format": "uuid" + }, + "event_type": { + "type": "string", + "description": "The Warmbly event to react to (e.g. email.replied)." + }, + "action": { + "type": "string", + "description": "Provider action id (e.g. slack.notify, hubspot.upsert_contact) or native action (e.g. warmbly.add_tag, warmbly.label_email)." + }, + "config": { + "type": "object", + "additionalProperties": true, + "description": "Action config (e.g. a Slack channel or message template)." + }, + "enabled": { + "type": "boolean" + }, + "use_case": { + "type": "string", + "description": "Discriminator describing what this automation is for (e.g. crm_sync, notify, custom)." + }, + "automation_id": { + "type": [ + "string", + "null" + ], + "format": "uuid", + "description": "Set when this subscription is one step of an Automation flow." + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "IntegrationEventSubscriptionList": { + "type": "object", + "required": [ + "events" + ], + "properties": { + "events": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IntegrationEventSubscription" + } + } + } + }, + "IntegrationEventSubscriptionCreate": { + "type": "object", + "required": [ + "event_type", + "action" + ], + "properties": { + "event_type": { + "type": "string", + "description": "The Warmbly event to react to (e.g. email.replied)." + }, + "action": { + "type": "string", + "description": "Provider action id (e.g. slack.notify, hubspot.upsert_contact)." + }, + "config": { + "type": "object", + "additionalProperties": true, + "description": "Action config (e.g. a Slack channel or message template)." + }, + "enabled": { + "type": "boolean", + "description": "Defaults to true when omitted." + } + } + }, + "IntegrationFieldMapping": { + "type": "object", + "description": "One Warmbly-field to provider-field mapping row.", + "required": [ + "id", + "connection_id", + "object_name", + "warmbly_field", + "external_field", + "is_default", + "created_at" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "connection_id": { + "type": "string", + "format": "uuid" + }, + "organization_id": { + "type": "string", + "format": "uuid" + }, + "subscription_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "direction": { + "type": "string", + "enum": [ + "push", + "pull", + "both" + ] + }, + "object_name": { + "type": "string" + }, + "warmbly_field": { + "type": "string" + }, + "external_field": { + "type": "string" + }, + "transform": { + "type": "string", + "description": "One of '' (none), none, static, uppercase, lowercase, trim." + }, + "static_value": { + "type": "string" + }, + "is_default": { + "type": "boolean" + }, + "created_at": { + "type": "string", + "format": "date-time" + } + } + }, + "IntegrationFieldMappingList": { + "type": "object", + "required": [ + "mappings" + ], + "properties": { + "mappings": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IntegrationFieldMapping" + } + } + } + }, + "IntegrationFieldMappingReplace": { + "type": "object", + "required": [ + "mappings" + ], + "properties": { + "object": { + "type": "string", + "description": "The provider object the mappings apply to (e.g. contact)." + }, + "mappings": { + "type": "array", + "description": "The full set of mappings to store (replaces any existing).", + "items": { + "type": "object", + "required": [ + "external_field" + ], + "properties": { + "external_field": { + "type": "string", + "description": "Destination field on the provider. Required for every mapping." + }, + "warmbly_field": { + "type": "string", + "description": "Source Warmbly field. Required unless transform is 'static'." + }, + "transform": { + "type": "string", + "description": "One of '' (none), none, static, uppercase, lowercase, trim." + }, + "static_value": { + "type": "string", + "description": "Required when transform is 'static'." + } + } + } + } + } + }, + "IntegrationSyncRun": { + "type": "object", + "description": "One observability record of work done against a connection.", + "required": [ + "id", + "connection_id", + "kind", + "status", + "records_processed", + "started_at" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "connection_id": { + "type": "string", + "format": "uuid" + }, + "organization_id": { + "type": "string", + "format": "uuid" + }, + "kind": { + "type": "string", + "description": "e.g. connect, refresh, dispatch, push." + }, + "status": { + "type": "string", + "description": "e.g. success, error." + }, + "detail": { + "type": "string" + }, + "records_processed": { + "type": "integer" + }, + "started_at": { + "type": "string", + "format": "date-time" + }, + "finished_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + } + } + }, + "IntegrationSyncRunList": { + "type": "object", + "required": [ + "runs" + ], + "properties": { + "runs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IntegrationSyncRun" + } + } + } + }, + "IntegrationWebhookSecret": { + "type": "object", + "required": [ + "signing_secret", + "signature_header", + "scheme" + ], + "properties": { + "signing_secret": { + "type": "string", + "description": "HMAC signing secret used to verify outbound webhook signatures." + }, + "signature_header": { + "type": "string", + "description": "Header carrying the signature (X-Warmbly-Signature)." + }, + "scheme": { + "type": "string", + "description": "Signature scheme description." + } + } + }, + "IntegrationPushRequest": { + "type": "object", + "required": [ + "contact_ids" + ], + "properties": { + "contact_ids": { + "type": "array", + "description": "Contact ids to push. Deduplicated server-side. At least 1, at most 500.", + "items": { + "type": "string", + "format": "uuid" + }, + "minItems": 1, + "maxItems": 500 + } + } + }, + "IntegrationPushResult": { + "type": "object", + "required": [ + "provider", + "pushed", + "failed", + "results" + ], + "properties": { + "provider": { + "type": "string" + }, + "pushed": { + "type": "integer" + }, + "failed": { + "type": "integer" + }, + "results": { + "type": "array", + "items": { + "type": "object", + "required": [ + "contact_id", + "ok" + ], + "properties": { + "contact_id": { + "type": "string", + "format": "uuid" + }, + "email": { + "type": "string" + }, + "ok": { + "type": "boolean" + }, + "error": { + "type": "string", + "description": "Present when ok is false." + } + } + } + } + } + }, + "MeetingBooking": { + "type": "object", + "description": "One booked meeting from a connected scheduling provider (Calendly, Cal.com) or a manually logged meeting.", + "required": [ + "id", + "source", + "status", + "created_at", + "updated_at" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "organization_id": { + "type": "string", + "format": "uuid" + }, + "source": { + "type": "string", + "description": "e.g. calendly, cal_com, manual." + }, + "external_event_id": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "booked", + "rescheduled", + "canceled", + "completed", + "no_show" + ] + }, + "invitee_email": { + "type": "string" + }, + "invitee_name": { + "type": "string" + }, + "event_name": { + "type": "string" + }, + "event_type": { + "type": "string" + }, + "scheduled_for": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "end_time": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "join_url": { + "type": "string" + }, + "location": { + "type": "string" + }, + "cancel_url": { + "type": "string" + }, + "reschedule_url": { + "type": "string" + }, + "canceled_reason": { + "type": "string" + }, + "contact_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "campaign_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "contact_name": { + "type": "string", + "description": "Joined for list display." + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "MeetingBookingList": { + "type": "object", + "required": [ + "bookings" + ], + "properties": { + "bookings": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MeetingBooking" + } + } + } + }, + "Automation": { + "type": "object", + "description": "A branching flow: when the trigger event fires, the executor walks the graph, evaluating condition nodes and running the action nodes on matched paths.", + "required": [ + "id", + "organization_id", + "name", + "enabled", + "trigger_event", + "graph", + "created_at", + "updated_at" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "organization_id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "trigger_event": { + "type": "string", + "description": "The event that fires the flow (e.g. email.replied)." + }, + "filter": { + "type": "object", + "additionalProperties": true, + "description": "Optional automation-wide gate (e.g. intents / min_confidence) applied to every action." + }, + "graph": { + "$ref": "#/components/schemas/AutomationGraph" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "AutomationList": { + "type": "object", + "required": [ + "automations" + ], + "properties": { + "automations": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Automation" + } + } + } + }, + "AutomationWrite": { + "type": "object", + "description": "Create/update payload from the flow builder.", + "required": [ + "name", + "trigger_event", + "graph" + ], + "properties": { + "name": { + "type": "string", + "description": "Display name." + }, + "enabled": { + "type": "boolean", + "description": "Whether the automation runs on matching events." + }, + "trigger_event": { + "type": "string", + "description": "The event that fires the flow (e.g. email.replied)." + }, + "filter": { + "type": "object", + "additionalProperties": true, + "description": "Optional automation-wide gate applied to every action." + }, + "graph": { + "$ref": "#/components/schemas/AutomationGraph" + } + } + }, + "AutomationGraph": { + "type": "object", + "description": "The editable flow: nodes plus the edges connecting them.", + "required": [ + "nodes", + "edges" + ], + "properties": { + "nodes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AutomationNode" + } + }, + "edges": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AutomationEdge" + } + } + } + }, + "AutomationNode": { + "type": "object", + "description": "One node on the canvas.", + "required": [ + "id", + "type", + "x", + "y" + ], + "properties": { + "id": { + "type": "string", + "description": "Node id. The single trigger node uses id 'trigger'." + }, + "type": { + "type": "string", + "enum": [ + "trigger", + "condition", + "action" + ], + "description": "Exactly one trigger node; condition nodes have true/false outgoing edges; action nodes run a handler." + }, + "action": { + "type": "string", + "description": "Action node only: provider action (e.g. slack.notify) or native action (e.g. warmbly.add_tag, warmbly.label_email, warmbly.run_automation)." + }, + "connection_id": { + "type": [ + "string", + "null" + ], + "format": "uuid", + "description": "Action node only: the integration connection the action runs against. Omitted for native (warmbly.*) actions." + }, + "config": { + "type": "object", + "additionalProperties": true, + "description": "Action node config." + }, + "condition": { + "$ref": "#/components/schemas/AutomationCondition" + }, + "x": { + "type": "number", + "description": "Canvas x coordinate." + }, + "y": { + "type": "number", + "description": "Canvas y coordinate." + } + } + }, + "AutomationEdge": { + "type": "object", + "required": [ + "id", + "source", + "target" + ], + "properties": { + "id": { + "type": "string" + }, + "source": { + "type": "string" + }, + "target": { + "type": "string" + }, + "when": { + "type": "string", + "description": "'' for plain edges; 'true' / 'false' for the two outgoing edges of a condition node." + } + } + }, + "AutomationCondition": { + "type": "object", + "description": "An IF test evaluated against the trigger event's data.", + "required": [ + "field", + "operator" + ], + "properties": { + "field": { + "type": "string", + "description": "Condition kind, e.g. 'field' or 'expression'." + }, + "key": { + "type": "string", + "description": "For 'field' conditions, the event-data key to test." + }, + "operator": { + "type": "string" + }, + "value": { + "description": "Comparison value (any JSON type)." + }, + "expression": { + "type": "string", + "description": "For 'expression' conditions, a Go-template predicate evaluated against the event data." + } + } + }, + "AutomationDryRunRequest": { + "type": "object", + "description": "Optional. When omitted, the server builds a sample event from the trigger.", + "properties": { + "data": { + "type": "object", + "additionalProperties": true, + "description": "Sample event payload to evaluate the flow against." + } + } + }, + "AutomationDryRunResponse": { + "type": "object", + "required": [ + "trace", + "data" + ], + "properties": { + "trace": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AutomationNodeResult" + } + }, + "data": { + "type": "object", + "additionalProperties": true, + "description": "The resolved event data the flow was evaluated against." + } + } + }, + "AutomationRun": { + "type": "object", + "description": "One execution of an automation graph (per fired event or manual launch).", + "required": [ + "id", + "automation_id", + "trigger_event", + "status", + "node_results", + "started_at" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "automation_id": { + "type": "string", + "format": "uuid" + }, + "organization_id": { + "type": "string", + "format": "uuid" + }, + "trigger_event": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "running", + "success", + "error" + ] + }, + "node_results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AutomationNodeResult" + } + }, + "error_detail": { + "type": "string" + }, + "started_at": { + "type": "string", + "format": "date-time" + }, + "finished_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + } + } + }, + "AutomationRunList": { + "type": "object", + "required": [ + "runs" + ], + "properties": { + "runs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AutomationRun" + } + } + } + }, + "AutomationNodeResult": { + "type": "object", + "description": "One node's outcome in a run (or a dry-run trace).", + "required": [ + "node_id", + "type", + "status" + ], + "properties": { + "node_id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "trigger", + "condition", + "action" + ] + }, + "action": { + "type": "string", + "description": "Action nodes only." + }, + "label": { + "type": "string", + "description": "Human summary (e.g. 'Slack · #sales')." + }, + "status": { + "type": "string", + "enum": [ + "success", + "error", + "skipped", + "branch_true", + "branch_false" + ] + }, + "error": { + "type": "string" + }, + "preview": { + "type": "object", + "additionalProperties": true, + "description": "Dry-run only: what the action would send." + } + } + }, + "Team": { + "type": "object", + "description": "A named, color-tagged grouping of an organization's members.", + "required": [ + "id", + "organization_id", + "name", + "color", + "members", + "created_at", + "updated_at" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "organization_id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "color": { + "type": "string", + "description": "Hex color for the chip (defaults to #94a3b8)." + }, + "members": { + "type": "array", + "description": "The members that belong to this team. Always an array (never null).", + "items": { + "$ref": "#/components/schemas/TeamMember" + } + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "TeamMember": { + "type": "object", + "description": "One membership row of a team, with the user's email and name joined for display.", + "required": [ + "user_id", + "email", + "name", + "added_at" + ], + "properties": { + "user_id": { + "type": "string", + "format": "uuid" + }, + "email": { + "type": "string", + "format": "email" + }, + "name": { + "type": "string" + }, + "added_at": { + "type": "string", + "format": "date-time" + } + } + }, + "TeamCollection": { + "type": "object", + "description": "A bare collection of teams (not cursor-paginated).", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Team" + } + } + } + }, + "TeamCreate": { + "type": "object", + "description": "Request body for creating a team.", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "color": { + "type": "string", + "description": "Hex color (defaults to #94a3b8)." + } + } + }, + "TeamUpdate": { + "type": "object", + "description": "Partial-update body for a team. Omitted fields are left untouched.", + "minProperties": 1, + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "color": { + "type": "string", + "description": "Hex color." + } + } + }, + "TeamAddMember": { + "type": "object", + "description": "Request body for adding an existing organization member to a team.", + "required": [ + "user_id" + ], + "properties": { + "user_id": { + "type": "string", + "format": "uuid", + "description": "The member's user id (must already belong to the organization)." + } + } + }, + "Plan": { + "type": "object", + "description": "A public subscription plan.", + "required": [ + "id", + "price", + "duration", + "public" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": [ + "string", + "null" + ] + }, + "max_contacts": { + "type": "integer" + }, + "daily_emails": { + "type": "integer" + }, + "ai_generation": { + "type": "boolean" + }, + "account_limit": { + "type": "integer" + }, + "price": { + "type": "number", + "format": "float" + }, + "discounted_price": { + "type": "number", + "format": "float" + }, + "duration": { + "type": "string", + "enum": [ + "month", + "year" + ] + }, + "savings": { + "type": "integer", + "description": "Percentage savings versus monthly (0-255)." + }, + "public": { + "type": "boolean" + }, + "stripe_price_id": { + "type": [ + "string", + "null" + ] + }, + "stripe_price_id_yearly": { + "type": [ + "string", + "null" + ] + }, + "stripe_product_id": { + "type": [ + "string", + "null" + ] + }, + "dedicated_workers": { + "type": "integer" + }, + "daily_campaign_limit": { + "type": [ + "integer", + "null" + ] + }, + "max_campaigns": { + "type": [ + "integer", + "null" + ] + }, + "max_active_campaigns": { + "type": [ + "integer", + "null" + ] + }, + "max_team_members": { + "type": [ + "integer", + "null" + ] + }, + "max_email_accounts": { + "type": [ + "integer", + "null" + ] + }, + "monthly_credits": { + "type": "integer", + "description": "AI writing-assistant monthly credit grant for this plan." + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "PlanList": { + "type": "object", + "description": "The list of public subscription plans.", + "required": [ + "plans" + ], + "properties": { + "plans": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Plan" + } + } + } + }, + "TimezoneOption": { + "type": "object", + "description": "A supported timezone identifier and its display label.", + "required": [ + "name", + "display_name" + ], + "properties": { + "name": { + "type": "string", + "description": "IANA name, e.g. \"Europe/Budapest\"." + }, + "display_name": { + "type": "string", + "description": "Human label, e.g. \"(UTC+02:00) Europe/Budapest\"." + } + } + }, + "UpsertOutreachSettingsRequest": { + "type": "object", + "required": [ + "settings" + ], + "properties": { + "settings": { + "$ref": "#/components/schemas/AdvancedOutreachSettings" + } + } + }, + "IngestDeliverabilityEventRequest": { + "type": "object", + "required": [ + "event_type", + "recipient_email" + ], + "properties": { + "event_type": { + "type": "string", + "enum": [ + "bounce", + "complaint", + "unsubscribe", + "open", + "click", + "reply" + ], + "description": "The deliverability signal type." + }, + "recipient_email": { + "type": "string", + "format": "email", + "description": "The recipient address the event is about." + }, + "campaign_id": { + "type": "string", + "format": "uuid", + "description": "Campaign the event is attributed to." + }, + "task_id": { + "type": "string", + "format": "uuid", + "description": "Send task the event is attributed to." + }, + "contact_id": { + "type": "string", + "format": "uuid", + "description": "Contact the event is attributed to." + }, + "provider": { + "type": "string", + "description": "Source provider label (e.g. ses, postmark)." + }, + "reason": { + "type": "string", + "description": "Human-readable reason or diagnostic text." + }, + "idempotency_key": { + "type": "string", + "description": "De-duplicates retried events." + }, + "metadata": { + "type": "object", + "additionalProperties": true, + "description": "Free-form JSON attached to the event." + } + } + }, + "TaskDeadLetter": { + "type": "object", + "description": "A task that exhausted its retry budget and landed in the dead-letter queue.", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "Dead-letter record ID (use this to replay)." + }, + "task_id": { + "type": "string", + "format": "uuid", + "description": "The underlying task ID." + }, + "task_type": { + "type": "string" + }, + "payload": { + "type": "object", + "additionalProperties": true, + "description": "The original task payload." + }, + "last_error": { + "type": "string" + }, + "attempts": { + "type": "integer" + }, + "max_attempts": { + "type": "integer" + }, + "status": { + "type": "string", + "description": "e.g. pending, replayed." + }, + "next_retry_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "replayed_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "task_id", + "task_type", + "status", + "attempts", + "max_attempts", + "created_at", + "updated_at" + ] + }, + "TaskDeadLetterList": { + "type": "object", + "description": "List of dead-letter records. Not cursor-paginated.", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TaskDeadLetter" + } + } + } + }, + "ReplayDeadLetterResponse": { + "type": "object", + "required": [ + "status" + ], + "properties": { + "status": { + "type": "string", + "example": "replayed" + } + } + }, + "WarmupRoutingRule": { + "type": "object", + "description": "A customer-defined preference applied during premium-pool partner selection.", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "organization_id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "priority": { + "type": "integer", + "description": "Evaluation order, ascending (lower runs first)." + }, + "sender_match_type": { + "type": "string", + "enum": [ + "any", + "domain", + "tld", + "provider" + ] + }, + "sender_match_value": { + "type": "string", + "description": "Lowercased/trimmed; empty when sender_match_type is any." + }, + "recipient_match_type": { + "type": "string", + "enum": [ + "any", + "domain", + "tld", + "provider" + ] + }, + "recipient_match_value": { + "type": "string", + "description": "Lowercased/trimmed; empty when recipient_match_type is any." + }, + "weight": { + "type": "number", + "description": "Selection weight, >= 0." + }, + "enabled": { + "type": "boolean" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "organization_id", + "name", + "priority", + "sender_match_type", + "recipient_match_type", + "weight", + "enabled", + "created_at", + "updated_at" + ] + }, + "WarmupRoutingRuleInput": { + "type": "object", + "description": "Create/update payload for a warmup routing rule. A match value is required unless its match type is `any`.", + "required": [ + "name", + "sender_match_type", + "recipient_match_type" + ], + "properties": { + "name": { + "type": "string", + "description": "Display name for the rule." + }, + "priority": { + "type": "integer", + "description": "Evaluation order, ascending. Lower runs first." + }, + "sender_match_type": { + "type": "string", + "enum": [ + "any", + "domain", + "tld", + "provider" + ] + }, + "sender_match_value": { + "type": "string", + "description": "Required unless sender_match_type is any. Domain (acme.com), TLD (com), or provider bucket (google, microsoft, yahoo, apple, proton, zoho, custom)." + }, + "recipient_match_type": { + "type": "string", + "enum": [ + "any", + "domain", + "tld", + "provider" + ] + }, + "recipient_match_value": { + "type": "string", + "description": "Required unless recipient_match_type is any. Same value forms as the sender side." + }, + "weight": { + "type": "number", + "minimum": 0, + "description": "Selection weight, must be >= 0." + }, + "enabled": { + "type": "boolean", + "description": "Whether the rule is active." + } + } + }, + "WarmupRoutingRuleList": { + "type": "object", + "description": "Warmup routing rules under a `rules` key (always an array, never null). Not cursor-paginated.", + "required": [ + "rules" + ], + "properties": { + "rules": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WarmupRoutingRule" + } + } + } + }, + "ReplyTemplate": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "organization_id": { + "type": "string", + "format": "uuid" + }, + "user_id": { + "type": "string", + "format": "uuid", + "description": "Owner (the user that created the template)." + }, + "name": { + "type": "string" + }, + "subject": { + "type": "string", + "description": "May contain {{.Key}} placeholders." + }, + "body_html": { + "type": "string" + }, + "body_plain": { + "type": "string" + }, + "position": { + "type": "integer", + "description": "1-indexed ordering within the org's list." + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "organization_id", + "user_id", + "name", + "position", + "created_at", + "updated_at" + ] + }, + "ReplyTemplateList": { + "type": "object", + "description": "Reply templates under a `data` key. Not cursor-paginated.", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ReplyTemplate" + } + } + } + }, + "CreateReplyTemplate": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "maxLength": 255, + "description": "Template name." + }, + "subject": { + "type": "string", + "description": "Subject line (may contain {{.Key}} placeholders)." + }, + "body_html": { + "type": "string" + }, + "body_plain": { + "type": "string" + } + } + }, + "UpdateReplyTemplate": { + "type": "object", + "description": "All fields optional; omitted fields are left unchanged.", + "properties": { + "name": { + "type": "string" + }, + "subject": { + "type": "string" + }, + "body_html": { + "type": "string" + }, + "body_plain": { + "type": "string" + } + } + }, + "ReorderReplyTemplates": { + "type": "object", + "required": [ + "ids" + ], + "properties": { + "ids": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "description": "Template IDs in their new order (1-indexed). IDs omitted are left untouched." + } + } + }, + "RenderReplyTemplateRequest": { + "type": "object", + "properties": { + "variables": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Values substituted into {{.Key}} placeholders." + } + } + }, + "RenderedReplyTemplate": { + "type": "object", + "required": [ + "subject", + "body_html", + "body_plain" + ], + "properties": { + "subject": { + "type": "string" + }, + "body_html": { + "type": "string" + }, + "body_plain": { + "type": "string" + } + } + }, + "ScoreTemplateRequest": { + "type": "object", + "description": "Content to score. body_plain is preferred over body_html when present.", + "properties": { + "subject": { + "type": "string" + }, + "body_html": { + "type": "string", + "description": "Used when body_plain is empty." + }, + "body_plain": { + "type": "string", + "description": "Preferred over HTML when present." + } + } + }, + "TemplateScoreIssue": { + "type": "object", + "required": [ + "severity", + "code", + "message" + ], + "properties": { + "severity": { + "type": "string", + "enum": [ + "warn", + "high" + ] + }, + "code": { + "type": "string", + "description": "Stable issue code (e.g. too_many_links)." + }, + "message": { + "type": "string" + } + } + }, + "TemplateScoreResult": { + "type": "object", + "required": [ + "score", + "issues" + ], + "properties": { + "score": { + "type": "integer", + "minimum": 0, + "maximum": 100, + "description": "Advisory content-safety score (higher is safer)." + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TemplateScoreIssue" + } + } + } + } + } + } +} diff --git a/internal/api/handler/analytics.go b/internal/api/handler/analytics.go index 3f5798ea..503f482b 100644 --- a/internal/api/handler/analytics.go +++ b/internal/api/handler/analytics.go @@ -230,10 +230,9 @@ func (h *Handler) GetRealtimeInfo(c *gin.Context) { // GetDashboardAnalytics returns main dashboard analytics overview // GET /analytics/dashboard?period=7d func (h *Handler) GetDashboardAnalytics(c *gin.Context) { - userIDStr := middleware.GetUserID(c) - userID, err := uuid.Parse(userIDStr) - if err != nil { - errx.Handle(c, errx.ErrAuth) + orgID := middleware.GetOrganizationID(c) + if orgID == nil { + errx.Handle(c, errx.New(errx.BadRequest, "no organization selected")) return } @@ -243,7 +242,7 @@ func (h *Handler) GetDashboardAnalytics(c *gin.Context) { period = "7d" } - analytics, xerr := h.AnalyticsService.GetDashboardAnalytics(c.Request.Context(), userID, period) + analytics, xerr := h.AnalyticsService.GetDashboardAnalytics(c.Request.Context(), *orgID, period) if xerr != nil { errx.Handle(c, xerr) return diff --git a/internal/api/handler/api_key.go b/internal/api/handler/api_key.go index 18bcc7be..db627213 100644 --- a/internal/api/handler/api_key.go +++ b/internal/api/handler/api_key.go @@ -10,6 +10,7 @@ import ( "github.com/warmbly/warmbly/internal/api/middleware" "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/utils/paging" ) // CreateAPIKey creates a new API key for the organization @@ -56,9 +57,12 @@ func (h *Handler) ListAPIKeys(c *gin.Context) { var cursor *uuid.UUID if cursorStr := c.Query("cursor"); cursorStr != "" { - if id, err := uuid.Parse(cursorStr); err == nil { - cursor = &id + id, err := paging.DecodeUUID(cursorStr) + if err != nil { + errx.Handle(c, errx.New(errx.BadRequest, "invalid cursor")) + return } + cursor = &id } limit := 50 @@ -244,9 +248,12 @@ func (h *Handler) ListAPIKeyUsageLogs(c *gin.Context) { var cursor *uuid.UUID if cursorStr := c.Query("cursor"); cursorStr != "" { - if id, err := uuid.Parse(cursorStr); err == nil { - cursor = &id + id, err := paging.DecodeUUID(cursorStr) + if err != nil { + errx.Handle(c, errx.New(errx.BadRequest, "invalid cursor")) + return } + cursor = &id } limit := 50 diff --git a/internal/api/handler/attachment.go b/internal/api/handler/attachment.go index fea85484..4a100714 100644 --- a/internal/api/handler/attachment.go +++ b/internal/api/handler/attachment.go @@ -76,7 +76,7 @@ func (h *Handler) UploadCampaignAttachment(c *gin.Context) { // Optional sequence_id form field scopes the attachment to one step. var seqID *uuid.UUID - if s := strings.TrimSpace(c.PostForm("sequence_id")); s != "" { + if s := strings.TrimSpace(c.PostForm("step_id")); s != "" { if id, perr := uuid.Parse(s); perr == nil { seqID = &id } @@ -224,7 +224,7 @@ func (h *Handler) attachmentResponse(c *gin.Context, att *models.CampaignAttachm return gin.H{ "id": att.ID, "campaign_id": att.CampaignID, - "sequence_id": att.SequenceID, + "step_id": att.SequenceID, "filename": att.Filename, "size": att.Size, "mime_type": att.MimeType, diff --git a/internal/api/handler/audit.go b/internal/api/handler/audit.go index aa779fee..6bee93a7 100644 --- a/internal/api/handler/audit.go +++ b/internal/api/handler/audit.go @@ -9,6 +9,7 @@ import ( "github.com/warmbly/warmbly/internal/api/middleware" "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/utils/paging" "github.com/warmbly/warmbly/internal/utils/validate" ) @@ -52,7 +53,7 @@ func (h *Handler) GetAuditLogs(c *gin.Context) { return } - cursor, xerr := validate.Uuid(c.Query("cursor")) + cursor, xerr := paging.DecodeCursor(c.Query("cursor")) if xerr != nil { errx.Handle(c, xerr) return diff --git a/internal/api/handler/auth.go b/internal/api/handler/auth.go index 4547b969..6f394329 100644 --- a/internal/api/handler/auth.go +++ b/internal/api/handler/auth.go @@ -206,3 +206,28 @@ func (h *Handler) ResetPasswordConfirm(c *gin.Context) { c.Status(http.StatusOK) } + +// ChangePassword updates the signed-in user's password (current + new). +func (h *Handler) ChangePassword(c *gin.Context) { + uid, err := uuid.Parse(middleware.GetUserID(c)) + if err != nil { + errx.Handle(c, errx.ErrUnauthorized) + return + } + + var data auth.ChangePassword + if berr := c.ShouldBindJSON(&data); berr != nil { + errx.Handle(c, errx.ErrInvalid) + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), authRequestTimeout) + defer cancel() + + if xerr := h.AuthService.ChangePassword(ctx, uid, currentSessionID(c), &data); xerr != nil { + errx.Handle(c, xerr) + return + } + + c.Status(http.StatusOK) +} diff --git a/internal/api/handler/campaign.go b/internal/api/handler/campaign.go index e5a46b43..5baaf824 100644 --- a/internal/api/handler/campaign.go +++ b/internal/api/handler/campaign.go @@ -97,11 +97,15 @@ func (h *Handler) CreateCampaign(c *gin.Context) { } func (h *Handler) GetCampaign(c *gin.Context) { - userID := middleware.GetUserID(c) + orgID := middleware.GetOrganizationID(c) + if orgID == nil { + errx.JSON(c, errx.New(errx.BadRequest, "no organization selected")) + return + } id := c.Param("id") - resp, err := h.CampaignService.Get(c.Request.Context(), userID, id) + resp, err := h.CampaignService.Get(c.Request.Context(), orgID.String(), id) if err != nil { errx.JSON(c, err) return @@ -111,14 +115,18 @@ func (h *Handler) GetCampaign(c *gin.Context) { } func (h *Handler) SearchCampaigns(c *gin.Context) { - userID := middleware.GetUserID(c) + orgID := middleware.GetOrganizationID(c) + if orgID == nil { + errx.JSON(c, errx.New(errx.BadRequest, "no organization selected")) + return + } query := c.Query("q") cursor := c.Query("cursor") folder := c.Query("folder") limit := c.Query("limit") - resp, err := h.CampaignService.Search(c.Request.Context(), userID, query, cursor, folder, limit) + resp, err := h.CampaignService.Search(c.Request.Context(), orgID.String(), query, cursor, folder, limit) if err != nil { errx.JSON(c, err) return diff --git a/internal/api/handler/contact.go b/internal/api/handler/contact.go index bfe40792..1a0ed850 100644 --- a/internal/api/handler/contact.go +++ b/internal/api/handler/contact.go @@ -46,7 +46,11 @@ func (h *Handler) AddContacts(c *gin.Context) { } func (h *Handler) SearchContacts(c *gin.Context) { - userID := middleware.GetUserID(c) + orgID := middleware.GetOrganizationID(c) + if orgID == nil { + errx.Handle(c, errx.New(errx.BadRequest, "no organization selected")) + return + } cursor := c.Query("cursor") category := c.Query("category") @@ -59,7 +63,7 @@ func (h *Handler) SearchContacts(c *gin.Context) { return } - resp, err := h.ContactService.Search(c.Request.Context(), userID, cursor, category, limit, data) + resp, err := h.ContactService.Search(c.Request.Context(), orgID.String(), cursor, category, limit, data) if err != nil { errx.Handle(c, err) return diff --git a/internal/api/handler/crm.go b/internal/api/handler/crm.go index d5671a59..6b082458 100644 --- a/internal/api/handler/crm.go +++ b/internal/api/handler/crm.go @@ -9,6 +9,7 @@ import ( "github.com/warmbly/warmbly/internal/api/middleware" "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/utils/paging" ) // ===================== @@ -68,9 +69,12 @@ func (h *Handler) ListContactNotes(c *gin.Context) { var cursor *uuid.UUID if cursorStr := c.Query("cursor"); cursorStr != "" { - if id, err := uuid.Parse(cursorStr); err == nil { - cursor = &id + id, err := paging.DecodeUUID(cursorStr) + if err != nil { + errx.Handle(c, errx.New(errx.BadRequest, "invalid cursor")) + return } + cursor = &id } result, xerr := h.CRMService.ListNotes(c.Request.Context(), *orgID, contactID, limit, cursor) @@ -157,9 +161,12 @@ func (h *Handler) ListContactActivities(c *gin.Context) { var cursor *uuid.UUID if cursorStr := c.Query("cursor"); cursorStr != "" { - if id, err := uuid.Parse(cursorStr); err == nil { - cursor = &id + id, err := paging.DecodeUUID(cursorStr) + if err != nil { + errx.Handle(c, errx.New(errx.BadRequest, "invalid cursor")) + return } + cursor = &id } result, xerr := h.CRMService.ListActivities(c.Request.Context(), *orgID, contactID, limit, cursor) @@ -416,9 +423,12 @@ func (h *Handler) ListDeals(c *gin.Context) { var cursor *uuid.UUID if cursorStr := c.Query("cursor"); cursorStr != "" { - if id, err := uuid.Parse(cursorStr); err == nil { - cursor = &id + id, err := paging.DecodeUUID(cursorStr) + if err != nil { + errx.Handle(c, errx.New(errx.BadRequest, "invalid cursor")) + return } + cursor = &id } result, xerr := h.CRMService.ListDeals(c.Request.Context(), *orgID, pipelineID, stageID, status, limit, cursor) @@ -451,7 +461,12 @@ func (h *Handler) SearchDeals(c *gin.Context) { limit = l } offset := 0 - if o, err := strconv.Atoi(c.Query("offset")); err == nil && o > 0 { + if cur := c.Query("cursor"); cur != "" { + o, cerr := paging.DecodeOffsetCursor(cur) + if cerr != nil { + errx.Handle(c, cerr) + return + } offset = o } @@ -731,9 +746,12 @@ func (h *Handler) ListCRMTasks(c *gin.Context) { var cursor *uuid.UUID if cursorStr := c.Query("cursor"); cursorStr != "" { - if id, err := uuid.Parse(cursorStr); err == nil { - cursor = &id + id, err := paging.DecodeUUID(cursorStr) + if err != nil { + errx.Handle(c, errx.New(errx.BadRequest, "invalid cursor")) + return } + cursor = &id } result, xerr := h.CRMService.ListCRMTasks(c.Request.Context(), *orgID, contactID, dealID, assignedTo, status, limit, cursor) @@ -766,7 +784,12 @@ func (h *Handler) SearchCRMTasks(c *gin.Context) { limit = l } offset := 0 - if o, err := strconv.Atoi(c.Query("offset")); err == nil && o > 0 { + if cur := c.Query("cursor"); cur != "" { + o, cerr := paging.DecodeOffsetCursor(cur) + if cerr != nil { + errx.Handle(c, cerr) + return + } offset = o } diff --git a/internal/api/handler/email.go b/internal/api/handler/email.go index ff64619a..a6b5aed3 100644 --- a/internal/api/handler/email.go +++ b/internal/api/handler/email.go @@ -11,14 +11,18 @@ import ( ) func (h *Handler) EmailsSearch(c *gin.Context) { - userID := middleware.GetUserID(c) + orgID := middleware.GetOrganizationID(c) + if orgID == nil { + errx.Handle(c, errx.New(errx.BadRequest, "no organization selected")) + return + } query := c.Query("q") cursor := c.Query("cursor") tag := c.Query("tag") limit := c.Query("limit") - resp, err := h.EmailService.Search(c.Request.Context(), userID, query, cursor, tag, limit, middleware.GetAPIKeyAllowedEmailAccounts(c)) + resp, err := h.EmailService.Search(c.Request.Context(), orgID.String(), query, cursor, tag, limit, middleware.GetAPIKeyAllowedEmailAccounts(c)) if err != nil { errx.Handle(c, err) return diff --git a/internal/api/handler/handler.go b/internal/api/handler/handler.go index 523411ca..171dfea9 100644 --- a/internal/api/handler/handler.go +++ b/internal/api/handler/handler.go @@ -22,6 +22,7 @@ import ( "github.com/warmbly/warmbly/internal/app/integration" "github.com/warmbly/warmbly/internal/app/leadsync" "github.com/warmbly/warmbly/internal/app/notification" + "github.com/warmbly/warmbly/internal/app/oauth" "github.com/warmbly/warmbly/internal/app/organization" "github.com/warmbly/warmbly/internal/app/passkey" "github.com/warmbly/warmbly/internal/app/placement" @@ -151,6 +152,10 @@ type Handler struct { IntegrationService integration.Service ContactRepo repository.ContactRepository + // OAuth 2.1 authorization server (third-party app registration + the + // authorization-code-with-PKCE flow + bearer-token validation). + OAuthService *oauth.Service + // Realtime publisher for handler paths that emit live dashboard events // directly (inbound meeting webhooks have no service layer of their own). // nil-safe: realtime is a nicety, not a requirement. @@ -177,6 +182,10 @@ type Handler struct { // as EncryptedKeys. Backed by Postgres in the backend. EmailMessageMap repository.EmailMessageMapRepository + // Click-link store, served to the tracking service over HTTPS at + // /api/v1/internal/tracked-links/:id (same no-direct-Postgres rule). + TrackedLinks repository.TrackedLinkRepository + // Direct repositories used by handlers that don't yet have a // service layer (avatars, etc.). Keep narrow and add a service // only when business logic accumulates. diff --git a/internal/api/handler/integration.go b/internal/api/handler/integration.go index 88f57b34..58827a16 100644 --- a/internal/api/handler/integration.go +++ b/internal/api/handler/integration.go @@ -19,6 +19,7 @@ import ( "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/infrastructure/pubsub" "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/utils/paging" ) // requireIntegrationActor resolves the org + user for a mutating integration @@ -687,7 +688,7 @@ func (h *Handler) CreateAutomation(c *gin.Context) { errx.JSON(c, errx.New(errx.BadRequest, err.Error())) return } - h.auditIntegration(c, userID, models.AuditActionCreate, a.ID, "automation") + h.auditIntegrationEntity(c, userID, models.AuditActionCreate, models.AuditEntityAutomation, a.ID, a.Name) h.StreamingPublisher.PublishAutomationEvent(c.Request.Context(), orgID, userID, pubsub.EventAutomationCreated, a.ID.String(), a.Name) c.JSON(http.StatusCreated, gin.H{"automation": a}) } @@ -712,7 +713,7 @@ func (h *Handler) UpdateAutomation(c *gin.Context) { errx.JSON(c, errx.New(errx.BadRequest, err.Error())) return } - h.auditIntegration(c, userID, models.AuditActionUpdate, id, "automation") + h.auditIntegrationEntity(c, userID, models.AuditActionUpdate, models.AuditEntityAutomation, id, a.Name) h.StreamingPublisher.PublishAutomationEvent(c.Request.Context(), orgID, userID, pubsub.EventAutomationUpdated, id.String(), a.Name) c.JSON(http.StatusOK, gin.H{"automation": a}) } @@ -733,7 +734,7 @@ func (h *Handler) DeleteAutomation(c *gin.Context) { errx.Handle(c, err) return } - h.auditIntegration(c, userID, models.AuditActionDelete, id, "automation") + h.auditIntegrationEntity(c, userID, models.AuditActionDelete, models.AuditEntityAutomation, id, "") h.StreamingPublisher.PublishAutomationEvent(c.Request.Context(), orgID, userID, pubsub.EventAutomationDeleted, id.String(), "") c.JSON(http.StatusOK, gin.H{"deleted": true}) } @@ -795,6 +796,32 @@ func (h *Handler) InboundCalCom(c *gin.Context) { h.handleInboundBooking(c, models.IntegrationCalCom) } +// InboundAutomation runs the automation whose inbound-webhook token is in the +// URL, using the POSTed JSON body as the event payload. Public + token-gated +// (the high-entropy token is the credential); the body is capped and the run is +// dispatched in the background so the caller gets a fast 202. +func (h *Handler) InboundAutomation(c *gin.Context) { + token := strings.TrimSpace(c.Param("token")) + if token == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "token required"}) + return + } + body, err := io.ReadAll(io.LimitReader(c.Request.Body, 1<<20)) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "read body failed"}) + return + } + if err := h.IntegrationService.TriggerInboundAutomation(c.Request.Context(), token, body); err != nil { + if errors.Is(err, integration.ErrInboundAutomationNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "unknown webhook token"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "trigger failed"}) + return + } + c.JSON(http.StatusAccepted, gin.H{"received": true}) +} + func (h *Handler) handleInboundBooking(c *gin.Context, provider models.IntegrationProvider) { secret := strings.TrimSpace(c.Param("secret")) if secret == "" { @@ -947,7 +974,11 @@ func (h *Handler) SearchMeetings(c *gin.Context) { return } limit, _ := strconv.Atoi(c.Query("limit")) - offset, _ := strconv.Atoi(c.Query("offset")) + offset, cerr := paging.DecodeOffsetCursor(c.Query("cursor")) + if cerr != nil { + errx.JSON(c, cerr) + return + } filter := models.MeetingBookingFilter{ Timeframe: strings.TrimSpace(c.Query("timeframe")), Status: strings.TrimSpace(c.Query("status")), @@ -1053,6 +1084,8 @@ func (h *Handler) CreateMeeting(c *gin.Context) { // by hand shouldn't fire "a prospect booked a call" alerts back at them). h.emitMeetingRealtime(c.Request.Context(), orgID, booking, pubsub.EventMeetingBooked) + h.auditOrg(c, models.AuditActionCreate, models.AuditEntityMeeting, &booking.ID, nil, map[string]string{"title": booking.EventName}) + c.JSON(http.StatusCreated, gin.H{"meeting": booking}) } @@ -1081,11 +1114,19 @@ func (h *Handler) DeleteMeeting(c *gin.Context) { return } h.emitMeetingRealtime(c.Request.Context(), orgID, existing, pubsub.EventMeetingCanceled) + h.auditOrg(c, models.AuditActionDelete, models.AuditEntityMeeting, &id, nil, nil) c.JSON(http.StatusOK, gin.H{"deleted": true}) } // auditIntegration is a thin best-effort audit-log wrapper. func (h *Handler) auditIntegration(c *gin.Context, userID uuid.UUID, action models.AuditAction, entityID uuid.UUID, detail string) { + h.auditIntegrationEntity(c, userID, action, models.AuditEntityIntegration, entityID, detail) +} + +// auditIntegrationEntity is auditIntegration with an explicit entity type, so +// automations (and other integration-adjacent surfaces) land in the audit log +// under their own filterable entity instead of a generic "integration" row. +func (h *Handler) auditIntegrationEntity(c *gin.Context, userID uuid.UUID, action models.AuditAction, entityType models.AuditEntityType, entityID uuid.UUID, detail string) { if h.AuditService == nil { return } @@ -1098,5 +1139,5 @@ func (h *Handler) auditIntegration(c *gin.Context, userID uuid.UUID, action mode if orgID == nil { return } - h.AuditService.LogAction(c.Request.Context(), *orgID, userID, action, models.AuditEntityIntegration, &id, c.ClientIP(), c.Request.UserAgent(), nil, meta) + h.AuditService.LogAction(c.Request.Context(), *orgID, userID, action, entityType, &id, c.ClientIP(), c.Request.UserAgent(), nil, meta) } diff --git a/internal/api/handler/internal_tracked_link.go b/internal/api/handler/internal_tracked_link.go new file mode 100644 index 00000000..33a93dde --- /dev/null +++ b/internal/api/handler/internal_tracked_link.go @@ -0,0 +1,40 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// InternalGetTrackedLink resolves a click ticket for the tracking service. +// Auth via middleware.InternalAuthMiddleware (INTERNAL_API_TOKEN, both sides). +// +// GET /api/v1/internal/tracked-links/:id +// -> 200 {"destination":"https://...","task_id":""} | 404 +// +// The tracking service caches positives and negatives aggressively and rate +// limits miss-heavy sources before calling here, so this stays a cheap +// primary-key read even under probing. +func (h *Handler) InternalGetTrackedLink(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + + link, lerr := h.TrackedLinks.GetByID(c.Request.Context(), id) + if lerr != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "lookup failed"}) + return + } + if link == nil { + c.Status(http.StatusNotFound) + return + } + + c.JSON(http.StatusOK, gin.H{ + "destination": link.Destination, + "task_id": link.TaskID.String(), + }) +} diff --git a/internal/api/handler/lead_sync.go b/internal/api/handler/lead_sync.go index d8f98e9b..c7a28627 100644 --- a/internal/api/handler/lead_sync.go +++ b/internal/api/handler/lead_sync.go @@ -175,6 +175,7 @@ func (h *Handler) CreateLeadSyncSource(c *gin.Context) { errx.JSON(c, xerr) return } + h.auditOrg(c, models.AuditActionCreate, models.AuditEntityLeadSyncSource, &src.ID, nil, map[string]string{"sheet": src.SheetTitle}) c.JSON(http.StatusCreated, src) } @@ -218,6 +219,7 @@ func (h *Handler) UpdateLeadSyncSource(c *gin.Context) { errx.JSON(c, xerr) return } + h.auditOrg(c, models.AuditActionUpdate, models.AuditEntityLeadSyncSource, &id, nil, nil) c.JSON(http.StatusOK, src) } @@ -236,6 +238,7 @@ func (h *Handler) DeleteLeadSyncSource(c *gin.Context) { errx.JSON(c, xerr) return } + h.auditOrg(c, models.AuditActionDelete, models.AuditEntityLeadSyncSource, &id, nil, nil) c.Status(http.StatusNoContent) } @@ -257,5 +260,6 @@ func (h *Handler) SyncLeadSyncSourceNow(c *gin.Context) { errx.JSON(c, xerr) return } + h.auditOrg(c, models.AuditActionImport, models.AuditEntityLeadSyncSource, &id, nil, map[string]string{"trigger": "manual_sync"}) c.JSON(http.StatusOK, result) } diff --git a/internal/api/handler/oauth.go b/internal/api/handler/oauth.go new file mode 100644 index 00000000..15584abb --- /dev/null +++ b/internal/api/handler/oauth.go @@ -0,0 +1,370 @@ +package handler + +import ( + "errors" + "fmt" + "net/http" + "os" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "github.com/warmbly/warmbly/internal/api/middleware" + "github.com/warmbly/warmbly/internal/app/oauth" + "github.com/warmbly/warmbly/internal/errx" + "github.com/warmbly/warmbly/internal/models" +) + +// respondOAuthError renders an *oauth.OAuthError as its RFC 6749 JSON body, or a +// generic invalid_request for anything else. +func respondOAuthError(c *gin.Context, err error) { + var oe *oauth.OAuthError + if errors.As(err, &oe) { + c.JSON(oe.HTTPStatus, oe) + return + } + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request", "error_description": err.Error()}) +} + +// --- Application management (JWT + org gate) -------------------------------- + +func (h *Handler) ListOAuthApplications(c *gin.Context) { + orgID := middleware.GetOrganizationID(c) + if orgID == nil { + errx.JSON(c, errx.New(errx.BadRequest, "no organization selected")) + return + } + apps, err := h.OAuthService.ListApplications(c.Request.Context(), *orgID) + if err != nil { + errx.JSON(c, errx.New(errx.Internal, "lookup failed")) + return + } + c.JSON(http.StatusOK, gin.H{"applications": apps}) +} + +func (h *Handler) CreateOAuthApplication(c *gin.Context) { + orgID := middleware.GetOrganizationID(c) + if orgID == nil { + errx.JSON(c, errx.New(errx.BadRequest, "no organization selected")) + return + } + userID, uerr := middleware.GetUserUUID(c) + if uerr != nil { + errx.JSON(c, errx.ErrUnauthorized) + return + } + var w models.OAuthApplicationWrite + if err := c.ShouldBindJSON(&w); err != nil { + errx.JSON(c, errx.New(errx.BadRequest, "invalid request body")) + return + } + app, err := h.OAuthService.RegisterApplication(c.Request.Context(), *orgID, userID, w) + if err != nil { + errx.JSON(c, errx.New(errx.BadRequest, err.Error())) + return + } + c.JSON(http.StatusCreated, app) +} + +func (h *Handler) GetOAuthApplication(c *gin.Context) { + orgID := middleware.GetOrganizationID(c) + if orgID == nil { + errx.JSON(c, errx.New(errx.BadRequest, "no organization selected")) + return + } + id, err := uuid.Parse(c.Param("id")) + if err != nil { + errx.JSON(c, errx.New(errx.BadRequest, "invalid id")) + return + } + app, gerr := h.OAuthService.GetApplication(c.Request.Context(), *orgID, id) + if gerr != nil { + errx.JSON(c, errx.New(errx.Internal, "lookup failed")) + return + } + if app == nil { + errx.JSON(c, errx.New(errx.NotFound, "application not found")) + return + } + c.JSON(http.StatusOK, app) +} + +func (h *Handler) UpdateOAuthApplication(c *gin.Context) { + orgID := middleware.GetOrganizationID(c) + if orgID == nil { + errx.JSON(c, errx.New(errx.BadRequest, "no organization selected")) + return + } + id, err := uuid.Parse(c.Param("id")) + if err != nil { + errx.JSON(c, errx.New(errx.BadRequest, "invalid id")) + return + } + var w models.OAuthApplicationWrite + if err := c.ShouldBindJSON(&w); err != nil { + errx.JSON(c, errx.New(errx.BadRequest, "invalid request body")) + return + } + app, uerr := h.OAuthService.UpdateApplication(c.Request.Context(), *orgID, id, w) + if uerr != nil { + errx.JSON(c, errx.New(errx.BadRequest, uerr.Error())) + return + } + c.JSON(http.StatusOK, app) +} + +func (h *Handler) DeleteOAuthApplication(c *gin.Context) { + orgID := middleware.GetOrganizationID(c) + if orgID == nil { + errx.JSON(c, errx.New(errx.BadRequest, "no organization selected")) + return + } + id, err := uuid.Parse(c.Param("id")) + if err != nil { + errx.JSON(c, errx.New(errx.BadRequest, "invalid id")) + return + } + if derr := h.OAuthService.DeleteApplication(c.Request.Context(), *orgID, id); derr != nil { + errx.JSON(c, errx.New(errx.Internal, "delete failed")) + return + } + c.JSON(http.StatusOK, gin.H{"deleted": true}) +} + +func (h *Handler) RotateOAuthApplicationSecret(c *gin.Context) { + orgID := middleware.GetOrganizationID(c) + if orgID == nil { + errx.JSON(c, errx.New(errx.BadRequest, "no organization selected")) + return + } + id, err := uuid.Parse(c.Param("id")) + if err != nil { + errx.JSON(c, errx.New(errx.BadRequest, "invalid id")) + return + } + secret, rerr := h.OAuthService.RotateSecret(c.Request.Context(), *orgID, id) + if rerr != nil { + errx.JSON(c, errx.New(errx.BadRequest, rerr.Error())) + return + } + c.JSON(http.StatusOK, gin.H{"client_secret": secret}) +} + +// UploadOAuthAppLogo stores an app logo image (PNG/JPG, <=2MB) in public object +// storage and returns its URL. Used by the registration UI during the branding +// step: there is no app id yet, so the returned URL is sent in the create/update +// payload. Reuses the avatar upload validation + storage path. +func (h *Handler) UploadOAuthAppLogo(c *gin.Context) { + orgID := middleware.GetOrganizationID(c) + if orgID == nil { + errx.JSON(c, errx.New(errx.BadRequest, "no organization selected")) + return + } + body, mime, ext, xerr := readAvatarUpload(c) + if xerr != nil { + errx.JSON(c, xerr) + return + } + key := fmt.Sprintf("oauth-app-logos/%s-%d%s", orgID.String(), time.Now().Unix(), ext) + url, xerr := putPublicObject(c.Request.Context(), h.Storage, key, body, mime) + if xerr != nil { + errx.JSON(c, xerr) + return + } + c.JSON(http.StatusOK, gin.H{"logo_url": url}) +} + +// --- Authorize (JWT: the logged-in user consents) --------------------------- + +func authorizeRequestFrom(get func(string) string) oauth.AuthorizeRequest { + return oauth.AuthorizeRequest{ + ResponseType: get("response_type"), + ClientID: get("client_id"), + RedirectURI: get("redirect_uri"), + Scope: get("scope"), + State: get("state"), + CodeChallenge: get("code_challenge"), + CodeChallengeMethod: get("code_challenge_method"), + } +} + +// OAuthAuthorizeDetails returns the consent info the dashboard renders before +// asking the user to approve. Read from query params. +func (h *Handler) OAuthAuthorizeDetails(c *gin.Context) { + req := authorizeRequestFrom(c.Query) + info, err := h.OAuthService.AuthorizeDetails(c.Request.Context(), req) + if err != nil { + respondOAuthError(c, err) + return + } + c.JSON(http.StatusOK, info) +} + +// OAuthAuthorize is called when the user approves consent. It mints the code and +// returns the redirect URL the browser should follow back to the app. +func (h *Handler) OAuthAuthorize(c *gin.Context) { + orgID := middleware.GetOrganizationID(c) + if orgID == nil { + errx.JSON(c, errx.New(errx.BadRequest, "no organization selected")) + return + } + userID, uerr := middleware.GetUserUUID(c) + if uerr != nil { + errx.JSON(c, errx.ErrUnauthorized) + return + } + var body struct { + ResponseType string `json:"response_type"` + ClientID string `json:"client_id"` + RedirectURI string `json:"redirect_uri"` + Scope string `json:"scope"` + State string `json:"state"` + CodeChallenge string `json:"code_challenge"` + CodeChallengeMethod string `json:"code_challenge_method"` + } + if err := c.ShouldBindJSON(&body); err != nil { + errx.JSON(c, errx.New(errx.BadRequest, "invalid request body")) + return + } + req := oauth.AuthorizeRequest{ + ResponseType: body.ResponseType, + ClientID: body.ClientID, + RedirectURI: body.RedirectURI, + Scope: body.Scope, + State: body.State, + CodeChallenge: body.CodeChallenge, + CodeChallengeMethod: body.CodeChallengeMethod, + } + redirect, err := h.OAuthService.IssueAuthorizationCode(c.Request.Context(), *orgID, userID, req) + if err != nil { + respondOAuthError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"redirect_url": redirect}) +} + +// --- Token + revoke (public; client-authenticated) -------------------------- + +// clientCredentials pulls client_id/secret from HTTP Basic auth (preferred) or +// the form body, per RFC 6749 §2.3.1. +func clientCredentials(c *gin.Context) (string, string) { + if id, secret, ok := c.Request.BasicAuth(); ok { + return id, secret + } + return c.PostForm("client_id"), c.PostForm("client_secret") +} + +// OAuthToken is the token endpoint. It dispatches on grant_type. +func (h *Handler) OAuthToken(c *gin.Context) { + grantType := c.PostForm("grant_type") + clientID, clientSecret := clientCredentials(c) + switch grantType { + case "authorization_code": + resp, err := h.OAuthService.ExchangeCode( + c.Request.Context(), clientID, clientSecret, + c.PostForm("code"), c.PostForm("redirect_uri"), c.PostForm("code_verifier"), + ) + if err != nil { + respondOAuthError(c, err) + return + } + c.Header("Cache-Control", "no-store") + c.JSON(http.StatusOK, resp) + case "refresh_token": + resp, err := h.OAuthService.RefreshToken(c.Request.Context(), clientID, clientSecret, c.PostForm("refresh_token")) + if err != nil { + respondOAuthError(c, err) + return + } + c.Header("Cache-Control", "no-store") + c.JSON(http.StatusOK, resp) + default: + c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported_grant_type", "error_description": "grant_type must be authorization_code or refresh_token"}) + } +} + +// OAuthRevoke is the token-revocation endpoint (RFC 7009). It succeeds even for +// an unknown token, so the response never confirms a token's existence. +func (h *Handler) OAuthRevoke(c *gin.Context) { + clientID, clientSecret := clientCredentials(c) + token := c.PostForm("token") + if err := h.OAuthService.RevokeToken(c.Request.Context(), clientID, clientSecret, token); err != nil { + respondOAuthError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"revoked": true}) +} + +// --- Authorized apps (JWT: the user's connected apps) ----------------------- + +func (h *Handler) ListAuthorizedApps(c *gin.Context) { + orgID := middleware.GetOrganizationID(c) + if orgID == nil { + errx.JSON(c, errx.New(errx.BadRequest, "no organization selected")) + return + } + userID, uerr := middleware.GetUserUUID(c) + if uerr != nil { + errx.JSON(c, errx.ErrUnauthorized) + return + } + apps, err := h.OAuthService.ListAuthorizedApps(c.Request.Context(), *orgID, userID) + if err != nil { + errx.JSON(c, errx.New(errx.Internal, "lookup failed")) + return + } + c.JSON(http.StatusOK, gin.H{"authorized_apps": apps}) +} + +func (h *Handler) RevokeAuthorizedApp(c *gin.Context) { + orgID := middleware.GetOrganizationID(c) + if orgID == nil { + errx.JSON(c, errx.New(errx.BadRequest, "no organization selected")) + return + } + userID, uerr := middleware.GetUserUUID(c) + if uerr != nil { + errx.JSON(c, errx.ErrUnauthorized) + return + } + appID, err := uuid.Parse(c.Param("id")) + if err != nil { + errx.JSON(c, errx.New(errx.BadRequest, "invalid id")) + return + } + if rerr := h.OAuthService.RevokeAuthorization(c.Request.Context(), *orgID, userID, appID); rerr != nil { + errx.JSON(c, errx.New(errx.Internal, "revoke failed")) + return + } + c.JSON(http.StatusOK, gin.H{"revoked": true}) +} + +// --- Discovery (RFC 8414) --------------------------------------------------- + +// OAuthServerMetadata advertises the authorization server's endpoints and +// capabilities. The authorization endpoint is the dashboard consent page; token +// and revocation are this API. APP_URL/API_PUBLIC_URL drive the absolute URLs. +func (h *Handler) OAuthServerMetadata(c *gin.Context) { + appURL := strings.TrimRight(os.Getenv("APP_URL"), "/") + apiURL := strings.TrimRight(os.Getenv("API_PUBLIC_URL"), "/") + if apiURL == "" { + scheme := "https" + if c.Request.TLS == nil && c.Request.Header.Get("X-Forwarded-Proto") == "" { + scheme = "http" + } + apiURL = scheme + "://" + c.Request.Host + } + c.JSON(http.StatusOK, gin.H{ + "issuer": apiURL, + "authorization_endpoint": appURL + "/oauth/authorize", + "token_endpoint": apiURL + "/v1/oauth/token", + "revocation_endpoint": apiURL + "/v1/oauth/revoke", + "response_types_supported": []string{"code"}, + "grant_types_supported": []string{"authorization_code", "refresh_token"}, + "code_challenge_methods_supported": []string{"S256"}, + "token_endpoint_auth_methods_supported": []string{"client_secret_basic", "client_secret_post"}, + "scopes_supported": oauth.ScopeList(models.AllAPIPermissionsMask), + }) +} diff --git a/internal/api/handler/organization.go b/internal/api/handler/organization.go index 01a3146f..0c734024 100644 --- a/internal/api/handler/organization.go +++ b/internal/api/handler/organization.go @@ -147,6 +147,12 @@ func (h *Handler) UpdateOrganization(c *gin.Context) { h.auditOrg(c, models.AuditActionUpdate, models.AuditEntityOrganization, orgID, nil, nil) + // A presence privacy change re-gates connected sockets live (re-track / + // untrack / strip activity) rather than waiting for members to reconnect. + if req.PresenceShowOnline != nil || req.PresenceShowActivity != nil { + h.StreamingPublisher.PublishPresencePolicy(c.Request.Context(), *orgID, org.PresenceShowOnline, org.PresenceShowActivity) + } + c.JSON(http.StatusOK, org) } @@ -254,7 +260,13 @@ func (h *Handler) UpdateMemberRole(c *gin.Context) { return } - member, xerr := h.OrganizationService.UpdateMemberRole(c.Request.Context(), *orgID, memberUserID, &req) + actorID, uerr := middleware.GetUserUUID(c) + if uerr != nil { + errx.JSON(c, errx.New(errx.Unauthorized, "invalid user")) + return + } + + member, xerr := h.OrganizationService.UpdateMemberRole(c.Request.Context(), *orgID, actorID, memberUserID, &req) if xerr != nil { errx.JSON(c, xerr) return @@ -350,6 +362,43 @@ func (h *Handler) CancelInvitation(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "invitation cancelled"}) } +// PreviewInvitation is the public landing-page lookup for the /invite link. +// No auth: anyone holding the secret token can see who invited them where. +func (h *Handler) PreviewInvitation(c *gin.Context) { + token := c.Query("token") + if token == "" { + errx.JSON(c, errx.New(errx.BadRequest, "token is required")) + return + } + preview, xerr := h.OrganizationService.PreviewInvitation(c.Request.Context(), token) + if xerr != nil { + errx.JSON(c, xerr) + return + } + c.JSON(http.StatusOK, preview) +} + +// GetInvitationLink returns the shareable /invite token for a pending +// invitation so a team manager can copy a real accept link. +func (h *Handler) GetInvitationLink(c *gin.Context) { + orgID := middleware.GetOrganizationID(c) + if orgID == nil { + errx.JSON(c, errx.New(errx.BadRequest, "no organization selected")) + return + } + invitationID, err := uuid.Parse(c.Param("id")) + if err != nil { + errx.JSON(c, errx.ErrUuid) + return + } + token, xerr := h.OrganizationService.GetInvitationToken(c.Request.Context(), *orgID, invitationID) + if xerr != nil { + errx.JSON(c, xerr) + return + } + c.JSON(http.StatusOK, gin.H{"token": token}) +} + // AcceptInvitation accepts an invitation (public endpoint - can be called before login or after) func (h *Handler) AcceptInvitation(c *gin.Context) { userID, err := uuid.Parse(middleware.GetUserID(c)) @@ -371,7 +420,15 @@ func (h *Handler) AcceptInvitation(c *gin.Context) { return } - member, xerr := h.OrganizationService.AcceptInvitation(c.Request.Context(), req.Token, userID, user.Email) + var member *models.OrganizationMember + if req.Token != "" { + member, xerr = h.OrganizationService.AcceptInvitation(c.Request.Context(), req.Token, userID, user.Email) + } else if req.InvitationID != nil { + member, xerr = h.OrganizationService.AcceptInvitationByID(c.Request.Context(), *req.InvitationID, userID, user.Email) + } else { + errx.JSON(c, errx.New(errx.BadRequest, "token or invitation_id is required")) + return + } if xerr != nil { errx.JSON(c, xerr) return diff --git a/internal/api/handler/organization_roles.go b/internal/api/handler/organization_roles.go new file mode 100644 index 00000000..1f5a4935 --- /dev/null +++ b/internal/api/handler/organization_roles.go @@ -0,0 +1,124 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/warmbly/warmbly/internal/api/middleware" + "github.com/warmbly/warmbly/internal/errx" + "github.com/warmbly/warmbly/internal/models" +) + +// Custom org roles: named permission sets managed by anyone with +// PermManageTeam (mutations). Listing is open to every member so role names +// render on the roster for non-admins too. + +func (h *Handler) ListOrganizationRoles(c *gin.Context) { + orgID := middleware.GetOrganizationID(c) + if orgID == nil { + errx.JSON(c, errx.New(errx.BadRequest, "no organization selected")) + return + } + + roles, xerr := h.OrganizationService.ListRoles(c.Request.Context(), *orgID) + if xerr != nil { + errx.JSON(c, xerr) + return + } + + c.JSON(http.StatusOK, gin.H{"data": roles}) +} + +func (h *Handler) CreateOrganizationRole(c *gin.Context) { + orgID := middleware.GetOrganizationID(c) + if orgID == nil { + errx.JSON(c, errx.New(errx.BadRequest, "no organization selected")) + return + } + + var req models.CreateOrganizationRoleRequest + if err := c.ShouldBindJSON(&req); err != nil { + errx.JSON(c, errx.ErrInvalid) + return + } + + actorID, uerr := middleware.GetUserUUID(c) + if uerr != nil { + errx.JSON(c, errx.New(errx.Unauthorized, "invalid user")) + return + } + + role, xerr := h.OrganizationService.CreateRole(c.Request.Context(), *orgID, actorID, &req) + if xerr != nil { + errx.JSON(c, xerr) + return + } + + h.auditOrg(c, models.AuditActionCreate, models.AuditEntityRole, &role.ID, nil, map[string]string{"name": role.Name}) + + c.JSON(http.StatusCreated, role) +} + +func (h *Handler) UpdateOrganizationRole(c *gin.Context) { + orgID := middleware.GetOrganizationID(c) + if orgID == nil { + errx.JSON(c, errx.New(errx.BadRequest, "no organization selected")) + return + } + roleID, err := uuid.Parse(c.Param("id")) + if err != nil { + errx.JSON(c, errx.ErrUuid) + return + } + + var req models.UpdateOrganizationRoleRequest + if berr := c.ShouldBindJSON(&req); berr != nil { + errx.JSON(c, errx.ErrInvalid) + return + } + + actorID, uerr := middleware.GetUserUUID(c) + if uerr != nil { + errx.JSON(c, errx.New(errx.Unauthorized, "invalid user")) + return + } + + role, xerr := h.OrganizationService.UpdateRole(c.Request.Context(), *orgID, actorID, roleID, &req) + if xerr != nil { + errx.JSON(c, xerr) + return + } + + h.auditOrg(c, models.AuditActionUpdate, models.AuditEntityRole, &roleID, nil, map[string]string{"name": role.Name}) + + c.JSON(http.StatusOK, role) +} + +func (h *Handler) DeleteOrganizationRole(c *gin.Context) { + orgID := middleware.GetOrganizationID(c) + if orgID == nil { + errx.JSON(c, errx.New(errx.BadRequest, "no organization selected")) + return + } + roleID, err := uuid.Parse(c.Param("id")) + if err != nil { + errx.JSON(c, errx.ErrUuid) + return + } + + actorID, uerr := middleware.GetUserUUID(c) + if uerr != nil { + errx.JSON(c, errx.New(errx.Unauthorized, "invalid user")) + return + } + + if xerr := h.OrganizationService.DeleteRole(c.Request.Context(), *orgID, actorID, roleID); xerr != nil { + errx.JSON(c, xerr) + return + } + + h.auditOrg(c, models.AuditActionDelete, models.AuditEntityRole, &roleID, nil, nil) + + c.Status(http.StatusNoContent) +} diff --git a/internal/api/handler/team.go b/internal/api/handler/team.go index 44b4cd2a..30bf3725 100644 --- a/internal/api/handler/team.go +++ b/internal/api/handler/team.go @@ -55,6 +55,8 @@ func (h *Handler) CreateTeam(c *gin.Context) { return } + h.auditOrg(c, models.AuditActionCreate, models.AuditEntityTeam, &team.ID, nil, map[string]string{"name": team.Name}) + c.JSON(http.StatusCreated, team) } @@ -103,6 +105,8 @@ func (h *Handler) UpdateTeam(c *gin.Context) { return } + h.auditOrg(c, models.AuditActionUpdate, models.AuditEntityTeam, &teamID, nil, nil) + c.JSON(http.StatusOK, team) } @@ -124,6 +128,8 @@ func (h *Handler) DeleteTeam(c *gin.Context) { return } + h.auditOrg(c, models.AuditActionDelete, models.AuditEntityTeam, &teamID, nil, nil) + c.Status(http.StatusNoContent) } @@ -160,6 +166,8 @@ func (h *Handler) AddTeamMember(c *gin.Context) { return } + h.auditOrg(c, models.AuditActionAssign, models.AuditEntityTeam, &teamID, nil, map[string]string{"member_user_id": data.UserID.String()}) + c.JSON(http.StatusOK, team) } @@ -185,5 +193,7 @@ func (h *Handler) RemoveTeamMember(c *gin.Context) { return } + h.auditOrg(c, models.AuditActionRemove, models.AuditEntityTeam, &teamID, nil, map[string]string{"member_user_id": userID.String()}) + c.Status(http.StatusNoContent) } diff --git a/internal/api/handler/test_email.go b/internal/api/handler/test_email.go index 20bb5209..a4ca2c4d 100644 --- a/internal/api/handler/test_email.go +++ b/internal/api/handler/test_email.go @@ -11,7 +11,7 @@ import ( ) type sendTestEmailRequest struct { - SequenceID *uuid.UUID `json:"sequence_id"` + SequenceID *uuid.UUID `json:"step_id"` AccountID uuid.UUID `json:"account_id" binding:"required"` Recipient string `json:"recipient" binding:"required,email"` } diff --git a/internal/api/handler/unibox.go b/internal/api/handler/unibox.go index 4a1d6835..7ec22262 100644 --- a/internal/api/handler/unibox.go +++ b/internal/api/handler/unibox.go @@ -40,6 +40,12 @@ func (h *Handler) GetUniboxIncoming(c *gin.Context) { return } + orgID := middleware.GetOrganizationID(c) + if orgID == nil { + errx.Handle(c, errx.New(errx.BadRequest, "no organization selected")) + return + } + // Check if organization can use unibox (active free trial or paid subscription) if h.FeatureGateService != nil { orgID := middleware.GetOrganizationID(c) @@ -148,7 +154,7 @@ func (h *Handler) GetUniboxIncoming(c *gin.Context) { } } - resp, xerr := h.UniboxService.Search(c.Request.Context(), uid, params) + resp, xerr := h.UniboxService.Search(c.Request.Context(), *orgID, uid, params) if xerr != nil { errx.Handle(c, xerr) return @@ -197,28 +203,28 @@ func (h *Handler) GetUniboxEmail(c *gin.Context) { } func (h *Handler) GetUniboxThread(c *gin.Context) { - userID := middleware.GetUserID(c) - uid, err := uuid.Parse(userID) - if err != nil { + // Org-scoped: the inbox list is org-wide, so the thread view must be too. + // Otherwise a non-owner member sees the conversation in the list but an + // empty thread when they open it — the messages are keyed to the mailbox + // owner's user_id, not theirs. + orgID := middleware.GetOrganizationID(c) + if orgID == nil { errx.Handle(c, errx.ErrUser) return } // Check if organization can use unibox if h.FeatureGateService != nil { - orgID := middleware.GetOrganizationID(c) - if orgID != nil { - canUse, _ := h.FeatureGateService.CanUseUnibox(c.Request.Context(), *orgID) - if !canUse { - errx.Handle(c, errx.New(errx.Forbidden, "Unibox requires an active trial or paid subscription")) - return - } + canUse, _ := h.FeatureGateService.CanUseUnibox(c.Request.Context(), *orgID) + if !canUse { + errx.Handle(c, errx.New(errx.Forbidden, "Unibox requires an active trial or paid subscription")) + return } } - // email_id is optional. When omitted, the thread is read across - // every mailbox the user owns — the natural unified-inbox view - // where the caller only knows the thread. + // email_id is optional. When omitted, the thread is read across every + // mailbox in the organization — the natural unified-inbox view where the + // caller only knows the thread. var eid uuid.UUID emailID := c.Query("email") if emailID == "" { @@ -246,7 +252,7 @@ func (h *Handler) GetUniboxThread(c *gin.Context) { resp, xerr := h.UniboxService.GetByThread( c.Request.Context(), - uid, eid, + *orgID, eid, threadID, limit, cursor, ) if xerr != nil { @@ -325,9 +331,10 @@ func (h *Handler) SetUniboxThreadLabels(c *gin.Context) { } func (h *Handler) UniboxMarkSeen(c *gin.Context) { - userID := middleware.GetUserID(c) - uid, err := uuid.Parse(userID) - if err != nil { + // Org-scoped: the inbox + unread count are org-wide, so marking read must be + // too, otherwise a non-owner member can never clear the shared unread badge. + orgID := middleware.GetOrganizationID(c) + if orgID == nil { errx.Handle(c, errx.ErrUser) return } @@ -338,7 +345,7 @@ func (h *Handler) UniboxMarkSeen(c *gin.Context) { return } - resp, xerr := h.UniboxService.MarkSeenBulk(c.Request.Context(), uid, &data) + resp, xerr := h.UniboxService.MarkSeenBulk(c.Request.Context(), *orgID, &data) if xerr != nil { errx.Handle(c, xerr) return @@ -350,10 +357,9 @@ func (h *Handler) UniboxMarkSeen(c *gin.Context) { // GetUnseenCount gets the count of unseen emails // GET /unibox/count func (h *Handler) GetUnseenCount(c *gin.Context) { - userID := middleware.GetUserID(c) - uid, err := uuid.Parse(userID) - if err != nil { - errx.Handle(c, errx.ErrUser) + orgID := middleware.GetOrganizationID(c) + if orgID == nil { + errx.Handle(c, errx.New(errx.BadRequest, "no organization selected")) return } @@ -365,7 +371,7 @@ func (h *Handler) GetUnseenCount(c *gin.Context) { } } - count, xerr := h.UniboxService.GetUnseenCount(c.Request.Context(), uid, emailAccountID) + count, xerr := h.UniboxService.GetUnseenCount(c.Request.Context(), *orgID, emailAccountID) if xerr != nil { errx.Handle(c, xerr) return @@ -459,8 +465,13 @@ func (h *Handler) GetUniboxOverview(c *gin.Context) { errx.Handle(c, errx.ErrUser) return } + orgID := middleware.GetOrganizationID(c) + if orgID == nil { + errx.Handle(c, errx.New(errx.BadRequest, "no organization selected")) + return + } - resp, xerr := h.UniboxService.Overview(c.Request.Context(), uid) + resp, xerr := h.UniboxService.Overview(c.Request.Context(), *orgID, uid) if xerr != nil { errx.Handle(c, xerr) return diff --git a/internal/api/middleware/apikey.go b/internal/api/middleware/apikey.go index cf511b10..6c46c8ef 100644 --- a/internal/api/middleware/apikey.go +++ b/internal/api/middleware/apikey.go @@ -20,8 +20,15 @@ const ( AuthTypeKey = "auth_type" AuthTypeJWT = "jwt" AuthTypeAPIKey = "api_key" + AuthTypeOAuth = "oauth" ) +// bitmaskAuth reports whether a caller's permissions come from a bitmask of API +// scopes (API keys and OAuth tokens) rather than an org role (JWT sessions). +func bitmaskAuth(authType string) bool { + return authType == AuthTypeAPIKey || authType == AuthTypeOAuth +} + // APIKeyMiddleware accepts only API key auth ("Bearer wmbly_..."). Reserved // for endpoints that should never accept browser sessions — none today, but // useful if we add API-only routes (e.g. partner integrations). @@ -51,6 +58,9 @@ func (h *Handler) CombinedAuthMiddleware() gin.HandlerFunc { case strings.HasPrefix(authHeader, "Bearer "+apikey.KeyPrefix): key := strings.TrimPrefix(authHeader, "Bearer ") h.validateAPIKey(c, key) + case strings.HasPrefix(authHeader, "Bearer "+models.OAuthAccessTokenPrefix): + token := strings.TrimPrefix(authHeader, "Bearer ") + h.validateOAuthToken(c, token) case strings.HasPrefix(authHeader, "Bearer "): token := strings.TrimPrefix(authHeader, "Bearer ") h.validateJWT(c, token) @@ -118,6 +128,29 @@ func (h *Handler) validateAPIKey(c *gin.Context, rawKey string) { c.Next() } +// validateOAuthToken authenticates an OAuth 2.1 bearer access token. It sets the +// same context keys as an API key (UserIDKey, OrganizationIDKey, and the granted +// scope bitmask in APIKeyPermissionsKey) so every existing route gate applies +// unchanged; auth_type is "oauth" so usage/last-used logic can tell them apart. +func (h *Handler) validateOAuthToken(c *gin.Context, token string) { + if h.OAuthService == nil { + errx.Handle(c, errx.ErrAuth) + c.Abort() + return + } + claims, err := h.OAuthService.ValidateAccessToken(c.Request.Context(), token) + if err != nil { + errx.Handle(c, errx.ErrAuth) + c.Abort() + return + } + c.Set(AuthTypeKey, AuthTypeOAuth) + c.Set(APIKeyPermissionsKey, claims.Scopes) + c.Set(UserIDKey, claims.UserID.String()) + c.Set(OrganizationIDKey, claims.OrganizationID) + c.Next() +} + func (h *Handler) validateJWT(c *gin.Context, token string) { session, err := h.TokenService.ValidateAccessToken(c.Request.Context(), token) if err != nil { @@ -141,7 +174,7 @@ func (h *Handler) validateJWT(c *gin.Context, token string) { // OrganizationPermission check (RequirePermission / RequireAccess). func RequireAPIPermission(perm uint64) gin.HandlerFunc { return func(c *gin.Context) { - if c.GetString(AuthTypeKey) != AuthTypeAPIKey { + if !bitmaskAuth(c.GetString(AuthTypeKey)) { c.Next() return } @@ -169,7 +202,7 @@ func RequireAPIPermission(perm uint64) gin.HandlerFunc { func (h *Handler) RequireAccess(orgPerm models.OrganizationPermission, apiPerm uint64) gin.HandlerFunc { return func(c *gin.Context) { switch c.GetString(AuthTypeKey) { - case AuthTypeAPIKey: + case AuthTypeAPIKey, AuthTypeOAuth: perms, exists := c.Get(APIKeyPermissionsKey) if !exists { errx.Handle(c, errx.ErrForbidden) @@ -225,7 +258,7 @@ func (h *Handler) RequireAccess(orgPerm models.OrganizationPermission, apiPerm u func (h *Handler) RequireAnyAccess(apiPerm uint64, orgPerms ...models.OrganizationPermission) gin.HandlerFunc { return func(c *gin.Context) { switch c.GetString(AuthTypeKey) { - case AuthTypeAPIKey: + case AuthTypeAPIKey, AuthTypeOAuth: perms, exists := c.Get(APIKeyPermissionsKey) if !exists { errx.Handle(c, errx.ErrForbidden) diff --git a/internal/api/middleware/handler.go b/internal/api/middleware/handler.go index 3f603fc8..cdfdfc83 100644 --- a/internal/api/middleware/handler.go +++ b/internal/api/middleware/handler.go @@ -3,6 +3,7 @@ package middleware import ( "github.com/warmbly/warmbly/internal/app/apikey" "github.com/warmbly/warmbly/internal/app/idempotency" + "github.com/warmbly/warmbly/internal/app/oauth" "github.com/warmbly/warmbly/internal/app/organization" "github.com/warmbly/warmbly/internal/app/ratelimit" "github.com/warmbly/warmbly/internal/app/token" @@ -14,4 +15,7 @@ type Handler struct { IdempotencyService idempotency.Service RateLimitService ratelimit.RateLimitService OrganizationService organization.OrganizationService + // OAuthService validates OAuth 2.1 bearer access tokens (nil-safe: when + // unset, only JWT + API-key auth are accepted). + OAuthService *oauth.Service } diff --git a/internal/api/middleware/version.go b/internal/api/middleware/version.go new file mode 100644 index 00000000..9ef3f61b --- /dev/null +++ b/internal/api/middleware/version.go @@ -0,0 +1,16 @@ +package middleware + +import "github.com/gin-gonic/gin" + +// APIVersion is the current public API version. The customer API is served under +// / (and, for now, also at the bare path as a deprecated alias). +const APIVersion = "v1" + +// APIVersionMiddleware stamps every response with the current API version so a +// client can detect which surface it is talking to without parsing the URL. +func APIVersionMiddleware(version string) gin.HandlerFunc { + return func(c *gin.Context) { + c.Header("API-Version", version) + c.Next() + } +} diff --git a/internal/api/routes.go b/internal/api/routes.go index ac49f9bf..fc84fc92 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -23,6 +23,7 @@ func Run( r := gin.Default() r.Use(middleware.RequestIDMiddleware()) + r.Use(middleware.APIVersionMiddleware(middleware.APIVersion)) r.GET("/health", func(c *gin.Context) { c.JSON(200, gin.H{"status": "ok"}) @@ -37,6 +38,12 @@ func Run( // rotatable from the dashboard. r.POST("/api/v1/integrations/inbound/calendly/:secret", h.InboundCalendly) r.POST("/api/v1/integrations/inbound/cal-com/:secret", h.InboundCalCom) + // Generic per-automation inbound trigger: the token in the path is the + // credential, resolving to one automation that runs with the JSON body. + r.POST("/api/v1/integrations/inbound/automation/:token", h.InboundAutomation) + + // OAuth 2.1 authorization-server discovery (RFC 8414): public + unversioned. + r.GET("/.well-known/oauth-authorization-server", h.OAuthServerMetadata) // Public worker enrollment. The one-time enrollment token is the // credential; successful exchange returns a dotenv file for the installer @@ -61,6 +68,10 @@ func Run( r.GET("/unsubscribe", h.Unsubscribe) r.POST("/unsubscribe", h.Unsubscribe) + // Public invitation preview for the /invite landing page. Unauthenticated: + // the secret token in the query is the capability. + r.GET("/invitations/lookup", h.PreviewInvitation) + // Internal backend-to-backend endpoints. Workers call these instead of // touching Postgres directly, per the no-direct-data-services rule in // CLAUDE.md. Auth: shared bearer token (INTERNAL_API_TOKEN). @@ -71,6 +82,10 @@ func Run( internal.PUT("/dek/:orgID", h.InternalPutDEK) internal.DELETE("/dek/:orgID", h.InternalDeleteDEK) + // Click-link tickets: the tracking service resolves /c/ redirects + // here instead of touching Postgres (read-only, heavily cached there). + internal.GET("/tracked-links/:id", h.InternalGetTrackedLink) + // Worker mailbox-sync messageId -> internal email map (replaces the // former DynamoDB EmailMessageData table). Workers read/write it here. internal.GET("/email-message-map", h.InternalGetEmailMessageMap) @@ -100,6 +115,11 @@ func Run( "X-RateLimit-Remaining", "X-RateLimit-Policy", "Retry-After", + "API-Version", + "Deprecation", + "Sunset", + "Link", + "Warning", }, MaxAge: 12 * time.Hour, } @@ -143,7 +163,14 @@ func Run( c.Next() }) - auth := r.Group("/auth") + // The entire customer-facing API surface (auth + the API-key-capable and + // session-only routes) lives under a single versioned prefix, /v1. There is + // no unversioned alias: a breaking change ships as /v2. Truly public routes + // (health, signed webhooks, OAuth bouncers, worker enroll, the internal API, + // and /admin) are NOT versioned and stay at their bare paths. + v1 := r.Group("/v1") + + auth := v1.Group("/auth") { auth.POST("/login", h.LoginStart) auth.POST("/login/confirm", h.LoginConfirm) @@ -183,6 +210,7 @@ func Run( protectedAuth.PATCH("/me/onboarding", h.CompleteOnboarding) protectedAuth.POST("/me/avatar", h.UploadUserAvatar) protectedAuth.DELETE("/me/avatar", h.DeleteUserAvatar) + protectedAuth.POST("/me/password", m.RateLimitMiddleware(models.RateLimitWrite), h.ChangePassword) // Notification preferences + in-app feed (user-scoped, no org gate). protectedAuth.GET("/me/notification-preferences", h.GetNotificationPreferences) @@ -205,529 +233,582 @@ func Run( protectedAuth.DELETE("/passkey/credentials/:id", h.PasskeyDeleteCredential) } - // JWT-only protected group: routes that are tied to a human session and - // must never be reachable via a long-lived API key. This is the safety - // boundary for billing, organization governance, websocket bootstrapping, - // and the email onboarding flow (which writes user-encrypted secrets). - jwtOnly := r.Group("") - jwtOnly.Use(m.AuthMiddleware()) + // The full customer-facing API surface (the API-key-capable `protected` + // routes and the session-only sensitive routes), mounted under the versioned + // `base` (/v1). Every response also carries an API-Version header. + mountPublicAPI := func(base *gin.RouterGroup) { + // OAuth 2.1 token + revocation endpoints: public and client-authenticated + // (client credentials arrive in the body or via HTTP Basic), so they sit + // outside the JWT/API-key groups. + base.POST("/oauth/token", h.OAuthToken) + base.POST("/oauth/revoke", h.OAuthRevoke) - // API-accessible protected group: routes that accept either a JWT or an - // API key. CombinedAuthMiddleware sets the same context keys for both - // auth types; APIKeyUsageMiddleware records one log row per API-key - // request (JWT requests are skipped). - protected := r.Group("") - protected.Use(m.CombinedAuthMiddleware(), m.APIKeyUsageMiddleware(), m.IdempotencyMiddleware()) - { - emails := protected.Group("/emails") - emails.Use(m.RateLimitMiddleware(models.RateLimitWrite)) + // JWT-only group: routes tied to a human session, never reachable via a + // long-lived API key (billing, org governance, websocket bootstrap, and + // the email onboarding flow that writes user-encrypted secrets). + jwtOnly := base.Group("") + jwtOnly.Use(m.AuthMiddleware()) + + // API-accessible group: routes that accept either a JWT or an API key. + // CombinedAuthMiddleware sets the same context keys for both; the usage + // middleware records one log row per API-key request (JWT skipped). + protected := base.Group("") + protected.Use(m.CombinedAuthMiddleware(), m.APIKeyUsageMiddleware(), m.IdempotencyMiddleware()) { - emails.GET("", m.RequireAccess(models.PermViewCampaigns, models.APIPermReadEmails), h.EmailsSearch) - emails.GET("/:id", m.RequireAccess(models.PermViewCampaigns, models.APIPermReadEmails), middleware.RequireAPIKeyEmailAccountParam("id"), h.GetEmail) - emails.PATCH("/:id", m.RequireAccess(models.PermManageEmails, models.APIPermWriteEmails), middleware.RequireAPIKeyEmailAccountParam("id"), h.UpdateEmail) - emails.PATCH("/:id/track", m.RequireAccess(models.PermManageEmails, models.APIPermWriteEmails), middleware.RequireAPIKeyEmailAccountParam("id"), h.UpdateEmailTrackingDomain) - emails.POST("/:id/warmup/start", m.RequireAccess(models.PermManageEmails, models.APIPermWriteEmails), middleware.RequireAPIKeyEmailAccountParam("id"), h.StartWarmup) - emails.POST("/:id/warmup/pause", m.RequireAccess(models.PermManageEmails, models.APIPermWriteEmails), middleware.RequireAPIKeyEmailAccountParam("id"), h.PauseWarmup) - emails.POST("/:id/warmup/resume", m.RequireAccess(models.PermManageEmails, models.APIPermWriteEmails), middleware.RequireAPIKeyEmailAccountParam("id"), h.ResumeWarmup) - emails.POST("/:id/warmup/stop", m.RequireAccess(models.PermManageEmails, models.APIPermWriteEmails), middleware.RequireAPIKeyEmailAccountParam("id"), h.StopWarmup) - emails.GET("/:id/auth-check", m.RequireAccess(models.PermViewCampaigns, models.APIPermReadEmails), middleware.RequireAPIKeyEmailAccountParam("id"), h.GetEmailAuthCheck) - emails.POST("/verify", m.RequireAccess(models.PermViewCampaigns, models.APIPermReadEmails), h.VerifyEmail) - emails.GET("/:id/warmup/ban-status", m.RequireAccess(models.PermViewCampaigns, models.APIPermReadEmails), middleware.RequireAPIKeyEmailAccountParam("id"), h.GetWarmupBanStatus) - emails.POST("/:id/warmup/appeal", m.RequireAccess(models.PermManageEmails, models.APIPermWriteEmails), middleware.RequireAPIKeyEmailAccountParam("id"), h.SubmitWarmupAppeal) - emails.DELETE("/:id", m.RequireAccess(models.PermManageEmails, models.APIPermWriteEmails), middleware.RequireAPIKeyEmailAccountParam("id"), h.DeleteEmail) - emails.POST("/:id/send", m.RequireOrganization(), m.RequireAccess(models.PermSendCampaigns, models.APIPermSendCampaigns), middleware.RequireAPIKeyEmailAccountParam("id"), h.SendEmailFromAccount) - } - - // Email onboarding is JWT-only — it writes user-encrypted refresh - // tokens via the SPA popup flow and shouldn't be triggerable by an - // API key with a long lifetime. - onboardingEmails := jwtOnly.Group("/emails/onboarding") - onboardingEmails.Use(m.RateLimitMiddleware(models.RateLimitWrite)) - { - onboardingEmails.POST("/oauth/start", h.StartEmailOAuth) - onboardingEmails.POST("/oauth/finish", h.FinishEmailOAuth) - onboardingEmails.POST("/smtp-imap", h.ConnectEmailSMTPIMAP) - } - - // Integration OAuth handshake is JWT-only — it writes user-encrypted - // provider tokens via the SPA popup flow, same as mailbox onboarding. - integrationsOAuth := jwtOnly.Group("/integrations/oauth") - integrationsOAuth.Use(m.RequireOrganization(), m.RateLimitMiddleware(models.RateLimitWrite)) - { - integrationsOAuth.POST("/start", h.StartIntegrationOAuth) - integrationsOAuth.POST("/finish", h.FinishIntegrationOAuth) - integrationsOAuth.POST("/reauth/:id", h.ReauthIntegration) - } - - // Template preview/validation (no campaign id; can't be a static sibling - // of /campaigns/:id, so it lives one level up). Renders against a sample - // contact — read-level access, no side effects. - protected.POST("/campaign-template-preview", m.RequireOrganization(), m.RequireAccess(models.PermViewCampaigns, models.APIPermReadCampaigns), h.PreviewCampaignTemplate) - - campaigns := protected.Group("/campaigns") - campaigns.Use(m.RateLimitMiddleware(models.RateLimitWrite)) - { - campaigns.GET("", m.RequireAccess(models.PermViewCampaigns, models.APIPermReadCampaigns), h.SearchCampaigns) - campaigns.POST("", m.RequireAccess(models.PermManageCampaigns, models.APIPermWriteCampaigns), h.CreateCampaign) - campaigns.GET("/:id", m.RequireAccess(models.PermViewCampaigns, models.APIPermReadCampaigns), h.GetCampaign) - campaigns.PATCH("/:id", m.RequireAccess(models.PermManageCampaigns, models.APIPermWriteCampaigns), h.UpdateCampaign) - campaigns.DELETE("/:id", m.RequireAccess(models.PermManageCampaigns, models.APIPermWriteCampaigns), h.DeleteCampaign) - - // Advanced campaign controls - campaigns.GET("/:id/advanced", m.RequireOrganization(), m.RequireAccess(models.PermViewCampaigns, models.APIPermReadCampaigns), h.GetCampaignAdvancedSettings) - campaigns.PATCH("/:id/advanced", m.RequireOrganization(), m.RequireAccess(models.PermManageSettings, models.APIPermWriteCampaigns), h.UpdateCampaignAdvancedSettings) - campaigns.GET("/:id/ab-variants", m.RequireOrganization(), m.RequireAccess(models.PermViewCampaigns, models.APIPermReadCampaigns), h.ListCampaignABVariants) - campaigns.POST("/:id/ab-variants", m.RequireOrganization(), m.RequireAccess(models.PermManageSettings, models.APIPermWriteCampaigns), h.CreateCampaignABVariant) - campaigns.PATCH("/:id/ab-variants/:variantId", m.RequireOrganization(), m.RequireAccess(models.PermManageSettings, models.APIPermWriteCampaigns), h.UpdateCampaignABVariant) - campaigns.DELETE("/:id/ab-variants/:variantId", m.RequireOrganization(), m.RequireAccess(models.PermManageSettings, models.APIPermWriteCampaigns), h.DeleteCampaignABVariant) - campaigns.GET("/:id/attachments", m.RequireOrganization(), m.RequireAccess(models.PermViewCampaigns, models.APIPermReadCampaigns), h.ListCampaignAttachments) - campaigns.POST("/:id/attachments", m.RequireOrganization(), m.RequireAccess(models.PermManageCampaigns, models.APIPermWriteCampaigns), h.UploadCampaignAttachment) - campaigns.DELETE("/:id/attachments/:attachmentId", m.RequireOrganization(), m.RequireAccess(models.PermManageCampaigns, models.APIPermWriteCampaigns), h.DeleteCampaignAttachment) - campaigns.POST("/:id/preflight", m.RequireOrganization(), m.RequireAccess(models.PermSendCampaigns, models.APIPermSendCampaigns), h.RunCampaignPreflight) - campaigns.GET("/:id/ab-analysis", m.RequireOrganization(), m.RequireAccess(models.PermViewAnalytics, models.APIPermReadAnalytics), h.GetCampaignABAnalysis) - campaigns.POST("/:id/test-email", m.RequireOrganization(), m.RequireAccess(models.PermSendCampaigns, models.APIPermSendCampaigns), h.SendTestEmail) - - // Campaign start/stop - campaigns.POST("/:id/start", m.RequireOrganization(), m.RequireAccess(models.PermSendCampaigns, models.APIPermSendCampaigns), h.StartCampaign) - campaigns.POST("/:id/stop", m.RequireOrganization(), m.RequireAccess(models.PermSendCampaigns, models.APIPermSendCampaigns), h.StopCampaign) - campaigns.GET("/:id/logs", m.RequireAccess(models.PermViewCampaigns, models.APIPermReadCampaigns), h.GetCampaignLogs) - - // Explicit sender pool (rotation/weighting). - campaigns.GET("/:id/senders", m.RequireOrganization(), m.RequireAccess(models.PermViewCampaigns, models.APIPermReadCampaigns), h.ListCampaignSenders) - campaigns.PUT("/:id/senders", m.RequireOrganization(), m.RequireAccess(models.PermManageCampaigns, models.APIPermWriteCampaigns), h.ReplaceCampaignSenders) - - // Campaign-scoped tracking-domain verification. - campaigns.POST("/:id/tracking-domain/verify", m.RequireOrganization(), m.RequireAccess(models.PermManageCampaigns, models.APIPermWriteCampaigns), h.VerifyCampaignTrackingDomain) - - sequences := campaigns.Group("/:id/sequences") + emails := protected.Group("/emails") + emails.Use(m.RateLimitMiddleware(models.RateLimitWrite)) { - sequences.GET("", m.RequireAccess(models.PermViewCampaigns, models.APIPermReadCampaigns), h.GetSequences) - sequences.POST("", m.RequireAccess(models.PermManageCampaigns, models.APIPermWriteCampaigns), h.CreateSequence) - sequences.PATCH("/:sid", m.RequireAccess(models.PermManageCampaigns, models.APIPermWriteCampaigns), h.UpdateSequence) - sequences.DELETE("/:sid", m.RequireAccess(models.PermManageCampaigns, models.APIPermWriteCampaigns), h.DeleteSequence) - } - } - - generation := protected.Group("/generation") - generation.Use(m.RateLimitMiddleware(models.RateLimitWrite)) - { - generation.POST("/write", m.RequireOrganization(), m.RequireAccess(models.PermManageCampaigns, models.APIPermWriteCampaigns), h.GenerateWriting) - } - - contacts := protected.Group("/contacts") - contacts.Use(m.RateLimitMiddleware(models.RateLimitWrite)) - { - contacts.POST("/search", m.RequireAccess(models.PermViewContacts, models.APIPermReadContacts), h.SearchContacts) - contacts.POST("", m.RequireAccess(models.PermManageContacts, models.APIPermWriteContacts), h.AddContacts) - contacts.DELETE("", m.RequireAccess(models.PermManageContacts, models.APIPermBulkContacts), h.DeleteContactBulk) - contacts.PATCH("", m.RequireAccess(models.PermManageContacts, models.APIPermBulkContacts), h.UpdateContactBulk) - // Import + export power-tools. Read-only export gates on - // ReadContacts; the import endpoints write and so use the - // stricter Write/Bulk scopes that the rest of the contact - // write paths already use. - contacts.POST("/export", m.RequireAccess(models.PermViewContacts, models.APIPermReadContacts), h.ExportContacts) - contacts.POST("/import/preview", m.RequireAccess(models.PermManageContacts, models.APIPermWriteContacts), h.ImportPreviewContacts) - contacts.POST("/import/commit", m.RequireAccess(models.PermManageContacts, models.APIPermBulkContacts), h.ImportCommitContacts) - contacts.PATCH("/:id", m.RequireAccess(models.PermManageContacts, models.APIPermWriteContacts), h.UpdateContact) - contacts.DELETE("/:id", m.RequireAccess(models.PermManageContacts, models.APIPermWriteContacts), h.DeleteContact) - - // Resolve a sender address to a contact (unibox CRM panel). - // Registered before /:id so the fixed path wins over the catch-all. - contacts.GET("/lookup", m.RequireAccess(models.PermViewContacts, models.APIPermReadContacts), h.LookupContactByEmail) - - // Contact 360 view: hydrated detail, every email sent to - // the contact, and the merged activity timeline. - contacts.GET("/:id", m.RequireAccess(models.PermViewContacts, models.APIPermReadContacts), h.GetContact) - contacts.GET("/:id/emails", m.RequireAccess(models.PermViewContacts, models.APIPermReadContacts), h.ListContactEmails) - contacts.GET("/:id/timeline", m.RequireAccess(models.PermViewContacts, models.APIPermReadContacts), h.ListContactTimeline) - - // CRM: Notes & Activities (under contacts) - contacts.GET("/:id/notes", m.RequireAccess(models.PermViewContacts, models.APIPermReadContacts), h.ListContactNotes) - contacts.POST("/:id/notes", m.RequireAccess(models.PermManageContacts, models.APIPermWriteContacts), h.CreateContactNote) - contacts.PATCH("/:id/notes/:noteId", m.RequireAccess(models.PermManageContacts, models.APIPermWriteContacts), h.UpdateContactNote) - contacts.DELETE("/:id/notes/:noteId", m.RequireAccess(models.PermManageContacts, models.APIPermWriteContacts), h.DeleteContactNote) - contacts.GET("/:id/activities", m.RequireAccess(models.PermViewContacts, models.APIPermReadContacts), h.ListContactActivities) - contacts.GET("/:id/deals", m.RequireAccess(models.PermViewContacts, models.APIPermReadCRM), h.GetDealsByContact) - } - - // Group endpoints map to the resources they organize: campaign - // folders, email-account tags, and contact categories. - grouph.New(protected, h.FolderService, h.AuditService, "folders", m.RequireAccess(models.PermManageCampaigns, models.APIPermWriteCampaigns)) - grouph.New(protected, h.TagService, h.AuditService, "tags", m.RequireAccess(models.PermManageEmails, models.APIPermWriteEmails)) - grouph.New(protected, h.CategoryService, h.AuditService, "categories", m.RequireAccess(models.PermManageContacts, models.APIPermWriteContacts)) - - unibox := protected.Group("/unibox") - unibox.Use(m.RateLimitMiddleware(models.RateLimitRead)) - { - unibox.GET("", m.RequireAccess(models.PermAccessUnibox, models.APIPermReadUnibox), h.GetUniboxIncoming) - unibox.GET("/count", m.RequireAccess(models.PermAccessUnibox, models.APIPermReadUnibox), h.GetUnseenCount) - unibox.GET("/overview", m.RequireAccess(models.PermAccessUnibox, models.APIPermReadUnibox), h.GetUniboxOverview) - unibox.GET("/thread", m.RequireAccess(models.PermAccessUnibox, models.APIPermReadUnibox), h.GetUniboxThread) - - // Conversation labels — read the set on a thread, or replace - // it wholesale (idempotent PUT). Registered before /:id so the - // fixed path wins over the catch-all. - unibox.GET("/thread/labels", m.RequireAccess(models.PermAccessUnibox, models.APIPermReadUnibox), h.GetUniboxThreadLabels) - unibox.PUT("/thread/labels", m.RequireAccess(models.PermAccessUnibox, models.APIPermWriteUnibox), h.SetUniboxThreadLabels) - - unibox.PATCH("/seen", m.RequireAccess(models.PermAccessUnibox, models.APIPermWriteUnibox), h.UniboxMarkSeen) - unibox.POST("/reply", m.RequireOrganization(), m.RequireAccess(models.PermAccessUnibox, models.APIPermWriteUnibox), h.UniboxReply) - - // Snoozes — POST/DELETE on a thread, GET lists active ones. - unibox.GET("/snoozes", m.RequireAccess(models.PermAccessUnibox, models.APIPermReadUnibox), h.ListUniboxSnoozes) - unibox.POST("/snooze", m.RequireAccess(models.PermAccessUnibox, models.APIPermWriteUnibox), h.CreateUniboxSnooze) - unibox.DELETE("/snooze", m.RequireAccess(models.PermAccessUnibox, models.APIPermWriteUnibox), h.DeleteUniboxSnooze) - - // Scheduled-sends review + cancel. DELETE is DB-only — - // we don't pay Cloud Tasks to delete the queued task; the - // handler short-circuits on cancelled status when it fires. - unibox.GET("/scheduled", m.RequireAccess(models.PermAccessUnibox, models.APIPermReadUnibox), h.ListUniboxScheduled) - unibox.DELETE("/scheduled/:task_id", m.RequireAccess(models.PermAccessUnibox, models.APIPermWriteUnibox), h.CancelUniboxScheduled) - - // Keep /:id last — gin treats it as a catch-all so any - // fixed-name routes (above) must register first. - unibox.GET("/:id", m.RequireAccess(models.PermAccessUnibox, models.APIPermReadUnibox), h.GetUniboxEmail) - } - - // API key management. JWT users need PermManageAPIKeys; API keys - // need the APIPermAPIKeys self-service bit. This lets an integration - // rotate its own keys without going through the dashboard. - apiKeys := protected.Group("/api-keys") - apiKeys.Use(m.RequireOrganization(), m.RequireAccess(models.PermManageAPIKeys, models.APIPermAPIKeys)) - apiKeys.Use(m.RateLimitMiddleware(models.RateLimitWrite)) - { - apiKeys.GET("", h.ListAPIKeys) - apiKeys.POST("", h.CreateAPIKey) - apiKeys.GET("/permissions", h.ListAPIPermissions) - apiKeys.GET("/usage/summary", h.GetAPIKeyUsageSummary) - apiKeys.GET("/usage/analytics", h.GetAPIKeyAnalytics) - apiKeys.GET("/:id", h.GetAPIKey) - apiKeys.PATCH("/:id", h.UpdateAPIKey) - apiKeys.DELETE("/:id", h.RevokeAPIKey) - apiKeys.GET("/:id/analytics", h.GetAPIKeyAnalytics) - apiKeys.GET("/:id/logs", h.ListAPIKeyUsageLogs) - } - - // Analytics endpoints - analytics := protected.Group("/analytics") - analytics.Use(m.RateLimitMiddleware(models.RateLimitAnalytics), m.RequireAccess(models.PermViewAnalytics, models.APIPermReadAnalytics)) - { - analytics.GET("/dashboard", h.GetDashboardAnalytics) - analytics.GET("/deliverability", m.RequireOrganization(), h.GetDeliverabilityDashboard) - analytics.GET("/warmup", h.GetWarmupAnalytics) - analytics.GET("/campaigns/compare", h.CompareCampaigns) - analytics.GET("/campaigns/:id", h.GetCampaignAnalytics) - analytics.GET("/campaigns/:id/daily", h.GetCampaignDailyStats) - analytics.GET("/campaigns/:id/hourly", h.GetCampaignHourlyStats) - analytics.GET("/accounts", h.GetAllAccountStatuses) - analytics.GET("/accounts/:id", h.GetAccountStatus) - analytics.GET("/usage", h.GetUsageOverview) - } - - // Audit logs - auditLogs := protected.Group("/audit-logs") - auditLogs.Use(m.RateLimitMiddleware(models.RateLimitRead), m.RequireAccess(models.PermViewAnalytics, models.APIPermReadAuditLogs)) - { - auditLogs.GET("", h.GetAuditLogs) - } - - // Realtime websocket bootstrap is JWT-only — the websocket itself - // has its own session-based auth. - realtime := jwtOnly.Group("/realtime") - { - realtime.GET("/info", h.GetRealtimeInfo) - } - - // Advanced outreach controls (org-scoped) - outreach := protected.Group("/outreach") - outreach.Use(m.RequireOrganization(), m.RequireAccess(models.PermManageSettings, models.APIPermWriteCampaigns)) - { - outreach.GET("/settings", h.GetOutreachSettings) - outreach.PATCH("/settings", h.UpdateOutreachSettings) - } - - // Deliverability event ingestion (org-scoped). API-key callable so - // downstream pipelines (e.g. SES bounce processors) can post events - // without a human in the loop. - deliverability := protected.Group("/deliverability") - deliverability.Use(m.RequireOrganization(), m.RequireAccess(models.PermSendCampaigns, models.APIPermWriteCampaigns)) - { - deliverability.POST("/events", h.IngestDeliverabilityEvent) - } - - // Task dead letter operations (org-scoped). Requires SendCampaigns - // because a replay actually re-dispatches mail. - taskOps := protected.Group("/tasks") - taskOps.Use(m.RequireOrganization(), m.RequireAccess(models.PermSendCampaigns, models.APIPermSendCampaigns)) - { - taskOps.GET("/dlq", h.ListTaskDeadLetters) - taskOps.POST("/dlq/:id/replay", h.ReplayTaskDeadLetter) - } - - // Customer-facing webhooks (org-scoped). - webhooks := protected.Group("/webhooks") - webhooks.Use(m.RequireOrganization(), m.RequireAccess(models.PermManageSettings, models.APIPermWebhooks), m.RateLimitMiddleware(models.RateLimitWrite)) - { - webhooks.GET("", h.ListWebhookEndpoints) - webhooks.POST("", h.CreateWebhookEndpoint) - webhooks.PATCH("/:id", h.UpdateWebhookEndpoint) - webhooks.DELETE("/:id", h.DeleteWebhookEndpoint) - webhooks.POST("/:id/rotate-secret", h.RotateWebhookSecret) - webhooks.GET("/:id/deliveries", h.ListWebhookDeliveries) - } - - // Third-party integrations (org-scoped). Reads are reachable by both - // settings managers AND operational integration users (PermUseIntegrations) - // so contextual integration actions show up everywhere they belong; - // connecting + configuring stays gated on PermManageSettings. Pushing - // records on demand is an operational action (PermUseIntegrations). - integrations := protected.Group("/integrations") - integrations.Use(m.RequireOrganization(), m.RateLimitMiddleware(models.RateLimitWrite)) - { - read := m.RequireAnyAccess(models.APIPermIntegrations, models.PermManageSettings, models.PermUseIntegrations) - write := m.RequireAccess(models.PermManageSettings, models.APIPermIntegrations) - operate := m.RequireAccess(models.PermUseIntegrations, models.APIPermIntegrations) - - integrations.GET("/catalog", read, h.ListIntegrationCatalog) - integrations.GET("/connections", read, h.ListIntegrationConnections) - integrations.POST("/connections", write, h.ConnectIntegration) - integrations.GET("/connections/:id", read, h.GetIntegrationConnection) - integrations.PATCH("/connections/:id/config", write, h.UpdateConnectionConfig) - integrations.DELETE("/connections/:id", write, h.DisconnectIntegration) - integrations.GET("/connections/:id/events", read, h.ListConnectionEventSubscriptions) - integrations.POST("/connections/:id/events", write, h.CreateConnectionEventSubscription) - integrations.DELETE("/connections/:id/events/:eventId", write, h.DeleteConnectionEventSubscription) - integrations.GET("/connections/:id/field-mappings", read, h.ListConnectionFieldMappings) - integrations.PUT("/connections/:id/field-mappings", write, h.ReplaceConnectionFieldMappings) - integrations.GET("/connections/:id/runs", read, h.ListConnectionSyncRuns) - integrations.GET("/connections/:id/webhook-secret", write, h.GetConnectionWebhookSecret) - integrations.POST("/connections/:id/test", write, h.TestConnection) - integrations.POST("/connections/:id/push", operate, h.PushContactsToIntegration) - integrations.GET("/bookings", read, h.ListMeetingBookings) - } - - // Meetings (org-scoped). Booked calls from connected scheduling - // providers (Calendly / Cal.com), surfaced as a first-class CRM list. - // Read-only and reachable by anyone who can view contacts. - meetings := protected.Group("/meetings") - meetings.Use(m.RequireOrganization(), m.RateLimitMiddleware(models.RateLimitWrite)) - { - meetingsRead := m.RequireAccess(models.PermViewContacts, models.APIPermReadContacts) - meetingsWrite := m.RequireAccess(models.PermManageContacts, models.APIPermWriteContacts) - meetings.GET("", meetingsRead, h.SearchMeetings) - meetings.GET("/summary", meetingsRead, h.MeetingsSummary) - meetings.POST("", meetingsWrite, h.CreateMeeting) - meetings.DELETE("/:id", meetingsWrite, h.DeleteMeeting) - } - - // Automations (org-scoped). The visual flow builder: a trigger event + - // action steps across integrations. Reads reachable by operational - // integration users; creating/editing is a settings action. - automations := protected.Group("/automations") - automations.Use(m.RequireOrganization(), m.RateLimitMiddleware(models.RateLimitWrite)) - { - aread := m.RequireAnyAccess(models.APIPermIntegrations, models.PermManageSettings, models.PermUseIntegrations) - awrite := m.RequireAccess(models.PermManageSettings, models.APIPermIntegrations) - automations.GET("", aread, h.ListAutomations) - automations.POST("", awrite, h.CreateAutomation) - automations.GET("/:id", aread, h.GetAutomation) - automations.PATCH("/:id", awrite, h.UpdateAutomation) - automations.DELETE("/:id", awrite, h.DeleteAutomation) - automations.POST("/:id/test", aread, h.TestAutomation) - automations.GET("/:id/runs", aread, h.ListAutomationRuns) - } - - // On-demand Google Sheets -> leads sync (org-scoped). A saved "sync - // source" the user re-runs with "Sync now"; new rows create contacts and - // existing rows (matched by email) update. Gated under the contacts - // write permissions because it ultimately upserts contacts. The Google - // account itself is connected via the existing /integrations/oauth flow - // with provider "google_sheets". - leadSync := protected.Group("/lead-sync") - leadSync.Use(m.RequireOrganization(), m.RequireAccess(models.PermManageContacts, models.APIPermWriteContacts), m.RateLimitMiddleware(models.RateLimitWrite)) - { - leadSync.GET("/google/connection", h.GetLeadSyncGoogleConnection) - leadSync.POST("/google/spreadsheet", h.GetLeadSyncSpreadsheet) - leadSync.POST("/google/preview", h.PreviewLeadSync) - - leadSync.GET("/sources", h.ListLeadSyncSources) - leadSync.POST("/sources", h.CreateLeadSyncSource) - leadSync.GET("/sources/:id", h.GetLeadSyncSource) - leadSync.PATCH("/sources/:id", h.UpdateLeadSyncSource) - leadSync.DELETE("/sources/:id", h.DeleteLeadSyncSource) - leadSync.POST("/sources/:id/sync", h.SyncLeadSyncSourceNow) - } - - // Warmup routing rules (org-scoped). Lets customers define - // preferences for premium-pool partner selection — e.g. send - // to Gmail recipients only from Google-classified senders. - warmupRouting := protected.Group("/warmup/routing") - warmupRouting.Use(m.RequireOrganization(), m.RequireAccess(models.PermManageSettings, models.APIPermWarmupRouting), m.RateLimitMiddleware(models.RateLimitWrite)) - { - warmupRouting.GET("", h.ListWarmupRoutingRules) - warmupRouting.POST("", h.CreateWarmupRoutingRule) - warmupRouting.PATCH("/:id", h.UpdateWarmupRoutingRule) - warmupRouting.DELETE("/:id", h.DeleteWarmupRoutingRule) - } - - // Reply templates (org-scoped) - templates := protected.Group("/templates") - templates.Use(m.RequireOrganization(), m.RateLimitMiddleware(models.RateLimitWrite)) - { - templates.GET("", m.RequireAccess(models.PermViewCampaigns, models.APIPermReadTemplates), h.ListTemplates) - templates.POST("", m.RequireAccess(models.PermManageCampaigns, models.APIPermWriteTemplates), h.CreateTemplate) - templates.PATCH("/reorder", m.RequireAccess(models.PermManageCampaigns, models.APIPermWriteTemplates), h.ReorderTemplates) - templates.GET("/:id", m.RequireAccess(models.PermViewCampaigns, models.APIPermReadTemplates), h.GetTemplate) - templates.PATCH("/:id", m.RequireAccess(models.PermManageCampaigns, models.APIPermWriteTemplates), h.UpdateTemplate) - templates.DELETE("/:id", m.RequireAccess(models.PermManageCampaigns, models.APIPermWriteTemplates), h.DeleteTemplate) - templates.POST("/:id/duplicate", m.RequireAccess(models.PermManageCampaigns, models.APIPermWriteTemplates), h.DuplicateTemplate) - templates.POST("/:id/render", m.RequireAccess(models.PermViewCampaigns, models.APIPermReadTemplates), h.RenderTemplate) - templates.POST("/score", m.RequireAccess(models.PermViewCampaigns, models.APIPermReadTemplates), h.ScoreTemplateContent) - } - - // CRM routes (require org) - crmGroup := protected.Group("/crm") - crmGroup.Use(m.RequireOrganization(), m.RateLimitMiddleware(models.RateLimitWrite)) - { - pipelines := crmGroup.Group("/pipelines") - { - pipelines.GET("", m.RequireAccess(models.PermViewContacts, models.APIPermReadCRM), h.ListPipelines) - pipelines.POST("", m.RequireAccess(models.PermManageContacts, models.APIPermWriteCRM), h.CreatePipeline) - pipelines.GET("/:id", m.RequireAccess(models.PermViewContacts, models.APIPermReadCRM), h.GetPipeline) - pipelines.PATCH("/:id", m.RequireAccess(models.PermManageContacts, models.APIPermWriteCRM), h.UpdatePipeline) - pipelines.DELETE("/:id", m.RequireAccess(models.PermManageContacts, models.APIPermWriteCRM), h.DeletePipeline) - pipelines.POST("/:id/stages", m.RequireAccess(models.PermManageContacts, models.APIPermWriteCRM), h.CreateStage) - pipelines.PATCH("/:id/stages/:stageId", m.RequireAccess(models.PermManageContacts, models.APIPermWriteCRM), h.UpdateStage) - pipelines.DELETE("/:id/stages/:stageId", m.RequireAccess(models.PermManageContacts, models.APIPermWriteCRM), h.DeleteStage) + emails.GET("", m.RequireAccess(models.PermViewCampaigns, models.APIPermReadEmails), h.EmailsSearch) + emails.GET("/:id", m.RequireAccess(models.PermViewCampaigns, models.APIPermReadEmails), middleware.RequireAPIKeyEmailAccountParam("id"), h.GetEmail) + emails.PATCH("/:id", m.RequireAccess(models.PermManageEmails, models.APIPermWriteEmails), middleware.RequireAPIKeyEmailAccountParam("id"), h.UpdateEmail) + emails.PATCH("/:id/track", m.RequireAccess(models.PermManageEmails, models.APIPermWriteEmails), middleware.RequireAPIKeyEmailAccountParam("id"), h.UpdateEmailTrackingDomain) + emails.POST("/:id/warmup/start", m.RequireAccess(models.PermManageEmails, models.APIPermWriteEmails), middleware.RequireAPIKeyEmailAccountParam("id"), h.StartWarmup) + emails.POST("/:id/warmup/pause", m.RequireAccess(models.PermManageEmails, models.APIPermWriteEmails), middleware.RequireAPIKeyEmailAccountParam("id"), h.PauseWarmup) + emails.POST("/:id/warmup/resume", m.RequireAccess(models.PermManageEmails, models.APIPermWriteEmails), middleware.RequireAPIKeyEmailAccountParam("id"), h.ResumeWarmup) + emails.POST("/:id/warmup/stop", m.RequireAccess(models.PermManageEmails, models.APIPermWriteEmails), middleware.RequireAPIKeyEmailAccountParam("id"), h.StopWarmup) + emails.GET("/:id/auth-check", m.RequireAccess(models.PermViewCampaigns, models.APIPermReadEmails), middleware.RequireAPIKeyEmailAccountParam("id"), h.GetEmailAuthCheck) + emails.POST("/verify", m.RequireAccess(models.PermViewCampaigns, models.APIPermReadEmails), h.VerifyEmail) + emails.GET("/:id/warmup/ban-status", m.RequireAccess(models.PermViewCampaigns, models.APIPermReadEmails), middleware.RequireAPIKeyEmailAccountParam("id"), h.GetWarmupBanStatus) + emails.POST("/:id/warmup/appeal", m.RequireAccess(models.PermManageEmails, models.APIPermWriteEmails), middleware.RequireAPIKeyEmailAccountParam("id"), h.SubmitWarmupAppeal) + emails.DELETE("/:id", m.RequireAccess(models.PermManageEmails, models.APIPermWriteEmails), middleware.RequireAPIKeyEmailAccountParam("id"), h.DeleteEmail) + emails.POST("/:id/send", m.RequireOrganization(), m.RequireAccess(models.PermSendCampaigns, models.APIPermSendCampaigns), middleware.RequireAPIKeyEmailAccountParam("id"), h.SendEmailFromAccount) } - deals := crmGroup.Group("/deals") + // Email onboarding is JWT-only — it writes user-encrypted refresh + // tokens via the SPA popup flow and shouldn't be triggerable by an + // API key with a long lifetime. + onboardingEmails := jwtOnly.Group("/emails/onboarding") + onboardingEmails.Use(m.RateLimitMiddleware(models.RateLimitWrite)) { - deals.GET("", m.RequireAccess(models.PermViewContacts, models.APIPermReadCRM), h.ListDeals) - deals.POST("", m.RequireAccess(models.PermManageContacts, models.APIPermWriteCRM), h.CreateDeal) - deals.POST("/search", m.RequireAccess(models.PermViewContacts, models.APIPermReadCRM), h.SearchDeals) - deals.POST("/summary", m.RequireAccess(models.PermViewContacts, models.APIPermReadCRM), h.DealsSummary) - deals.GET("/:id", m.RequireAccess(models.PermViewContacts, models.APIPermReadCRM), h.GetDeal) - deals.PATCH("/:id", m.RequireAccess(models.PermManageContacts, models.APIPermWriteCRM), h.UpdateDeal) - deals.DELETE("/:id", m.RequireAccess(models.PermManageContacts, models.APIPermWriteCRM), h.DeleteDeal) + onboardingEmails.POST("/oauth/start", h.StartEmailOAuth) + onboardingEmails.POST("/oauth/finish", h.FinishEmailOAuth) + onboardingEmails.POST("/smtp-imap", h.ConnectEmailSMTPIMAP) } - taskTypes := crmGroup.Group("/task-types") + // Integration OAuth handshake is JWT-only — it writes user-encrypted + // provider tokens via the SPA popup flow, same as mailbox onboarding. + integrationsOAuth := jwtOnly.Group("/integrations/oauth") + integrationsOAuth.Use(m.RequireOrganization(), m.RateLimitMiddleware(models.RateLimitWrite)) { - taskTypes.GET("", m.RequireAccess(models.PermViewContacts, models.APIPermReadCRM), h.ListTaskTypes) - taskTypes.POST("", m.RequireAccess(models.PermManageContacts, models.APIPermWriteCRM), h.CreateTaskType) - taskTypes.PATCH("/:id", m.RequireAccess(models.PermManageContacts, models.APIPermWriteCRM), h.UpdateTaskType) - taskTypes.DELETE("/:id", m.RequireAccess(models.PermManageContacts, models.APIPermWriteCRM), h.DeleteTaskType) + integrationsOAuth.POST("/start", h.StartIntegrationOAuth) + integrationsOAuth.POST("/finish", h.FinishIntegrationOAuth) + integrationsOAuth.POST("/reauth/:id", h.ReauthIntegration) } - crmTasks := crmGroup.Group("/tasks") + // Template preview/validation (no campaign id; can't be a static sibling + // of /campaigns/:id, so it lives one level up). Renders against a sample + // contact — read-level access, no side effects. + protected.POST("/campaign-template-preview", m.RequireOrganization(), m.RequireAccess(models.PermViewCampaigns, models.APIPermReadCampaigns), h.PreviewCampaignTemplate) + + campaigns := protected.Group("/campaigns") + campaigns.Use(m.RateLimitMiddleware(models.RateLimitWrite)) { - crmTasks.GET("", m.RequireAccess(models.PermViewContacts, models.APIPermReadCRM), h.ListCRMTasks) - crmTasks.POST("", m.RequireAccess(models.PermManageContacts, models.APIPermWriteCRM), h.CreateCRMTask) - crmTasks.POST("/search", m.RequireAccess(models.PermViewContacts, models.APIPermReadCRM), h.SearchCRMTasks) - crmTasks.POST("/summary", m.RequireAccess(models.PermViewContacts, models.APIPermReadCRM), h.TasksSummary) - crmTasks.GET("/:id", m.RequireAccess(models.PermViewContacts, models.APIPermReadCRM), h.GetCRMTask) - crmTasks.PATCH("/:id", m.RequireAccess(models.PermManageContacts, models.APIPermWriteCRM), h.UpdateCRMTask) - crmTasks.DELETE("/:id", m.RequireAccess(models.PermManageContacts, models.APIPermWriteCRM), h.DeleteCRMTask) + campaigns.GET("", m.RequireAccess(models.PermViewCampaigns, models.APIPermReadCampaigns), h.SearchCampaigns) + campaigns.POST("", m.RequireAccess(models.PermManageCampaigns, models.APIPermWriteCampaigns), h.CreateCampaign) + campaigns.GET("/:id", m.RequireAccess(models.PermViewCampaigns, models.APIPermReadCampaigns), h.GetCampaign) + campaigns.PATCH("/:id", m.RequireAccess(models.PermManageCampaigns, models.APIPermWriteCampaigns), h.UpdateCampaign) + campaigns.DELETE("/:id", m.RequireAccess(models.PermManageCampaigns, models.APIPermWriteCampaigns), h.DeleteCampaign) + + // Advanced campaign controls + campaigns.GET("/:id/advanced", m.RequireOrganization(), m.RequireAccess(models.PermViewCampaigns, models.APIPermReadCampaigns), h.GetCampaignAdvancedSettings) + campaigns.PATCH("/:id/advanced", m.RequireOrganization(), m.RequireAccess(models.PermManageSettings, models.APIPermWriteCampaigns), h.UpdateCampaignAdvancedSettings) + campaigns.GET("/:id/ab-variants", m.RequireOrganization(), m.RequireAccess(models.PermViewCampaigns, models.APIPermReadCampaigns), h.ListCampaignABVariants) + campaigns.POST("/:id/ab-variants", m.RequireOrganization(), m.RequireAccess(models.PermManageSettings, models.APIPermWriteCampaigns), h.CreateCampaignABVariant) + campaigns.PATCH("/:id/ab-variants/:variantId", m.RequireOrganization(), m.RequireAccess(models.PermManageSettings, models.APIPermWriteCampaigns), h.UpdateCampaignABVariant) + campaigns.DELETE("/:id/ab-variants/:variantId", m.RequireOrganization(), m.RequireAccess(models.PermManageSettings, models.APIPermWriteCampaigns), h.DeleteCampaignABVariant) + campaigns.GET("/:id/attachments", m.RequireOrganization(), m.RequireAccess(models.PermViewCampaigns, models.APIPermReadCampaigns), h.ListCampaignAttachments) + campaigns.POST("/:id/attachments", m.RequireOrganization(), m.RequireAccess(models.PermManageCampaigns, models.APIPermWriteCampaigns), h.UploadCampaignAttachment) + campaigns.DELETE("/:id/attachments/:attachmentId", m.RequireOrganization(), m.RequireAccess(models.PermManageCampaigns, models.APIPermWriteCampaigns), h.DeleteCampaignAttachment) + campaigns.POST("/:id/preflight", m.RequireOrganization(), m.RequireAccess(models.PermSendCampaigns, models.APIPermSendCampaigns), h.RunCampaignPreflight) + campaigns.GET("/:id/ab-analysis", m.RequireOrganization(), m.RequireAccess(models.PermViewAnalytics, models.APIPermReadAnalytics), h.GetCampaignABAnalysis) + campaigns.POST("/:id/test-email", m.RequireOrganization(), m.RequireAccess(models.PermSendCampaigns, models.APIPermSendCampaigns), h.SendTestEmail) + + // Campaign start/stop + campaigns.POST("/:id/start", m.RequireOrganization(), m.RequireAccess(models.PermSendCampaigns, models.APIPermSendCampaigns), h.StartCampaign) + campaigns.POST("/:id/stop", m.RequireOrganization(), m.RequireAccess(models.PermSendCampaigns, models.APIPermSendCampaigns), h.StopCampaign) + campaigns.GET("/:id/logs", m.RequireAccess(models.PermViewCampaigns, models.APIPermReadCampaigns), h.GetCampaignLogs) + + // Explicit sender pool (rotation/weighting). + campaigns.GET("/:id/senders", m.RequireOrganization(), m.RequireAccess(models.PermViewCampaigns, models.APIPermReadCampaigns), h.ListCampaignSenders) + campaigns.PUT("/:id/senders", m.RequireOrganization(), m.RequireAccess(models.PermManageCampaigns, models.APIPermWriteCampaigns), h.ReplaceCampaignSenders) + + // Campaign-scoped tracking-domain verification. + campaigns.POST("/:id/tracking-domain/verify", m.RequireOrganization(), m.RequireAccess(models.PermManageCampaigns, models.APIPermWriteCampaigns), h.VerifyCampaignTrackingDomain) + + sequences := campaigns.Group("/:id/steps") + { + sequences.GET("", m.RequireAccess(models.PermViewCampaigns, models.APIPermReadCampaigns), h.GetSequences) + sequences.POST("", m.RequireAccess(models.PermManageCampaigns, models.APIPermWriteCampaigns), h.CreateSequence) + sequences.PATCH("/:sid", m.RequireAccess(models.PermManageCampaigns, models.APIPermWriteCampaigns), h.UpdateSequence) + sequences.DELETE("/:sid", m.RequireAccess(models.PermManageCampaigns, models.APIPermWriteCampaigns), h.DeleteSequence) + } } + + generation := protected.Group("/generation") + generation.Use(m.RateLimitMiddleware(models.RateLimitWrite)) + { + generation.POST("/write", m.RequireOrganization(), m.RequireAccess(models.PermManageCampaigns, models.APIPermWriteCampaigns), h.GenerateWriting) + } + + contacts := protected.Group("/contacts") + contacts.Use(m.RateLimitMiddleware(models.RateLimitWrite)) + { + contacts.POST("/search", m.RequireAccess(models.PermViewContacts, models.APIPermReadContacts), h.SearchContacts) + contacts.POST("", m.RequireAccess(models.PermManageContacts, models.APIPermWriteContacts), h.AddContacts) + contacts.DELETE("", m.RequireAccess(models.PermManageContacts, models.APIPermBulkContacts), h.DeleteContactBulk) + contacts.PATCH("", m.RequireAccess(models.PermManageContacts, models.APIPermBulkContacts), h.UpdateContactBulk) + // Import + export power-tools. Read-only export gates on + // ReadContacts; the import endpoints write and so use the + // stricter Write/Bulk scopes that the rest of the contact + // write paths already use. + contacts.POST("/export", m.RequireAccess(models.PermViewContacts, models.APIPermReadContacts), h.ExportContacts) + contacts.POST("/import/preview", m.RequireAccess(models.PermManageContacts, models.APIPermWriteContacts), h.ImportPreviewContacts) + contacts.POST("/import/commit", m.RequireAccess(models.PermManageContacts, models.APIPermBulkContacts), h.ImportCommitContacts) + contacts.PATCH("/:id", m.RequireAccess(models.PermManageContacts, models.APIPermWriteContacts), h.UpdateContact) + contacts.DELETE("/:id", m.RequireAccess(models.PermManageContacts, models.APIPermWriteContacts), h.DeleteContact) + + // Resolve a sender address to a contact (unibox CRM panel). + // Registered before /:id so the fixed path wins over the catch-all. + contacts.GET("/lookup", m.RequireAccess(models.PermViewContacts, models.APIPermReadContacts), h.LookupContactByEmail) + + // Contact 360 view: hydrated detail, every email sent to + // the contact, and the merged activity timeline. + contacts.GET("/:id", m.RequireAccess(models.PermViewContacts, models.APIPermReadContacts), h.GetContact) + contacts.GET("/:id/emails", m.RequireAccess(models.PermViewContacts, models.APIPermReadContacts), h.ListContactEmails) + contacts.GET("/:id/timeline", m.RequireAccess(models.PermViewContacts, models.APIPermReadContacts), h.ListContactTimeline) + + // CRM: Notes & Activities (under contacts) + contacts.GET("/:id/notes", m.RequireAccess(models.PermViewContacts, models.APIPermReadContacts), h.ListContactNotes) + contacts.POST("/:id/notes", m.RequireAccess(models.PermManageContacts, models.APIPermWriteContacts), h.CreateContactNote) + contacts.PATCH("/:id/notes/:noteId", m.RequireAccess(models.PermManageContacts, models.APIPermWriteContacts), h.UpdateContactNote) + contacts.DELETE("/:id/notes/:noteId", m.RequireAccess(models.PermManageContacts, models.APIPermWriteContacts), h.DeleteContactNote) + contacts.GET("/:id/activities", m.RequireAccess(models.PermViewContacts, models.APIPermReadContacts), h.ListContactActivities) + contacts.GET("/:id/deals", m.RequireAccess(models.PermViewContacts, models.APIPermReadCRM), h.GetDealsByContact) + } + + // Group endpoints map to the resources they organize: campaign + // folders, email-account tags, and contact categories. + grouph.New(protected, h.FolderService, h.AuditService, "folders", m.RequireAccess(models.PermManageCampaigns, models.APIPermWriteCampaigns)) + grouph.New(protected, h.TagService, h.AuditService, "tags", m.RequireAccess(models.PermManageEmails, models.APIPermWriteEmails)) + grouph.New(protected, h.CategoryService, h.AuditService, "categories", m.RequireAccess(models.PermManageContacts, models.APIPermWriteContacts)) + + unibox := protected.Group("/unibox") + unibox.Use(m.RateLimitMiddleware(models.RateLimitRead)) + { + unibox.GET("", m.RequireAccess(models.PermAccessUnibox, models.APIPermReadUnibox), h.GetUniboxIncoming) + unibox.GET("/count", m.RequireAccess(models.PermAccessUnibox, models.APIPermReadUnibox), h.GetUnseenCount) + unibox.GET("/overview", m.RequireAccess(models.PermAccessUnibox, models.APIPermReadUnibox), h.GetUniboxOverview) + unibox.GET("/thread", m.RequireAccess(models.PermAccessUnibox, models.APIPermReadUnibox), h.GetUniboxThread) + + // Conversation labels — read the set on a thread, or replace + // it wholesale (idempotent PUT). Registered before /:id so the + // fixed path wins over the catch-all. + unibox.GET("/thread/labels", m.RequireAccess(models.PermAccessUnibox, models.APIPermReadUnibox), h.GetUniboxThreadLabels) + unibox.PUT("/thread/labels", m.RequireAccess(models.PermAccessUnibox, models.APIPermWriteUnibox), h.SetUniboxThreadLabels) + + unibox.PATCH("/seen", m.RequireAccess(models.PermAccessUnibox, models.APIPermWriteUnibox), h.UniboxMarkSeen) + unibox.POST("/reply", m.RequireOrganization(), m.RequireAccess(models.PermAccessUnibox, models.APIPermWriteUnibox), h.UniboxReply) + + // Snoozes — POST/DELETE on a thread, GET lists active ones. + unibox.GET("/snoozes", m.RequireAccess(models.PermAccessUnibox, models.APIPermReadUnibox), h.ListUniboxSnoozes) + unibox.POST("/snooze", m.RequireAccess(models.PermAccessUnibox, models.APIPermWriteUnibox), h.CreateUniboxSnooze) + unibox.DELETE("/snooze", m.RequireAccess(models.PermAccessUnibox, models.APIPermWriteUnibox), h.DeleteUniboxSnooze) + + // Scheduled-sends review + cancel. DELETE is DB-only — + // we don't pay Cloud Tasks to delete the queued task; the + // handler short-circuits on cancelled status when it fires. + unibox.GET("/scheduled", m.RequireAccess(models.PermAccessUnibox, models.APIPermReadUnibox), h.ListUniboxScheduled) + unibox.DELETE("/scheduled/:task_id", m.RequireAccess(models.PermAccessUnibox, models.APIPermWriteUnibox), h.CancelUniboxScheduled) + + // Keep /:id last — gin treats it as a catch-all so any + // fixed-name routes (above) must register first. + unibox.GET("/:id", m.RequireAccess(models.PermAccessUnibox, models.APIPermReadUnibox), h.GetUniboxEmail) + } + + // API key management. JWT users need PermManageAPIKeys; API keys + // need the APIPermAPIKeys self-service bit. This lets an integration + // rotate its own keys without going through the dashboard. + apiKeys := protected.Group("/api-keys") + apiKeys.Use(m.RequireOrganization(), m.RequireAccess(models.PermManageAPIKeys, models.APIPermAPIKeys)) + apiKeys.Use(m.RateLimitMiddleware(models.RateLimitWrite)) + { + apiKeys.GET("", h.ListAPIKeys) + apiKeys.POST("", h.CreateAPIKey) + apiKeys.GET("/permissions", h.ListAPIPermissions) + apiKeys.GET("/usage/summary", h.GetAPIKeyUsageSummary) + apiKeys.GET("/usage/analytics", h.GetAPIKeyAnalytics) + apiKeys.GET("/:id", h.GetAPIKey) + apiKeys.PATCH("/:id", h.UpdateAPIKey) + apiKeys.DELETE("/:id", h.RevokeAPIKey) + apiKeys.GET("/:id/analytics", h.GetAPIKeyAnalytics) + apiKeys.GET("/:id/logs", h.ListAPIKeyUsageLogs) + } + + // Analytics endpoints + analytics := protected.Group("/analytics") + analytics.Use(m.RateLimitMiddleware(models.RateLimitAnalytics), m.RequireAccess(models.PermViewAnalytics, models.APIPermReadAnalytics)) + { + analytics.GET("/dashboard", h.GetDashboardAnalytics) + analytics.GET("/deliverability", m.RequireOrganization(), h.GetDeliverabilityDashboard) + analytics.GET("/warmup", h.GetWarmupAnalytics) + analytics.GET("/campaigns/compare", h.CompareCampaigns) + analytics.GET("/campaigns/:id", h.GetCampaignAnalytics) + analytics.GET("/campaigns/:id/daily", h.GetCampaignDailyStats) + analytics.GET("/campaigns/:id/hourly", h.GetCampaignHourlyStats) + analytics.GET("/accounts", h.GetAllAccountStatuses) + analytics.GET("/accounts/:id", h.GetAccountStatus) + analytics.GET("/usage", h.GetUsageOverview) + } + + // Audit logs + auditLogs := protected.Group("/audit-logs") + auditLogs.Use(m.RateLimitMiddleware(models.RateLimitRead), m.RequireAccess(models.PermViewAnalytics, models.APIPermReadAuditLogs)) + { + auditLogs.GET("", h.GetAuditLogs) + } + + // Realtime websocket bootstrap is JWT-only — the websocket itself + // has its own session-based auth. + realtime := jwtOnly.Group("/realtime") + { + realtime.GET("/info", h.GetRealtimeInfo) + } + + // Advanced outreach controls (org-scoped) + outreach := protected.Group("/outreach") + outreach.Use(m.RequireOrganization(), m.RequireAccess(models.PermManageSettings, models.APIPermWriteCampaigns)) + { + outreach.GET("/settings", h.GetOutreachSettings) + outreach.PATCH("/settings", h.UpdateOutreachSettings) + } + + // Deliverability event ingestion (org-scoped). API-key callable so + // downstream pipelines (e.g. SES bounce processors) can post events + // without a human in the loop. + deliverability := protected.Group("/deliverability") + deliverability.Use(m.RequireOrganization(), m.RequireAccess(models.PermSendCampaigns, models.APIPermWriteCampaigns)) + { + deliverability.POST("/events", h.IngestDeliverabilityEvent) + } + + // Task dead letter operations (org-scoped). Requires SendCampaigns + // because a replay actually re-dispatches mail. + taskOps := protected.Group("/tasks") + taskOps.Use(m.RequireOrganization(), m.RequireAccess(models.PermSendCampaigns, models.APIPermSendCampaigns)) + { + taskOps.GET("/dlq", h.ListTaskDeadLetters) + taskOps.POST("/dlq/:id/replay", h.ReplayTaskDeadLetter) + } + + // Customer-facing webhooks (org-scoped). + webhooks := protected.Group("/webhooks") + webhooks.Use(m.RequireOrganization(), m.RequireAccess(models.PermManageSettings, models.APIPermWebhooks), m.RateLimitMiddleware(models.RateLimitWrite)) + { + webhooks.GET("", h.ListWebhookEndpoints) + webhooks.POST("", h.CreateWebhookEndpoint) + webhooks.PATCH("/:id", h.UpdateWebhookEndpoint) + webhooks.DELETE("/:id", h.DeleteWebhookEndpoint) + webhooks.POST("/:id/rotate-secret", h.RotateWebhookSecret) + webhooks.GET("/:id/deliveries", h.ListWebhookDeliveries) + } + + // Third-party integrations (org-scoped). Reads are reachable by both + // settings managers AND operational integration users (PermUseIntegrations) + // so contextual integration actions show up everywhere they belong; + // connecting + configuring stays gated on PermManageSettings. Pushing + // records on demand is an operational action (PermUseIntegrations). + integrations := protected.Group("/integrations") + integrations.Use(m.RequireOrganization(), m.RateLimitMiddleware(models.RateLimitWrite)) + { + read := m.RequireAnyAccess(models.APIPermIntegrations, models.PermManageSettings, models.PermUseIntegrations) + write := m.RequireAccess(models.PermManageSettings, models.APIPermIntegrations) + operate := m.RequireAccess(models.PermUseIntegrations, models.APIPermIntegrations) + + integrations.GET("/catalog", read, h.ListIntegrationCatalog) + integrations.GET("/connections", read, h.ListIntegrationConnections) + integrations.POST("/connections", write, h.ConnectIntegration) + integrations.GET("/connections/:id", read, h.GetIntegrationConnection) + integrations.PATCH("/connections/:id/config", write, h.UpdateConnectionConfig) + integrations.DELETE("/connections/:id", write, h.DisconnectIntegration) + integrations.GET("/connections/:id/events", read, h.ListConnectionEventSubscriptions) + integrations.POST("/connections/:id/events", write, h.CreateConnectionEventSubscription) + integrations.DELETE("/connections/:id/events/:eventId", write, h.DeleteConnectionEventSubscription) + integrations.GET("/connections/:id/field-mappings", read, h.ListConnectionFieldMappings) + integrations.PUT("/connections/:id/field-mappings", write, h.ReplaceConnectionFieldMappings) + integrations.GET("/connections/:id/runs", read, h.ListConnectionSyncRuns) + integrations.GET("/connections/:id/webhook-secret", write, h.GetConnectionWebhookSecret) + integrations.POST("/connections/:id/test", write, h.TestConnection) + integrations.POST("/connections/:id/push", operate, h.PushContactsToIntegration) + integrations.GET("/bookings", read, h.ListMeetingBookings) + } + + // Meetings (org-scoped). Booked calls from connected scheduling + // providers (Calendly / Cal.com), surfaced as a first-class CRM list. + // Read-only and reachable by anyone who can view contacts. + meetings := protected.Group("/meetings") + meetings.Use(m.RequireOrganization(), m.RateLimitMiddleware(models.RateLimitWrite)) + { + meetingsRead := m.RequireAccess(models.PermViewContacts, models.APIPermReadContacts) + meetingsWrite := m.RequireAccess(models.PermManageContacts, models.APIPermWriteContacts) + meetings.GET("", meetingsRead, h.SearchMeetings) + meetings.GET("/summary", meetingsRead, h.MeetingsSummary) + meetings.POST("", meetingsWrite, h.CreateMeeting) + meetings.DELETE("/:id", meetingsWrite, h.DeleteMeeting) + } + + // Automations (org-scoped). The visual flow builder: a trigger event + + // action steps across integrations. Reads reachable by operational + // integration users; creating/editing is a settings action. + automations := protected.Group("/automations") + automations.Use(m.RequireOrganization(), m.RateLimitMiddleware(models.RateLimitWrite)) + { + aread := m.RequireAnyAccess(models.APIPermIntegrations, models.PermManageSettings, models.PermUseIntegrations) + // Writing automations needs the integration permission (same family as + // reads) OR settings-manager; previously it required manage-settings only, + // which let integration-permitted members open the builder but 403 on save. + awrite := m.RequireAnyAccess(models.APIPermIntegrations, models.PermManageSettings, models.PermUseIntegrations) + automations.GET("", aread, h.ListAutomations) + automations.POST("", awrite, h.CreateAutomation) + automations.GET("/:id", aread, h.GetAutomation) + automations.PATCH("/:id", awrite, h.UpdateAutomation) + automations.DELETE("/:id", awrite, h.DeleteAutomation) + automations.POST("/:id/test", aread, h.TestAutomation) + automations.GET("/:id/runs", aread, h.ListAutomationRuns) + } + + // OAuth 2.1 authorization server. Registering/editing apps is a + // developer-credentials action (the manage-api-keys family); the + // authorize + authorized-apps flows are session-only (a human consents + // in their browser, so they never accept a long-lived API key). + oauthApps := protected.Group("/oauth/applications") + oauthApps.Use(m.RequireOrganization(), m.RequireAccess(models.PermManageAPIKeys, models.APIPermAPIKeys), m.RateLimitMiddleware(models.RateLimitWrite)) + { + oauthApps.GET("", h.ListOAuthApplications) + oauthApps.POST("", h.CreateOAuthApplication) + oauthApps.GET("/:id", h.GetOAuthApplication) + oauthApps.PATCH("/:id", h.UpdateOAuthApplication) + oauthApps.DELETE("/:id", h.DeleteOAuthApplication) + oauthApps.POST("/:id/rotate-secret", h.RotateOAuthApplicationSecret) + } + + // Logo upload for the app-registration UI. A separate path (not under + // /applications/:id) so it doesn't collide with the :id param route and + // can be called during creation, before an app id exists. + oauthLogo := protected.Group("/oauth/application-logo") + oauthLogo.Use(m.RequireOrganization(), m.RequireAccess(models.PermManageAPIKeys, models.APIPermAPIKeys), m.RateLimitMiddleware(models.RateLimitWrite)) + oauthLogo.POST("", h.UploadOAuthAppLogo) + + oauthFlow := jwtOnly.Group("/oauth") + oauthFlow.Use(m.RequireOrganization(), m.RateLimitMiddleware(models.RateLimitWrite)) + { + oauthFlow.GET("/authorize/details", h.OAuthAuthorizeDetails) + oauthFlow.POST("/authorize", h.OAuthAuthorize) + oauthFlow.GET("/authorized-apps", h.ListAuthorizedApps) + oauthFlow.DELETE("/authorized-apps/:id", h.RevokeAuthorizedApp) + } + + // On-demand Google Sheets -> leads sync (org-scoped). A saved "sync + // source" the user re-runs with "Sync now"; new rows create contacts and + // existing rows (matched by email) update. Gated under the contacts + // write permissions because it ultimately upserts contacts. The Google + // account itself is connected via the existing /integrations/oauth flow + // with provider "google_sheets". + leadSync := protected.Group("/lead-sync") + leadSync.Use(m.RequireOrganization(), m.RequireAccess(models.PermManageContacts, models.APIPermWriteContacts), m.RateLimitMiddleware(models.RateLimitWrite)) + { + leadSync.GET("/google/connection", h.GetLeadSyncGoogleConnection) + leadSync.POST("/google/spreadsheet", h.GetLeadSyncSpreadsheet) + leadSync.POST("/google/preview", h.PreviewLeadSync) + + leadSync.GET("/sources", h.ListLeadSyncSources) + leadSync.POST("/sources", h.CreateLeadSyncSource) + leadSync.GET("/sources/:id", h.GetLeadSyncSource) + leadSync.PATCH("/sources/:id", h.UpdateLeadSyncSource) + leadSync.DELETE("/sources/:id", h.DeleteLeadSyncSource) + leadSync.POST("/sources/:id/sync", h.SyncLeadSyncSourceNow) + } + + // Warmup routing rules (org-scoped). Lets customers define + // preferences for premium-pool partner selection — e.g. send + // to Gmail recipients only from Google-classified senders. + warmupRouting := protected.Group("/warmup/routing") + warmupRouting.Use(m.RequireOrganization(), m.RequireAccess(models.PermManageSettings, models.APIPermWarmupRouting), m.RateLimitMiddleware(models.RateLimitWrite)) + { + warmupRouting.GET("", h.ListWarmupRoutingRules) + warmupRouting.POST("", h.CreateWarmupRoutingRule) + warmupRouting.PATCH("/:id", h.UpdateWarmupRoutingRule) + warmupRouting.DELETE("/:id", h.DeleteWarmupRoutingRule) + } + + // Reply templates (org-scoped) + templates := protected.Group("/templates") + templates.Use(m.RequireOrganization(), m.RateLimitMiddleware(models.RateLimitWrite)) + { + templates.GET("", m.RequireAccess(models.PermViewCampaigns, models.APIPermReadTemplates), h.ListTemplates) + templates.POST("", m.RequireAccess(models.PermManageCampaigns, models.APIPermWriteTemplates), h.CreateTemplate) + templates.PATCH("/reorder", m.RequireAccess(models.PermManageCampaigns, models.APIPermWriteTemplates), h.ReorderTemplates) + templates.GET("/:id", m.RequireAccess(models.PermViewCampaigns, models.APIPermReadTemplates), h.GetTemplate) + templates.PATCH("/:id", m.RequireAccess(models.PermManageCampaigns, models.APIPermWriteTemplates), h.UpdateTemplate) + templates.DELETE("/:id", m.RequireAccess(models.PermManageCampaigns, models.APIPermWriteTemplates), h.DeleteTemplate) + templates.POST("/:id/duplicate", m.RequireAccess(models.PermManageCampaigns, models.APIPermWriteTemplates), h.DuplicateTemplate) + templates.POST("/:id/render", m.RequireAccess(models.PermViewCampaigns, models.APIPermReadTemplates), h.RenderTemplate) + templates.POST("/score", m.RequireAccess(models.PermViewCampaigns, models.APIPermReadTemplates), h.ScoreTemplateContent) + } + + // CRM routes (require org) + crmGroup := protected.Group("/crm") + crmGroup.Use(m.RequireOrganization(), m.RateLimitMiddleware(models.RateLimitWrite)) + { + pipelines := crmGroup.Group("/pipelines") + { + pipelines.GET("", m.RequireAccess(models.PermViewContacts, models.APIPermReadCRM), h.ListPipelines) + pipelines.POST("", m.RequireAccess(models.PermManageContacts, models.APIPermWriteCRM), h.CreatePipeline) + pipelines.GET("/:id", m.RequireAccess(models.PermViewContacts, models.APIPermReadCRM), h.GetPipeline) + pipelines.PATCH("/:id", m.RequireAccess(models.PermManageContacts, models.APIPermWriteCRM), h.UpdatePipeline) + pipelines.DELETE("/:id", m.RequireAccess(models.PermManageContacts, models.APIPermWriteCRM), h.DeletePipeline) + pipelines.POST("/:id/stages", m.RequireAccess(models.PermManageContacts, models.APIPermWriteCRM), h.CreateStage) + pipelines.PATCH("/:id/stages/:stageId", m.RequireAccess(models.PermManageContacts, models.APIPermWriteCRM), h.UpdateStage) + pipelines.DELETE("/:id/stages/:stageId", m.RequireAccess(models.PermManageContacts, models.APIPermWriteCRM), h.DeleteStage) + } + + deals := crmGroup.Group("/deals") + { + deals.GET("", m.RequireAccess(models.PermViewContacts, models.APIPermReadCRM), h.ListDeals) + deals.POST("", m.RequireAccess(models.PermManageContacts, models.APIPermWriteCRM), h.CreateDeal) + deals.POST("/search", m.RequireAccess(models.PermViewContacts, models.APIPermReadCRM), h.SearchDeals) + deals.POST("/summary", m.RequireAccess(models.PermViewContacts, models.APIPermReadCRM), h.DealsSummary) + deals.GET("/:id", m.RequireAccess(models.PermViewContacts, models.APIPermReadCRM), h.GetDeal) + deals.PATCH("/:id", m.RequireAccess(models.PermManageContacts, models.APIPermWriteCRM), h.UpdateDeal) + deals.DELETE("/:id", m.RequireAccess(models.PermManageContacts, models.APIPermWriteCRM), h.DeleteDeal) + } + + taskTypes := crmGroup.Group("/task-types") + { + taskTypes.GET("", m.RequireAccess(models.PermViewContacts, models.APIPermReadCRM), h.ListTaskTypes) + taskTypes.POST("", m.RequireAccess(models.PermManageContacts, models.APIPermWriteCRM), h.CreateTaskType) + taskTypes.PATCH("/:id", m.RequireAccess(models.PermManageContacts, models.APIPermWriteCRM), h.UpdateTaskType) + taskTypes.DELETE("/:id", m.RequireAccess(models.PermManageContacts, models.APIPermWriteCRM), h.DeleteTaskType) + } + + crmTasks := crmGroup.Group("/tasks") + { + crmTasks.GET("", m.RequireAccess(models.PermViewContacts, models.APIPermReadCRM), h.ListCRMTasks) + crmTasks.POST("", m.RequireAccess(models.PermManageContacts, models.APIPermWriteCRM), h.CreateCRMTask) + crmTasks.POST("/search", m.RequireAccess(models.PermViewContacts, models.APIPermReadCRM), h.SearchCRMTasks) + crmTasks.POST("/summary", m.RequireAccess(models.PermViewContacts, models.APIPermReadCRM), h.TasksSummary) + crmTasks.GET("/:id", m.RequireAccess(models.PermViewContacts, models.APIPermReadCRM), h.GetCRMTask) + crmTasks.PATCH("/:id", m.RequireAccess(models.PermManageContacts, models.APIPermWriteCRM), h.UpdateCRMTask) + crmTasks.DELETE("/:id", m.RequireAccess(models.PermManageContacts, models.APIPermWriteCRM), h.DeleteCRMTask) + } + } + + // Teams — group existing org members into named teams (for CRM + // ownership / routing). Built from members; managed by team managers. + teamsGroup := protected.Group("/teams") + teamsGroup.Use(m.RequireOrganization(), m.RateLimitMiddleware(models.RateLimitWrite)) + { + teamsGroup.GET("", m.RequireAccess(models.PermViewContacts, models.APIPermReadCRM), h.ListTeams) + teamsGroup.POST("", m.RequireAccess(models.PermManageTeam, models.APIPermWriteCRM), h.CreateTeam) + teamsGroup.GET("/:id", m.RequireAccess(models.PermViewContacts, models.APIPermReadCRM), h.GetTeam) + teamsGroup.PATCH("/:id", m.RequireAccess(models.PermManageTeam, models.APIPermWriteCRM), h.UpdateTeam) + teamsGroup.DELETE("/:id", m.RequireAccess(models.PermManageTeam, models.APIPermWriteCRM), h.DeleteTeam) + teamsGroup.POST("/:id/members", m.RequireAccess(models.PermManageTeam, models.APIPermWriteCRM), h.AddTeamMember) + teamsGroup.DELETE("/:id/members/:userId", m.RequireAccess(models.PermManageTeam, models.APIPermWriteCRM), h.RemoveTeamMember) + } + + // Plans and timezones are essentially public reference data — auth + // gates them only to avoid being scraped. Cheap to expose to keys. + protected.GET("/plans", h.ListPlans) + protected.GET("/timezones", h.GetTimezones) } - // Teams — group existing org members into named teams (for CRM - // ownership / routing). Built from members; managed by team managers. - teamsGroup := protected.Group("/teams") - teamsGroup.Use(m.RequireOrganization(), m.RateLimitMiddleware(models.RateLimitWrite)) + // Sensitive routes below — JWT only. Organization governance, billing, + // websocket bootstrap, danger-zone destructions, and pending invitations + // all live here. None of these are reachable via an API key. { - teamsGroup.GET("", m.RequireAccess(models.PermViewContacts, models.APIPermReadCRM), h.ListTeams) - teamsGroup.POST("", m.RequireAccess(models.PermManageTeam, models.APIPermWriteCRM), h.CreateTeam) - teamsGroup.GET("/:id", m.RequireAccess(models.PermViewContacts, models.APIPermReadCRM), h.GetTeam) - teamsGroup.PATCH("/:id", m.RequireAccess(models.PermManageTeam, models.APIPermWriteCRM), h.UpdateTeam) - teamsGroup.DELETE("/:id", m.RequireAccess(models.PermManageTeam, models.APIPermWriteCRM), h.DeleteTeam) - teamsGroup.POST("/:id/members", m.RequireAccess(models.PermManageTeam, models.APIPermWriteCRM), h.AddTeamMember) - teamsGroup.DELETE("/:id/members/:userId", m.RequireAccess(models.PermManageTeam, models.APIPermWriteCRM), h.RemoveTeamMember) - } + org := jwtOnly.Group("/organization") + org.Use(m.RateLimitMiddleware(models.RateLimitWrite)) + { + org.POST("", h.CreateOrganization) + org.GET("", h.GetUserOrganizations) + org.POST("/switch/:id", h.SwitchOrganization) - // Plans and timezones are essentially public reference data — auth - // gates them only to avoid being scraped. Cheap to expose to keys. - protected.GET("/plans", h.ListPlans) - protected.GET("/timezones", h.GetTimezones) + org.GET("/current", h.GetCurrentOrganization) + org.PATCH("/current", m.RequireOrganization(), m.RequirePermission(models.PermManageSettings), h.UpdateOrganization) + org.GET("/current/limits", m.RequireOrganization(), h.GetOrganizationLimits) + + org.GET("/members", m.RequireOrganization(), h.GetMembers) + org.POST("/members/invite", m.RequireOrganization(), m.RequirePermission(models.PermManageTeam), h.InviteMember) + org.PATCH("/members/:id", m.RequireOrganization(), m.RequirePermission(models.PermManageTeam), h.UpdateMemberRole) + org.DELETE("/members/:id", m.RequireOrganization(), m.RequirePermission(models.PermManageTeam), h.RemoveMember) + + // Custom roles: named permission sets assignable to members. + org.GET("/roles", m.RequireOrganization(), h.ListOrganizationRoles) + org.POST("/roles", m.RequireOrganization(), m.RequirePermission(models.PermManageTeam), h.CreateOrganizationRole) + org.PATCH("/roles/:id", m.RequireOrganization(), m.RequirePermission(models.PermManageTeam), h.UpdateOrganizationRole) + org.DELETE("/roles/:id", m.RequireOrganization(), m.RequirePermission(models.PermManageTeam), h.DeleteOrganizationRole) + + org.GET("/invitations", m.RequireOrganization(), m.RequirePermission(models.PermManageTeam), h.GetPendingInvitations) + org.DELETE("/invitations/:id", m.RequireOrganization(), m.RequirePermission(models.PermManageTeam), h.CancelInvitation) + org.GET("/invitations/:id/link", m.RequireOrganization(), m.RequirePermission(models.PermManageTeam), h.GetInvitationLink) + + org.POST("/transfer-ownership", m.RequireOrganization(), m.RequirePermission(models.PermTransferOwnership), h.TransferOwnership) + + org.POST("/avatar", m.RequireOrganization(), h.UploadOrganizationAvatar) + org.DELETE("/avatar", m.RequireOrganization(), h.DeleteOrganizationAvatar) + + org.GET("/current/danger-zone", m.RequireOrganization(), h.GetOrganizationDangerZone) + org.POST("/current/danger-zone/delete", m.RequireOrganization(), h.ScheduleOrganizationDeletion) + org.DELETE("/current/danger-zone/delete", m.RequireOrganization(), h.CancelOrganizationDeletion) + + // Customer-facing limit-increase requests. The "current + // effective" value is computed server-side at submission + // time so the org/admin can see what the user was looking + // at when they asked. + org.POST("/:orgId/limit-requests", h.SubmitLimitIncreaseRequest) + org.GET("/:orgId/limit-requests", h.ListOrgLimitRequests) + } + + // Cancel a pending limit request by id (submitter-only). Sits + // outside the /organization group so the URL doesn't need + // double-encoding of the org id. + jwtOnly.DELETE("/limit-requests/:id", h.CancelLimitRequest) + + account := jwtOnly.Group("/me") + { + account.GET("/danger-zone", h.GetAccountDangerZone) + account.POST("/danger-zone/delete", h.ScheduleAccountDeletion) + account.DELETE("/danger-zone/delete", h.CancelAccountDeletion) + } + + jwtOnly.GET("/invitations", h.GetMyPendingInvitations) + jwtOnly.POST("/invitations/accept", h.AcceptInvitation) + + // Websocket bootstrap. The token returned here is single-session. + jwtOnly.POST("/getaway", h.GenerateWebsocket) + + subscriptions := jwtOnly.Group("/subscription") + subscriptions.Use(m.RateLimitMiddleware(models.RateLimitWrite)) + { + subscriptions.GET("", h.GetSubscription) + subscriptions.GET("/limits", h.GetSubscriptionLimits) + subscriptions.GET("/trial", h.GetTrialStatus) + subscriptions.GET("/features", h.GetFeatureStatus) + subscriptions.POST("/checkout", h.CreateCheckoutSession) + subscriptions.POST("/discount/validate", h.ValidateDiscountCode) + subscriptions.POST("/portal", h.CreateBillingPortalSession) + subscriptions.POST("/cancel", h.CancelSubscription) + + subscriptions.POST("/change-plan", m.RequireOrganization(), m.RequirePermission(models.PermManageBilling), h.ChangePlan) + subscriptions.GET("/preview-change", m.RequireOrganization(), m.RequirePermission(models.PermManageBilling), h.PreviewPlanChange) + + subscriptions.POST("/enterprise-inquiry", h.SubmitEnterpriseInquiry) + } + } } - // Sensitive routes below — JWT only. Organization governance, billing, - // websocket bootstrap, danger-zone destructions, and pending invitations - // all live here. None of these are reachable via an API key. - { - org := jwtOnly.Group("/organization") - org.Use(m.RateLimitMiddleware(models.RateLimitWrite)) - { - org.POST("", h.CreateOrganization) - org.GET("", h.GetUserOrganizations) - org.POST("/switch/:id", h.SwitchOrganization) - - org.GET("/current", h.GetCurrentOrganization) - org.PATCH("/current", m.RequireOrganization(), m.RequirePermission(models.PermManageSettings), h.UpdateOrganization) - org.GET("/current/limits", m.RequireOrganization(), h.GetOrganizationLimits) - - org.GET("/members", m.RequireOrganization(), h.GetMembers) - org.POST("/members/invite", m.RequireOrganization(), m.RequirePermission(models.PermManageTeam), h.InviteMember) - org.PATCH("/members/:id", m.RequireOrganization(), m.RequirePermission(models.PermManageTeam), h.UpdateMemberRole) - org.DELETE("/members/:id", m.RequireOrganization(), m.RequirePermission(models.PermManageTeam), h.RemoveMember) - - org.GET("/invitations", m.RequireOrganization(), m.RequirePermission(models.PermManageTeam), h.GetPendingInvitations) - org.DELETE("/invitations/:id", m.RequireOrganization(), m.RequirePermission(models.PermManageTeam), h.CancelInvitation) - - org.POST("/transfer-ownership", m.RequireOrganization(), m.RequirePermission(models.PermTransferOwnership), h.TransferOwnership) - - org.POST("/avatar", m.RequireOrganization(), h.UploadOrganizationAvatar) - org.DELETE("/avatar", m.RequireOrganization(), h.DeleteOrganizationAvatar) - - org.GET("/current/danger-zone", m.RequireOrganization(), h.GetOrganizationDangerZone) - org.POST("/current/danger-zone/delete", m.RequireOrganization(), h.ScheduleOrganizationDeletion) - org.DELETE("/current/danger-zone/delete", m.RequireOrganization(), h.CancelOrganizationDeletion) - - // Customer-facing limit-increase requests. The "current - // effective" value is computed server-side at submission - // time so the org/admin can see what the user was looking - // at when they asked. - org.POST("/:orgId/limit-requests", h.SubmitLimitIncreaseRequest) - org.GET("/:orgId/limit-requests", h.ListOrgLimitRequests) - } - - // Cancel a pending limit request by id (submitter-only). Sits - // outside the /organization group so the URL doesn't need - // double-encoding of the org id. - jwtOnly.DELETE("/limit-requests/:id", h.CancelLimitRequest) - - account := jwtOnly.Group("/me") - { - account.GET("/danger-zone", h.GetAccountDangerZone) - account.POST("/danger-zone/delete", h.ScheduleAccountDeletion) - account.DELETE("/danger-zone/delete", h.CancelAccountDeletion) - } - - jwtOnly.GET("/invitations", h.GetMyPendingInvitations) - jwtOnly.POST("/invitations/accept", h.AcceptInvitation) - - // Websocket bootstrap. The token returned here is single-session. - jwtOnly.POST("/getaway", h.GenerateWebsocket) - - subscriptions := jwtOnly.Group("/subscription") - subscriptions.Use(m.RateLimitMiddleware(models.RateLimitWrite)) - { - subscriptions.GET("", h.GetSubscription) - subscriptions.GET("/limits", h.GetSubscriptionLimits) - subscriptions.GET("/trial", h.GetTrialStatus) - subscriptions.GET("/features", h.GetFeatureStatus) - subscriptions.POST("/checkout", h.CreateCheckoutSession) - subscriptions.POST("/discount/validate", h.ValidateDiscountCode) - subscriptions.POST("/portal", h.CreateBillingPortalSession) - subscriptions.POST("/cancel", h.CancelSubscription) - - subscriptions.POST("/change-plan", m.RequireOrganization(), m.RequirePermission(models.PermManageBilling), h.ChangePlan) - subscriptions.GET("/preview-change", m.RequireOrganization(), m.RequirePermission(models.PermManageBilling), h.PreviewPlanChange) - - subscriptions.POST("/enterprise-inquiry", h.SubmitEnterpriseInquiry) - } - } + // Single versioned mount. No unversioned alias. + mountPublicAPI(v1) // Admin routes (requires admin permissions) adminRoutes := r.Group("/admin") diff --git a/internal/app/advanced/campaign_steps.go b/internal/app/advanced/campaign_steps.go new file mode 100644 index 00000000..fb78eacc --- /dev/null +++ b/internal/app/advanced/campaign_steps.go @@ -0,0 +1,89 @@ +package advanced + +import ( + "bytes" + "context" + "fmt" + "net/http" + "strings" + "time" + + "github.com/google/uuid" + + "github.com/warmbly/warmbly/internal/app/webhook" + "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/pkg/safehttp" +) + +// campaignStepHTTP is the SSRF-safe client for the campaign "HTTP request" step: +// it validates the resolved IP at dial time (no internal/metadata targets) on top +// of the URL-level check, so a templated URL can't reach a private address. +var campaignStepHTTP = safehttp.Client(15 * time.Second) + +// FireCampaignEvent publishes a developer-defined custom event to the realtime +// gateway from a campaign "fire event" step. The event name + each field value +// are templated against the contact; the fields become the event payload. +// Subscribers (an API key with REALTIME_SUBSCRIBE on the org websocket) receive +// it with no public URL. Best-effort — a publish hiccup never blocks sending. +func (s *service) FireCampaignEvent(ctx context.Context, orgID uuid.UUID, sourceID, name string, fields []models.ActionKV, contact *models.Contact) { + if s.realtime == nil || orgID == uuid.Nil || contact == nil { + return + } + evName := strings.TrimSpace(renderContactTemplate(name, contact)) + if evName == "" { + return + } + payload := make(map[string]string, len(fields)) + for _, f := range fields { + key := strings.TrimSpace(f.Key) + if key == "" { + continue + } + payload[key] = renderContactTemplate(f.Value, contact) + } + s.realtime.PublishCustomEvent(ctx, orgID, uuid.Nil, evName, payload, "campaign", sourceID) +} + +// RunCampaignHTTPRequest makes a configurable outbound call from a campaign +// "HTTP request" step. Method/URL/headers/body are templated against the contact +// and the URL is SSRF-validated. Best-effort: a non-2xx or transport error is +// returned for logging but never aborts the campaign. +func (s *service) RunCampaignHTTPRequest(ctx context.Context, orgID uuid.UUID, cfg *models.ActionConfig, contact *models.Contact) error { + _ = orgID + if cfg == nil || contact == nil { + return nil + } + method := strings.ToUpper(strings.TrimSpace(cfg.HTTPMethod)) + if method == "" { + method = http.MethodPost + } + rawURL := strings.TrimSpace(renderContactTemplate(cfg.HTTPURL, contact)) + if rawURL == "" { + return nil + } + if err := webhook.ValidateOutboundURL(rawURL); err != nil { + return fmt.Errorf("http url rejected: %w", err) + } + body := renderContactTemplate(cfg.HTTPBody, contact) + req, err := http.NewRequestWithContext(ctx, method, rawURL, bytes.NewBufferString(body)) + if err != nil { + return err + } + if body != "" && req.Header.Get("Content-Type") == "" { + req.Header.Set("Content-Type", "application/json") + } + for k, v := range cfg.HTTPHeaders { + if k = strings.TrimSpace(k); k != "" { + req.Header.Set(k, renderContactTemplate(v, contact)) + } + } + resp, err := campaignStepHTTP.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + return fmt.Errorf("http request returned %d", resp.StatusCode) + } + return nil +} diff --git a/internal/app/advanced/events.go b/internal/app/advanced/events.go index 3027eb54..616790c3 100644 --- a/internal/app/advanced/events.go +++ b/internal/app/advanced/events.go @@ -43,6 +43,22 @@ func (s *service) EmitCampaignEvent(ctx context.Context, orgID uuid.UUID, eventT s.emit(ctx, orgID, eventType, data) } +// ReplyRealtimePublisher pushes an org-scoped EMAIL_REPLIED pulse to the live +// dashboard. Satisfied by *pubsub.StreamingPublisher; primitive-typed local +// interface so this package stays decoupled from the pubsub event types. +type ReplyRealtimePublisher interface { + PublishEmailReplied(ctx context.Context, orgID, userID, campaignID, contactID, contactEmail, sequenceID string) + // PublishCustomEvent pushes a developer-defined "fire event" to the gateway so + // API-key websocket subscribers receive it (the campaign "fire event" step). + PublishCustomEvent(ctx context.Context, orgID, actorID uuid.UUID, name string, payload map[string]string, source, sourceID string) +} + +// WireRealtime attaches the realtime publisher after construction. No-op if +// never called: the emit site guards on nil. +func (s *service) WireRealtime(p ReplyRealtimePublisher) { + s.realtime = p +} + // Notifier raises a per-user in-app notification (gated by the user's prefs). // Satisfied by *notification.Service. Local interface to avoid an import cycle; // wired post-construction in the consumer (where reply/bounce/complaint run). @@ -55,6 +71,21 @@ func (s *service) WireNotifier(n Notifier) { s.notifier = n } +// AutomationRunner launches an automation graph by id, so an instant +// "run_automation" action node (reply/open/click branch) can fire the same flow +// the scheduler runs at a step boundary. Satisfied by *integration.Service; +// kept as a local interface to avoid an import cycle (integration imports +// advanced) and wired post-construction once the integration service exists. +type AutomationRunner interface { + RunAutomationByID(ctx context.Context, orgID, automationID uuid.UUID, data map[string]any) error +} + +// WireAutomationRunner attaches the automation runner after construction. No-op +// if never called: the run_automation instant case guards on a nil runner. +func (s *service) WireAutomationRunner(r AutomationRunner) { + s.automationRunner = r +} + // notify raises an in-app notification off the hot path. It detaches from the // request context (the ingest call may return first) and is best-effort. func (s *service) notify(userID uuid.UUID, orgID *uuid.UUID, category models.NotificationCategory, title, body, link string, meta map[string]any) { diff --git a/internal/app/advanced/labels.go b/internal/app/advanced/labels.go new file mode 100644 index 00000000..f94a2c8f --- /dev/null +++ b/internal/app/advanced/labels.go @@ -0,0 +1,37 @@ +package advanced + +import ( + "context" + + "github.com/google/uuid" +) + +// LabelThread additively applies the given category labels to a unibox +// conversation, on behalf of the thread's owning user. Backs the "label_email" +// automation action. Best-effort: empty input or a missing labeler is a no-op, +// and categories not owned by userID are silently ignored by the repository. +func (s *service) LabelThread(ctx context.Context, userID uuid.UUID, threadID string, categoryIDs []uuid.UUID) error { + if s.uniboxRepo == nil || threadID == "" || len(categoryIDs) == 0 { + return nil + } + return s.uniboxRepo.AddThreadLabels(ctx, userID, threadID, categoryIDs) +} + +// LabelLatestThreadForContact resolves the contact's most recent conversation in +// userID's unibox and labels it. Backs the "label_email" campaign step action, +// which knows the contact but not the thread id (off a reply branch the most +// recent thread IS the reply). Returns the labeled thread id, or "" when the +// contact has no conversation yet (a logged no-op for the caller). +func (s *service) LabelLatestThreadForContact(ctx context.Context, userID uuid.UUID, contactEmail string, categoryIDs []uuid.UUID) (string, error) { + if s.uniboxRepo == nil || contactEmail == "" || len(categoryIDs) == 0 { + return "", nil + } + threadID, err := s.uniboxRepo.LatestThreadIDForContact(ctx, userID, contactEmail) + if err != nil { + return "", err + } + if threadID == "" { + return "", nil + } + return threadID, s.uniboxRepo.AddThreadLabels(ctx, userID, threadID, categoryIDs) +} diff --git a/internal/app/advanced/reply_actions.go b/internal/app/advanced/reply_actions.go index cde59d0a..45f50c5a 100644 --- a/internal/app/advanced/reply_actions.go +++ b/internal/app/advanced/reply_actions.go @@ -265,28 +265,23 @@ func (s *service) executeInstantActionNode(ctx context.Context, campaign *models }); xerr != nil { s.logActionErr(campaign, contact, cfg.Type, eventKind, xerr) } + case "label_email": + // Label the conversation the contact just replied on. The most recent + // thread for the contact in the campaign owner's unibox is that reply. + if len(cfg.LabelIDs) == 0 { + return + } + owner, perr := uuid.Parse(campaign.UserID) + if perr != nil { + return + } + if _, xerr := s.LabelLatestThreadForContact(ctx, owner, contact.Email, cfg.LabelIDs); xerr != nil { + s.logActionErr(campaign, contact, cfg.Type, eventKind, xerr) + } case "unsubscribe": if xerr := s.Unsubscribe(ctx, campaign.ID, contact.ID); xerr != nil { s.logActionErr(campaign, contact, cfg.Type, eventKind, xerr) } - case "notify": - if campaign.OrganizationID == nil { - return - } - event := models.WebhookEventCampaignAction - if cfg.NotifyEvent != "" { - event = models.WebhookEventType(cfg.NotifyEvent) - } - data := map[string]any{ - "campaign_id": campaign.ID.String(), - "contact_id": contact.ID.String(), - "contact_email": contact.Email, - "trigger": eventKind, - } - for k, v := range cfg.NotifyData { - data[k] = v - } - s.EmitCampaignEvent(ctx, *campaign.OrganizationID, event, data) case "create_task": if campaign.OrganizationID == nil { return @@ -362,6 +357,50 @@ func (s *service) executeInstantActionNode(ctx context.Context, campaign *models if _, xerr := s.MoveContactDealStage(ctx, *campaign.OrganizationID, contact.ID, *cfg.DealPipelineID, *cfg.DealStageID); xerr != nil { s.logActionErr(campaign, contact, cfg.Type, eventKind, xerr) } + case "run_automation": + // Mirror tasks.executeActionNode's run_automation case so an automation + // placed directly on a reply/open/click branch fires NOW. Without this the + // walker would stamp the node sent (below) with the automation never run, + // and the scheduler's sentIDs guard would then skip it forever. + if s.automationRunner == nil || campaign.OrganizationID == nil || cfg.AutomationID == nil { + return + } + data := map[string]any{ + "campaign_id": campaign.ID.String(), + "campaign_name": campaign.Name, + "contact_id": contact.ID.String(), + "contact_email": contact.Email, + "first_name": contact.FirstName, + "last_name": contact.LastName, + "company": contact.Company, + "phone": contact.Phone, + "trigger": eventKind, + // Stable per-(campaign,contact,trigger) key so a duplicate instant + // delivery dedupes downstream (same contract as the scheduler path). + "idempotency_key": fmt.Sprintf("campaign:%s:%s:%s", campaign.ID, contact.ID, eventKind), + } + for _, kv := range cfg.AutomationValues { + key := strings.TrimSpace(kv.Key) + if key == "" { + continue + } + data[key] = renderContactTemplate(kv.Value, contact) + } + if xerr := s.automationRunner.RunAutomationByID(ctx, *campaign.OrganizationID, *cfg.AutomationID, data); xerr != nil { + s.logActionErr(campaign, contact, cfg.Type, eventKind, xerr) + } + case "fire_event": + if campaign.OrganizationID == nil { + return + } + s.FireCampaignEvent(ctx, *campaign.OrganizationID, campaign.ID.String(), cfg.EventName, cfg.EventFields, contact) + case "http_request": + if campaign.OrganizationID == nil { + return + } + if xerr := s.RunCampaignHTTPRequest(ctx, *campaign.OrganizationID, cfg, contact); xerr != nil { + s.logActionErr(campaign, contact, cfg.Type, eventKind, xerr) + } default: // "wait" / "end" are handled by the chain walker (they stop the walk); // unknown types are ignored. diff --git a/internal/app/advanced/service.go b/internal/app/advanced/service.go index afefa9f8..86aca0c3 100644 --- a/internal/app/advanced/service.go +++ b/internal/app/advanced/service.go @@ -73,17 +73,38 @@ type Service interface { // just because a deal hasn't been created yet. MoveContactDealStage(ctx context.Context, orgID, contactID, pipelineID, stageID uuid.UUID) (*models.Deal, *errx.Error) + // LabelThread additively applies unibox conversation labels (categories owned + // by userID) to a thread, for the "label_email" automation action. No-op on + // empty input; categories not owned by userID are silently ignored. + LabelThread(ctx context.Context, userID uuid.UUID, threadID string, categoryIDs []uuid.UUID) error + // LabelLatestThreadForContact finds the contact's most recent conversation in + // userID's unibox and labels it, for the "label_email" campaign step action + // (which knows the contact but not the thread id). Returns the labeled thread + // id, or "" when the contact has no conversation yet. + LabelLatestThreadForContact(ctx context.Context, userID uuid.UUID, contactEmail string, categoryIDs []uuid.UUID) (string, error) + // WireDispatcher attaches the event dispatcher that fans classified // replies + deliverability events out to customer webhooks and third-party // integration actions (Slack ping, CRM upsert). WireDispatcher(d EventDispatcher) // WireNotifier attaches the in-app notification gate (reply/bounce/complaint). WireNotifier(n Notifier) + // WireRealtime attaches the org-scoped EMAIL_REPLIED realtime pulse. + WireRealtime(p ReplyRealtimePublisher) + // WireAutomationRunner attaches the automation runner so instant + // "run_automation" action nodes (reply/open/click branches) can launch a flow. + WireAutomationRunner(r AutomationRunner) // EmitCampaignEvent dispatches a campaign event (e.g. from a sequence // "notify" action node) to customer webhooks and wired integrations. EmitCampaignEvent(ctx context.Context, orgID uuid.UUID, eventType models.WebhookEventType, data map[string]any) + // FireCampaignEvent publishes a developer-defined "fire event" to the realtime + // gateway from a campaign step (subscribers receive it over the API websocket). + FireCampaignEvent(ctx context.Context, orgID uuid.UUID, sourceID, name string, fields []models.ActionKV, contact *models.Contact) + // RunCampaignHTTPRequest runs a campaign "HTTP request" step (SSRF-guarded). + RunCampaignHTTPRequest(ctx context.Context, orgID uuid.UUID, cfg *models.ActionConfig, contact *models.Contact) error + // FireInstantActions runs the matched INSTANT branch's action chain for a // contact the moment an engagement signal lands for them, instead of waiting // for the next scheduled step boundary. eventKind is "reply", "open", or @@ -108,10 +129,13 @@ type service struct { contactRepo repository.ContactRepository campaignProgressRepo repository.CampaignProgressRepository crmRepo repository.CRMRepository + uniboxRepo repository.UniboxRepository tasksClient *gtasks.Client warmupService warmupapp.Service dispatcher EventDispatcher notifier Notifier + realtime ReplyRealtimePublisher + automationRunner AutomationRunner } func NewService( @@ -122,6 +146,7 @@ func NewService( contactRepo repository.ContactRepository, campaignProgressRepo repository.CampaignProgressRepository, crmRepo repository.CRMRepository, + uniboxRepo repository.UniboxRepository, tasksClient *gtasks.Client, warmupService warmupapp.Service, ) Service { @@ -133,6 +158,7 @@ func NewService( contactRepo: contactRepo, campaignProgressRepo: campaignProgressRepo, crmRepo: crmRepo, + uniboxRepo: uniboxRepo, tasksClient: tasksClient, warmupService: warmupService, } @@ -448,6 +474,11 @@ func pickVariantWeightedRandom(variants []models.CampaignABVariant) *models.Camp return &variants[len(variants)-1] } +// abControlWeight is the implicit weight of a step's original content (the +// control arm) in a step-scoped A/B split, matching the default variant weight +// so one variant yields an even 50/50 split with the original. +const abControlWeight = 100 + // pickVariantDeterministic does a weighted draw seeded by a stable string, so // the same seed always picks the same variant (used for per-step assignment). func pickVariantDeterministic(variants []models.CampaignABVariant, seed string) *models.CampaignABVariant { @@ -504,9 +535,15 @@ func (s *service) SelectVariant(ctx context.Context, organizationID, campaignID, var selected *models.CampaignABVariant if len(stepVariants) > 0 { - // Step-scoped: deterministic per (contact, step), so the same contact - // always gets the same variant for this step without an assignment row. - selected = pickVariantDeterministic(stepVariants, contactID.String()+":"+sequenceID.String()) + // Step-scoped: the step's own content is the control arm, so contacts are + // split across the original PLUS the active variants by weight, + // deterministically per (contact, step). The control arm carries the zero + // id; if it wins we send the step's original content. + pool := append([]models.CampaignABVariant{{Weight: abControlWeight}}, stepVariants...) + selected = pickVariantDeterministic(pool, contactID.String()+":"+sequenceID.String()) + if selected != nil && selected.ID == uuid.Nil { + return &models.VariantSelection{Subject: subject, BodyHTML: bodyHTML, BodyPlain: bodyPlain}, nil + } } else if len(campaignVariants) > 0 { // Campaign-level (legacy): keep the assignment-based selection so a // contact stays on one variant across the whole campaign. @@ -788,6 +825,12 @@ func (s *service) ProcessIncomingReply(ctx context.Context, emailAccountID uuid. if !replyclassify.IsAutomated(replyResult.Class) { _ = s.campaignProgressRepo.RecordEmailReplied(ctx, cID, ctID, sID) _ = s.repo.MarkVariantEvent(ctx, cID, ctID, string(models.DeliverabilityEventReply)) + + // Live org-wide pulse: the team sees the reply land on the + // campaign without a refresh. + if s.realtime != nil && account.OrganizationID != nil { + s.realtime.PublishEmailReplied(ctx, account.OrganizationID.String(), account.UserID, cID.String(), ctID.String(), sender, sID.String()) + } } // INSTANT reply trigger: if the contact's CURRENT step has a reply_* intent @@ -871,6 +914,13 @@ func (s *service) ProcessIncomingReply(ctx context.Context, emailAccountID uuid. "snippet": msg.Snippet, "action_taken": actionTaken, "trigger": "campaign_reply", + // thread_id lets a "label email" automation action tag the conversation + // this reply belongs to. _user_id is the mailbox owner (categories are per + // user); the leading underscore keeps it out of outbound customer webhook + // bodies (publicEventData strips _-prefixed keys) while staying available + // to native actions, which read the raw event data. + "thread_id": msg.ThreadID, + "_user_id": account.UserID, } if campaignID != nil { payload["campaign_id"] = campaignID.String() diff --git a/internal/app/analytics/service.go b/internal/app/analytics/service.go index fbfc0896..61d58656 100644 --- a/internal/app/analytics/service.go +++ b/internal/app/analytics/service.go @@ -414,7 +414,7 @@ func calculateTargetVolume(email *models.Email) int { // Dashboard Analytics implementations -func (s *analyticsService) GetDashboardAnalytics(ctx context.Context, userID uuid.UUID, period string) (*models.DashboardAnalytics, *errx.Error) { +func (s *analyticsService) GetDashboardAnalytics(ctx context.Context, orgID uuid.UUID, period string) (*models.DashboardAnalytics, *errx.Error) { // Calculate date range from period var from, to time.Time to = time.Now() @@ -432,31 +432,31 @@ func (s *analyticsService) GetDashboardAnalytics(ctx context.Context, userID uui } // Get overall stats - overallStats, xerr := s.analyticsRepo.GetDashboardOverallStats(ctx, userID, from, to) + overallStats, xerr := s.analyticsRepo.GetDashboardOverallStats(ctx, orgID, from, to) if xerr != nil { return nil, xerr } // Get recent activity - recentActivity, xerr := s.analyticsRepo.GetRecentActivity(ctx, userID, 20) + recentActivity, xerr := s.analyticsRepo.GetRecentActivity(ctx, orgID, 20) if xerr != nil { recentActivity = make([]models.RecentActivityItem, 0) } // Get top campaigns - topCampaigns, xerr := s.analyticsRepo.GetTopCampaigns(ctx, userID, from, to, 5, "emails_sent") + topCampaigns, xerr := s.analyticsRepo.GetTopCampaigns(ctx, orgID, from, to, 5, "emails_sent") if xerr != nil { topCampaigns = make([]models.TopCampaignStats, 0) } // Get account health summary - accountHealth, xerr := s.analyticsRepo.GetAccountHealthSummary(ctx, userID) + accountHealth, xerr := s.analyticsRepo.GetAccountHealthSummary(ctx, orgID) if xerr != nil { accountHealth = &models.AccountHealthSummary{} } // Get daily trend - dailyTrend, xerr := s.analyticsRepo.GetDashboardDailyTrend(ctx, userID, from, to) + dailyTrend, xerr := s.analyticsRepo.GetDashboardDailyTrend(ctx, orgID, from, to) if xerr != nil { dailyTrend = make([]models.DashboardDailyStats, 0) } diff --git a/internal/app/auth/model.go b/internal/app/auth/model.go index 211d414c..680c92ff 100644 --- a/internal/app/auth/model.go +++ b/internal/app/auth/model.go @@ -22,3 +22,8 @@ type ResetPasswordConfirm struct { Password string `json:"password"` Turnstile string `json:"turnstile"` } + +type ChangePassword struct { + CurrentPassword string `json:"current_password"` + NewPassword string `json:"new_password"` +} diff --git a/internal/app/auth/reset_password.go b/internal/app/auth/reset_password.go index ad74561f..d0650919 100644 --- a/internal/app/auth/reset_password.go +++ b/internal/app/auth/reset_password.go @@ -116,5 +116,65 @@ func (s *authService) ResetPasswordConfirm(ctx context.Context, data *ResetPassw return err } + // A forgotten-password reset means the account may be compromised: evict + // every existing session (no current device to keep — uuid.Nil matches + // none, so all are revoked) so a reset always fully cuts off prior access. + if s.tokenService != nil { + if err := s.tokenService.RevokeOtherSessions(ctx, sess.UserID, uuid.Nil); err != nil { + sentry.CaptureException(err) + // Non-fatal: the password is already reset. + } + } + + return nil +} + +// ChangePassword updates a logged-in user's password. It verifies the current +// password first (so a hijacked but unattended session can't silently change +// it), rejects OAuth-only accounts, and enforces the password policy. +func (s *authService) ChangePassword(ctx context.Context, userID, currentSessionID uuid.UUID, data *ChangePassword) *errx.Error { + hash, xerr := s.authRepository.GetPasswordHash(ctx, userID) + if xerr != nil { + return xerr + } + if hash == "" { + return errx.New(errx.BadRequest, "this account signs in without a password") + } + + ok, verr := argon2.Verify(data.CurrentPassword, hash) + if verr != nil { + sentry.CaptureException(verr) + return errx.InternalError() + } + if !ok { + return errx.ErrCredentials + } + + if !crypt.ValidatePassword(data.NewPassword) { + return errx.ErrPassword + } + if data.NewPassword == data.CurrentPassword { + return errx.New(errx.BadRequest, "the new password must be different") + } + + newHash, hashErr := argon2.Hash(data.NewPassword) + if hashErr != nil { + sentry.CaptureException(hashErr) + return errx.InternalError() + } + if err := s.authRepository.ResetPassword(ctx, userID, newHash); err != nil { + return err + } + + // Changing the password evicts every OTHER signed-in device (the whole + // point of changing it when a session may be compromised). The current + // device keeps its session so the user isn't logged out of the action + // they just performed. + if s.tokenService != nil && currentSessionID != uuid.Nil { + if err := s.tokenService.RevokeOtherSessions(ctx, userID, currentSessionID); err != nil { + sentry.CaptureException(err) + // Non-fatal: the password is already changed. + } + } return nil } diff --git a/internal/app/auth/service.go b/internal/app/auth/service.go index acec79bd..79b91820 100644 --- a/internal/app/auth/service.go +++ b/internal/app/auth/service.go @@ -36,6 +36,10 @@ type AuthService interface { ResetPasswordStart(ctx context.Context, data *ResetPasswordStart, ipaddr string) *errx.Error ResetPasswordConfirm(ctx context.Context, data *ResetPasswordConfirm, session, ipaddr string) *errx.Error + + // ChangePassword updates a logged-in user's password after verifying the + // current one. + ChangePassword(ctx context.Context, userID, currentSessionID uuid.UUID, data *ChangePassword) *errx.Error } type authService struct { diff --git a/internal/app/campaign/handlers.go b/internal/app/campaign/handlers.go index b9abb68b..e2301465 100644 --- a/internal/app/campaign/handlers.go +++ b/internal/app/campaign/handlers.go @@ -19,6 +19,7 @@ import ( "github.com/warmbly/warmbly/internal/scheduler" "github.com/warmbly/warmbly/internal/tasks" "github.com/warmbly/warmbly/internal/tasks/proto" + "github.com/warmbly/warmbly/internal/utils/paging" "github.com/warmbly/warmbly/internal/utils/validate" ) @@ -51,6 +52,7 @@ func (s *campaignService) Create(ctx context.Context, userID string, orgID *uuid EventType: pubsub.EventCampaignCreated, UserID: userID, }, + OrgID: modelOrgID(orgID), CampaignID: resp.ID.String(), Name: resp.Name, Status: resp.Status, @@ -60,8 +62,8 @@ func (s *campaignService) Create(ctx context.Context, userID string, orgID *uuid return resp, nil } -func (s *campaignService) Get(ctx context.Context, userID, id string) (*models.Campaign, *errx.Error) { - resp, err := s.campaignRepository.Get(ctx, userID, id) +func (s *campaignService) Get(ctx context.Context, orgID, id string) (*models.Campaign, *errx.Error) { + resp, err := s.campaignRepository.Get(ctx, orgID, id) if err != nil { if errors.Is(err, errx.ErrResourceNotFound) { return nil, errx.ErrNotFound @@ -73,8 +75,8 @@ func (s *campaignService) Get(ctx context.Context, userID, id string) (*models.C return resp, nil } -func (s *campaignService) Search(ctx context.Context, userID, query, cursor, folder, limit string) (*models.CampaignsResult, *errx.Error) { - cursorId, err := validate.Uuid(cursor) +func (s *campaignService) Search(ctx context.Context, orgID, query, cursor, folder, limit string) (*models.CampaignsResult, *errx.Error) { + cursorId, err := paging.DecodeCursor(cursor) if err != nil { return nil, err } @@ -87,7 +89,7 @@ func (s *campaignService) Search(ctx context.Context, userID, query, cursor, fol return nil, err } - resp, xerr := s.campaignRepository.Search(ctx, userID, query, cursorId, folderId, limitN) + resp, xerr := s.campaignRepository.Search(ctx, orgID, query, cursorId, folderId, limitN) if xerr != nil { return nil, errx.InternalError() } @@ -218,6 +220,7 @@ func (s *campaignService) StartCampaign(ctx context.Context, orgID uuid.UUID, ca EventType: pubsub.EventCampaignStarted, UserID: campaign.UserID, }, + OrgID: modelOrgID(campaign.OrganizationID), CampaignID: cID.String(), Name: campaign.Name, Status: "active", @@ -341,6 +344,7 @@ func (s *campaignService) StopCampaign(ctx context.Context, orgID uuid.UUID, cam EventType: pubsub.EventCampaignPaused, UserID: campaign.UserID, }, + OrgID: modelOrgID(campaign.OrganizationID), CampaignID: cID.String(), Name: campaign.Name, Status: "paused", @@ -465,3 +469,11 @@ func (s *campaignService) VerifyCampaignTrackingDomain(ctx context.Context, orgI } return status, nil } + +// modelOrgID renders an optional org UUID for org-scoped realtime events. +func modelOrgID(orgID *uuid.UUID) string { + if orgID == nil { + return "" + } + return orgID.String() +} diff --git a/internal/app/consumer/event_new_email.go b/internal/app/consumer/event_new_email.go index 0a103cc8..2601ab30 100644 --- a/internal/app/consumer/event_new_email.go +++ b/internal/app/consumer/event_new_email.go @@ -40,7 +40,7 @@ func (s *JobsService) HandleNewEmail(ctx context.Context, e *models.JobEventNewE return err } if s.StreamingPublisher != nil && e.Message != nil { - s.StreamingPublisher.PublishEmailReceived(ctx, emailInboxEvent(e.UserID, e.Message)) + s.StreamingPublisher.PublishEmailReceived(ctx, s.emailInboxEvent(ctx, e.UserID, e.Message)) } // Advanced reply-intent automation is best-effort and should not block inbox @@ -59,12 +59,19 @@ func (s *JobsService) publishEmailUpdated(ctx context.Context, userID uuid.UUID, if s.StreamingPublisher == nil || message == nil { return } - s.StreamingPublisher.PublishEmailUpdated(ctx, emailInboxEvent(userID, message)) + s.StreamingPublisher.PublishEmailUpdated(ctx, s.emailInboxEvent(ctx, userID, message)) } -func emailInboxEvent(userID uuid.UUID, message *models.EmailMessageStoreData) *pubsub.EmailInboxEvent { +// emailInboxEvent builds the realtime inbox payload. Org-scoped (best-effort) +// so every teammate's unibox updates live, not just the mailbox owner's. +func (s *JobsService) emailInboxEvent(ctx context.Context, userID uuid.UUID, message *models.EmailMessageStoreData) *pubsub.EmailInboxEvent { + var orgID string + if account, err := s.EmailRepository.GetByID(ctx, message.EmailID); err == nil && account != nil && account.OrganizationID != nil { + orgID = account.OrganizationID.String() + } return &pubsub.EmailInboxEvent{ BaseEvent: pubsub.BaseEvent{UserID: userID.String()}, + OrgID: orgID, EmailAccountID: message.EmailID.String(), MessageID: message.ID.String(), ThreadID: message.ThreadID, diff --git a/internal/app/consumer/event_remove_email.go b/internal/app/consumer/event_remove_email.go index 5cf44bc4..b5520455 100644 --- a/internal/app/consumer/event_remove_email.go +++ b/internal/app/consumer/event_remove_email.go @@ -3,6 +3,7 @@ package jobs import ( "context" + "github.com/warmbly/warmbly/internal/infrastructure/pubsub" "github.com/warmbly/warmbly/internal/models" ) @@ -27,5 +28,20 @@ func (s *JobsService) HandleRemoveEmail(ctx context.Context, e *models.JobEventR if s.UniboxRepository != nil { _ = s.UniboxRepository.Delete(ctx, e.UserID, e.ID) } + + // Tell open dashboards the row is gone (org-scoped so every teammate's + // unibox drops it live, not just the mailbox owner's). + if s.StreamingPublisher != nil { + var orgID string + if account, err := s.EmailRepository.GetByID(ctx, e.EmailID); err == nil && account != nil && account.OrganizationID != nil { + orgID = account.OrganizationID.String() + } + s.StreamingPublisher.PublishEmailDeleted(ctx, &pubsub.EmailInboxEvent{ + BaseEvent: pubsub.BaseEvent{UserID: e.UserID.String()}, + OrgID: orgID, + EmailAccountID: e.EmailID.String(), + MessageID: e.ID.String(), + }) + } return nil } diff --git a/internal/app/consumer/event_tracking.go b/internal/app/consumer/event_tracking.go index 0fff6f17..abeb7636 100644 --- a/internal/app/consumer/event_tracking.go +++ b/internal/app/consumer/event_tracking.go @@ -129,6 +129,11 @@ func (tc *TrackingConsumer) HandleTrackingEvent(ctx context.Context, event *even urlHash = hashURL(*event.OriginalURL) } + // Classify opens: machine fetches (Apple MPP prefetch, UA-less clients) + // still count as delivery signal but are labeled, and must never fire + // open-triggered automations (a prefetch is not intent). + machineOpen := event.EventType == events.EventTypeEmailOpened && isMachineOpen(event.UserAgent) + // Check for duplicate at consumer level (belt and suspenders with Rust service) if tc.dedupeRepo != nil { processed, err := tc.dedupeRepo.IsProcessed(ctx, taskID, event.EventType, urlHash) @@ -136,7 +141,18 @@ func (tc *TrackingConsumer) HandleTrackingEvent(ctx context.Context, event *even // Log but continue - allow processing on dedupe errors log.Warn().Err(err).Str("task_id", event.TaskID).Msg("tracking dedupe check failed") } else if processed { - // Already processed, skip + // A HUMAN open after a machine-labeled one upgrades the label + // (MPP prefetched at delivery; the person actually read it later + // from another network). Quiet write only: the open was already + // counted once, so no automations and no re-publish. + if event.EventType == events.EventTypeEmailOpened && !machineOpen { + if campaignTask, terr := tc.taskRepo.GetCampaignTask(ctx, taskID); terr == nil && + campaignTask != nil && campaignTask.CampaignID != nil && + campaignTask.ContactID != nil && campaignTask.SequenceID != nil { + _ = tc.campaignProgressRepo.RecordEmailOpened(ctx, + *campaignTask.CampaignID, *campaignTask.ContactID, *campaignTask.SequenceID, false) + } + } return nil } } @@ -170,8 +186,11 @@ func (tc *TrackingConsumer) HandleTrackingEvent(ctx context.Context, event *even err = tc.campaignProgressRepo.RecordEmailOpened(ctx, *campaignTask.CampaignID, *campaignTask.ContactID, - *campaignTask.SequenceID) - instantKind = "open" + *campaignTask.SequenceID, + machineOpen) + if !machineOpen { + instantKind = "open" + } case events.EventTypeEmailClicked: err = tc.campaignProgressRepo.RecordEmailClicked(ctx, *campaignTask.CampaignID, @@ -193,7 +212,7 @@ func (tc *TrackingConsumer) HandleTrackingEvent(ctx context.Context, event *even // must never block tracking ingest; the scheduler still routes the matching // opened/clicked branch at the next step boundary. Exactly-once per (step, // eventKind) is enforced inside FireInstantActions via ClaimInstantFire. - if tc.advancedService != nil { + if tc.advancedService != nil && instantKind != "" { tc.advancedService.FireInstantActions(ctx, *campaignTask.CampaignID, *campaignTask.ContactID, @@ -209,13 +228,13 @@ func (tc *TrackingConsumer) HandleTrackingEvent(ctx context.Context, event *even } // Publish to Pub/Sub for realtime updates - tc.publishTrackingEvent(ctx, campaignTask, *event) + tc.publishTrackingEvent(ctx, campaignTask, *event, machineOpen) return nil } // publishTrackingEvent publishes the tracking event to Pub/Sub for realtime UI updates -func (tc *TrackingConsumer) publishTrackingEvent(ctx context.Context, task *repository.CampaignTask, event events.TrackingEvent) { +func (tc *TrackingConsumer) publishTrackingEvent(ctx context.Context, task *repository.CampaignTask, event events.TrackingEvent, machine bool) { if tc.streamingPublisher == nil { return } @@ -246,17 +265,24 @@ func (tc *TrackingConsumer) publishTrackingEvent(ctx context.Context, task *repo return } - // Publish tracking event + // Publish tracking event (org-scoped: opens/clicks pulse live for the + // whole team, not just the campaign owner) + var orgID string + if campaign.OrganizationID != nil { + orgID = campaign.OrganizationID.String() + } trackingPayload := &pubsub.TrackingEventPayload{ BaseEvent: pubsub.BaseEvent{ EventType: eventType, UserID: campaign.UserID, Timestamp: time.Now(), }, + OrgID: orgID, CampaignID: task.CampaignID.String(), ContactID: task.ContactID.String(), ContactEmail: contactEmail, SequenceID: task.SequenceID.String(), + Machine: machine, } if event.EventType == events.EventTypeEmailClicked && event.OriginalURL != nil { diff --git a/internal/app/consumer/open_class.go b/internal/app/consumer/open_class.go new file mode 100644 index 00000000..43f492b6 --- /dev/null +++ b/internal/app/consumer/open_class.go @@ -0,0 +1,27 @@ +package jobs + +import "strings" + +// isMachineOpen reports whether an open event came from an automated fetcher +// rather than a human-rendered view. The edge already filters crawlers and +// security scanners outright; this classifies the gray zone we still WANT to +// count (it is real delivery signal) but must not present as a human open: +// +// - Apple Mail Privacy Protection prefetches every pixel at delivery time +// with a WebKit UA that ends at the engine token. A real Safari/Mail +// render continues with "Version/... Safari/...", so the bare suffix is +// the canonical MPP fingerprint. +// - A missing UA is never a real mail client or browser. +// +// Gmail's image proxy is deliberately treated as HUMAN: it fetches at open +// time (not delivery), and it is the only open signal Gmail exposes. +func isMachineOpen(userAgent *string) bool { + if userAgent == nil { + return true + } + ua := strings.ToLower(strings.TrimSpace(*userAgent)) + if ua == "" { + return true + } + return strings.HasSuffix(ua, "(khtml, like gecko)") +} diff --git a/internal/app/contact/handler.go b/internal/app/contact/handler.go index 0a8b2f15..efa3d125 100644 --- a/internal/app/contact/handler.go +++ b/internal/app/contact/handler.go @@ -8,6 +8,7 @@ import ( "github.com/google/uuid" "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/utils/paging" "github.com/warmbly/warmbly/internal/utils/validate" ) @@ -41,8 +42,8 @@ func (s *contactService) Add(ctx context.Context, userID string, contacts []mode return created, nil } -func (s *contactService) Search(ctx context.Context, userID, cursor, category, limit string, filters models.SearchContacts) (*models.ContactsResult, *errx.Error) { - cursorId, err := validate.Uuid(cursor) +func (s *contactService) Search(ctx context.Context, orgID, cursor, category, limit string, filters models.SearchContacts) (*models.ContactsResult, *errx.Error) { + cursorId, err := paging.DecodeCursor(cursor) if err != nil { return nil, err } @@ -56,7 +57,7 @@ func (s *contactService) Search(ctx context.Context, userID, cursor, category, l return nil, err } - return s.contactRepository.Search(ctx, userID, categoryId, cursorId, filters, limitN) + return s.contactRepository.Search(ctx, orgID, categoryId, cursorId, filters, limitN) } func (s *contactService) BulkUpdate(ctx context.Context, userID string, data *models.BulkEditContactsData) ([]models.Contact, *errx.Error) { diff --git a/internal/app/email/handler.go b/internal/app/email/handler.go index d394845b..e890b788 100644 --- a/internal/app/email/handler.go +++ b/internal/app/email/handler.go @@ -10,11 +10,12 @@ import ( "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/infrastructure/pubsub" "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/utils/paging" "github.com/warmbly/warmbly/internal/utils/validate" ) -func (s *emailService) Search(ctx context.Context, userID, search, cursor, tag, limit string, allowedAccountIDs []uuid.UUID) (*models.EmailsResult, *errx.Error) { - cursorId, err := validate.Uuid(cursor) +func (s *emailService) Search(ctx context.Context, orgID, search, cursor, tag, limit string, allowedAccountIDs []uuid.UUID) (*models.EmailsResult, *errx.Error) { + cursorId, err := paging.DecodeCursor(cursor) if err != nil { return nil, err } @@ -32,11 +33,11 @@ func (s *emailService) Search(ctx context.Context, userID, search, cursor, tag, return nil, err } - return s.emailRepository.Search(ctx, userID, search, cursorId, tagId, limitN, allowedAccountIDs) + return s.emailRepository.Search(ctx, orgID, search, cursorId, tagId, limitN, allowedAccountIDs) } -func (s *emailService) Get(ctx context.Context, userID, emailAccountID string) (*models.Email, *errx.Error) { - return s.emailRepository.Get(ctx, userID, emailAccountID) +func (s *emailService) Get(ctx context.Context, orgID, emailAccountID string) (*models.Email, *errx.Error) { + return s.emailRepository.Get(ctx, orgID, emailAccountID) } func (s *emailService) Update(ctx context.Context, userID, emailAccountID string, udata *models.UpdateEmail) (*models.Email, *errx.Error) { diff --git a/internal/app/email/service.go b/internal/app/email/service.go index 22f45ccd..3c189d3b 100644 --- a/internal/app/email/service.go +++ b/internal/app/email/service.go @@ -132,11 +132,16 @@ func (s *emailService) publishAccountEvent(ctx context.Context, eventType pubsub return } + var orgID string + if account.OrganizationID != nil { + orgID = account.OrganizationID.String() + } s.streamingPublisher.PublishAccountEvent(ctx, &pubsub.AccountEvent{ BaseEvent: pubsub.BaseEvent{ EventType: eventType, UserID: account.UserID, }, + OrgID: orgID, EmailAccountID: account.ID.String(), Email: account.Email, Provider: account.Provider, diff --git a/internal/app/integration/actions.go b/internal/app/integration/actions.go index dfbdaa99..0e8c9f85 100644 --- a/internal/app/integration/actions.go +++ b/internal/app/integration/actions.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -12,13 +13,18 @@ import ( "time" "github.com/google/uuid" + "github.com/rs/zerolog/log" "github.com/warmbly/warmbly/internal/app/webhook" + "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/pkg/safehttp" ) -// actionHTTP is the shared client for outbound provider action calls. Short -// timeout — a slow third party shouldn't pin a dispatch goroutine for long. -var actionHTTP = &http.Client{Timeout: 15 * time.Second} +// actionHTTP is the shared client for outbound provider action calls. It is +// SSRF-hardened (resolves + blocks non-public hosts at dial time) because most of +// these calls go to user-supplied URLs. Short timeout — a slow third party +// shouldn't pin a dispatch goroutine for long. +var actionHTTP = safehttp.Client(15 * time.Second) // automationEventPayload is the structured, versioned body delivered to generic // automation webhooks (Zapier / Make / n8n). The legacy flat fields @@ -85,16 +91,211 @@ func automationDeliver(ctx context.Context, targetURL, secret, eventType string, // newDeliveryID returns a fresh idempotency / delivery id for a webhook event. func newDeliveryID() string { return uuid.New().String() } +// httpResponseBodyLimit caps how much of a response we read + keep so a huge +// response can't blow up memory (it lives in the run's in-memory event data). +const httpResponseBodyLimit = 64 << 10 // 64 KiB + +// runHTTPRequest performs the configurable HTTP node: render method/URL/headers/ +// query/body against the event data, SSRF-guard the URL, call it with bounded +// retry, and write the response back into `data` under the node's output key +// (default "response") so downstream nodes can template {{.response.body...}} +// and condition nodes can branch on {{.response.ok}}. +func runHTTPRequest(ctx context.Context, orgID, automationID uuid.UUID, n models.AutomationNode, cfg nativeActionConfig, data map[string]any) error { + method := strings.ToUpper(strings.TrimSpace(cfg.HTTPMethod)) + if method == "" { + method = http.MethodPost + } + rawURL := strings.TrimSpace(renderTemplate(cfg.HTTPURL, data)) + if rawURL == "" { + return fmt.Errorf("http request needs a url") + } + // SSRF + HTTPS guard (same policy as outbound webhooks), re-checked here at + // execution time, not just at save time. + if err := webhook.ValidateOutboundURL(rawURL); err != nil { + return fmt.Errorf("http url rejected: %w", err) + } + if len(cfg.HTTPQuery) > 0 { + u, err := url.Parse(rawURL) + if err != nil { + return fmt.Errorf("invalid http url: %w", err) + } + q := u.Query() + for k, v := range cfg.HTTPQuery { + q.Set(k, renderTemplate(v, data)) + } + u.RawQuery = q.Encode() + rawURL = u.String() + } + body := renderTemplate(cfg.HTTPBody, data) + + outKey := strings.TrimSpace(cfg.HTTPOutputKey) + if outKey == "" { + outKey = "response" + } + + // Abuse trail: log every outbound HTTP-request action with org/automation + // attribution so a misuse pattern (scanning, relaying) is reviewable. + host := rawURL + if pu, perr := url.Parse(rawURL); perr == nil { + host = pu.Hostname() + } + log.Info().Str("org_id", orgID.String()).Str("automation_id", automationID.String()). + Str("method", method).Str("host", host).Msg("automation http_request outbound") + + var lastErr error + for attempt := 0; attempt < 3; attempt++ { + if attempt > 0 { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(time.Duration(attempt) * 500 * time.Millisecond): + } + } + var reader io.Reader + if body != "" { + reader = strings.NewReader(body) + } + req, err := http.NewRequestWithContext(ctx, method, rawURL, reader) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "Warmbly-Automations/1.0") + for k, v := range cfg.HTTPHeaders { + req.Header.Set(k, renderTemplate(v, data)) + } + + resp, err := actionHTTP.Do(req) + if err != nil { + lastErr = err + continue + } + out := readHTTPOutput(resp) + _ = resp.Body.Close() + data[outKey] = out + recordStepOutput(n, data, out) + + if ok, _ := out["ok"].(bool); ok { + return nil + } + status, _ := out["status"].(int) + lastErr = fmt.Errorf("http %s -> %d", method, status) + if status >= 400 && status < 500 { + return lastErr // client error: retrying won't help + } + } + + // A blocked destination is an SSRF attempt worth flagging with attribution. + if errors.Is(lastErr, safehttp.ErrBlockedAddress) { + log.Warn().Str("org_id", orgID.String()).Str("automation_id", automationID.String()). + Str("host", host).Msg("automation http_request blocked: non-public destination") + } + + // Network failure on every attempt — still record a failure response so a + // downstream condition on {{.response.ok}} can route to an error branch. + if _, ok := data[outKey]; !ok { + fail := map[string]any{"ok": false, "status": 0, "error": lastErr.Error()} + data[outKey] = fail + recordStepOutput(n, data, fail) + } + return lastErr +} + +func readHTTPOutput(resp *http.Response) map[string]any { + raw, _ := io.ReadAll(io.LimitReader(resp.Body, httpResponseBodyLimit)) + out := map[string]any{ + "status": resp.StatusCode, + "ok": resp.StatusCode >= 200 && resp.StatusCode < 300, + "text": string(raw), + "headers": flattenHeaders(resp.Header), + } + // Parse JSON bodies so {{.response.body.field}} works downstream. + var parsed any + if json.Unmarshal(raw, &parsed) == nil { + out["body"] = parsed + } + return out +} + +func flattenHeaders(h http.Header) map[string]string { + out := make(map[string]string, len(h)) + for k := range h { + out[k] = h.Get(k) + } + return out +} + +// recordStepOutput also stores the response under data["steps"][nodeID] so a +// later node can reference a specific earlier call via {{index .steps ""}}. +func recordStepOutput(n models.AutomationNode, data map[string]any, out map[string]any) { + steps, ok := data["steps"].(map[string]any) + if !ok { + steps = map[string]any{} + data["steps"] = steps + } + steps[n.ID] = out +} + +// Warmbly's sky accent (Tailwind sky-500, #0EA5E9) brands outbound notification +// cards: an integer for Discord embeds, a hex string for Slack attachments. +const ( + notifyAccentInt = 0x0EA5E9 + notifyAccentHex = "#0EA5E9" +) + +// truncateRunes caps a string at max runes (provider embed/field limits), adding +// an ellipsis when it trims so a long custom template can't get rejected. +func truncateRunes(s string, max int) string { + r := []rune(s) + if len(r) <= max { + return s + } + if max <= 1 { + return string(r[:max]) + } + return string(r[:max-1]) + "…" +} + +// notifyFields turns the message's contact/subject into structured key-value +// fields shared by the Slack and Discord cards (omitted when empty). +func (m eventMessage) notifyFields() (contact, subject string) { + return m.Email, m.Subject +} + // slackPostMessage posts to a channel using a bot token from the OAuth connect. -// Slack returns HTTP 200 with {ok:false,error:...} on failure, so we inspect -// the body rather than the status code. +// It renders a sky-accented attachment card (title + contact/subject fields) +// rather than a bare line, with a plain-text fallback for the notification +// preview. Slack returns HTTP 200 with {ok:false,error:...} on failure, so we +// inspect the body rather than the status code. func slackPostMessage(ctx context.Context, token, channel string, msg eventMessage) error { if channel == "" { return fmt.Errorf("no slack channel configured") } + attachment := map[string]any{ + "color": notifyAccentHex, + "fallback": msg.plainText(), + "title": truncateRunes(msg.Title, 256), + "footer": "Warmbly", + "ts": time.Now().Unix(), + } + if msg.Custom != "" { + attachment["text"] = truncateRunes(msg.Custom, 3000) + } + contact, subject := msg.notifyFields() + var fields []map[string]any + if contact != "" { + fields = append(fields, map[string]any{"title": "Contact", "value": contact, "short": true}) + } + if subject != "" { + fields = append(fields, map[string]any{"title": "Subject", "value": subject, "short": true}) + } + if len(fields) > 0 { + attachment["fields"] = fields + } body, _ := json.Marshal(map[string]any{ - "channel": channel, - "text": msg.plainText(), + "channel": channel, + "text": msg.Title, + "attachments": []map[string]any{attachment}, }) req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://slack.com/api/chat.postMessage", bytes.NewReader(body)) if err != nil { @@ -122,15 +323,37 @@ func slackPostMessage(ctx context.Context, token, channel string, msg eventMessa return nil } -// webhookPost delivers a Discord-compatible payload (and works for any generic -// JSON webhook): Discord requires a top-level "content" string. -func webhookPost(ctx context.Context, url string, msg eventMessage) error { - body, _ := json.Marshal(map[string]any{ - "content": msg.plainText(), - "email": msg.Email, - "subject": msg.Subject, - "title": msg.Title, - }) +// discordEmbedPayload builds a Discord webhook body as a single rich embed in +// Warmbly's sky theme (title + optional description + contact/subject fields + +// footer/timestamp) rather than a plain content line, so notifications render as +// branded cards. Discord ignores unknown top-level keys, so embeds are the body. +func discordEmbedPayload(msg eventMessage) map[string]any { + embed := map[string]any{ + "title": truncateRunes(msg.Title, 256), + "color": notifyAccentInt, + "footer": map[string]any{"text": "Warmbly"}, + "timestamp": time.Now().UTC().Format(time.RFC3339), + } + if msg.Custom != "" { + embed["description"] = truncateRunes(msg.Custom, 4096) + } + contact, subject := msg.notifyFields() + var fields []map[string]any + if contact != "" { + fields = append(fields, map[string]any{"name": "Contact", "value": truncateRunes(contact, 1024), "inline": true}) + } + if subject != "" { + fields = append(fields, map[string]any{"name": "Subject", "value": truncateRunes(subject, 1024), "inline": true}) + } + if len(fields) > 0 { + embed["fields"] = fields + } + return map[string]any{"embeds": []map[string]any{embed}} +} + +// discordNotify delivers a sky-themed embed card to a Discord channel webhook. +func discordNotify(ctx context.Context, url string, msg eventMessage) error { + body, _ := json.Marshal(discordEmbedPayload(msg)) req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) if err != nil { return err diff --git a/internal/app/integration/dispatch.go b/internal/app/integration/dispatch.go index 09b3555b..218d5174 100644 --- a/internal/app/integration/dispatch.go +++ b/internal/app/integration/dispatch.go @@ -132,7 +132,7 @@ func (s *service) execAction(ctx context.Context, target repository.DispatchTarg if rerr != nil { return rerr } - return webhookPost(ctx, rendered, msg) + return discordNotify(ctx, rendered, msg) case models.IntegrationActionGenericWebhookPing: url := stringFromMap(secretCfg, "webhook_url") diff --git a/internal/app/integration/graph_executor.go b/internal/app/integration/graph_executor.go index cc8730b5..d30b7c29 100644 --- a/internal/app/integration/graph_executor.go +++ b/internal/app/integration/graph_executor.go @@ -87,15 +87,39 @@ func (s *service) executeAutomationGraph(ctx context.Context, a models.Automatio case models.AutomationNodeAction: label, aerr := s.runGraphAction(ctx, a, n, eventType, data) nr := models.AutomationNodeResult{NodeID: id, Type: "action", Action: string(n.Action), Label: label, Status: "success"} + // Capture what the action actually did (rendered fields + any output it + // wrote) so run history shows the result, not just success/failure. + nr.Preview = actionRunOutput(n, data) if aerr != nil { nr.Status = "error" nr.Error = truncate(aerr.Error(), 300) - anyError = true + // try/catch: if the node has an "on error" branch, route the failure + // there and treat it as handled (the run is not marked failed, the + // normal path is skipped). With no error branch, fall back to the old + // best-effort behavior: mark the run errored and continue downstream. + handled := false + for _, e := range outEdges[id] { + if e.When == "error" { + queue = append(queue, e.Target) + handled = true + } + } + if !handled { + anyError = true + for _, e := range outEdges[id] { + if e.When != "error" { + queue = append(queue, e.Target) + } + } + } + } else { + for _, e := range outEdges[id] { + if e.When != "error" { + queue = append(queue, e.Target) + } + } } results = append(results, nr) - for _, e := range outEdges[id] { - queue = append(queue, e.Target) - } default: // trigger / unknown: just follow outgoing edges for _, e := range outEdges[id] { queue = append(queue, e.Target) @@ -203,12 +227,18 @@ func (s *service) DryRunAutomation(ctx context.Context, orgID, id uuid.UUID, req if len(data) == 0 { data = sampleEventData(a.TriggerEvent) } - return &models.DryRunResponse{Trace: dryRunGraph(*a, data), Data: data}, nil + skip := make(map[string]bool, len(req.SkipNodeIDs)) + for _, id := range req.SkipNodeIDs { + skip[id] = true + } + return &models.DryRunResponse{Trace: dryRunGraph(*a, data, skip), Data: data}, nil } // dryRunGraph mirrors executeAutomationGraph's walk but records a preview trace // instead of executing anything (conditions are pure, so they evaluate for real). -func dryRunGraph(a models.Automation, data map[string]any) []models.AutomationNodeResult { +// Action nodes in skip are recorded as "skipped" and not previewed, but the walk +// still follows their normal edge so downstream steps are reflected. +func dryRunGraph(a models.Automation, data map[string]any, skip map[string]bool) []models.AutomationNodeResult { baseSeed := stringFromMap(data, "delivery_id", "id", "booking_id", "contact_email", "invitee_email", "email") byID := make(map[string]models.AutomationNode, len(a.Graph.Nodes)) for _, n := range a.Graph.Nodes { @@ -261,16 +291,31 @@ func dryRunGraph(a models.Automation, data map[string]any) []models.AutomationNo } } case models.AutomationNodeAction: - trace = append(trace, models.AutomationNodeResult{ - NodeID: id, - Type: "action", - Action: string(n.Action), - Label: string(n.Action), - Status: "success", - Preview: actionPreview(n, data), - }) + if skip[id] { + // Toggled off for this test: record as skipped, no preview. + trace = append(trace, models.AutomationNodeResult{ + NodeID: id, + Type: "action", + Action: string(n.Action), + Label: string(n.Action), + Status: "skipped", + }) + } else { + trace = append(trace, models.AutomationNodeResult{ + NodeID: id, + Type: "action", + Action: string(n.Action), + Label: string(n.Action), + Status: "success", + Preview: actionPreview(n, data), + }) + } + // A dry run never fails an action, so it follows the normal path and + // not the "on error" branch (a skipped action still continues the walk). for _, e := range outEdges[id] { - queue = append(queue, e.Target) + if e.When != "error" { + queue = append(queue, e.Target) + } } default: for _, e := range outEdges[id] { @@ -297,10 +342,51 @@ func actionPreview(n models.AutomationNode, data map[string]any) map[string]any if cfg.TaskTitle != "" { p["task_title"] = renderTemplate(cfg.TaskTitle, data) } + if n.Action == models.IntegrationActionFireEvent { + p["event"] = renderTemplate(cfg.EventName, data) + for _, f := range cfg.EventFields { + if k := strings.TrimSpace(f.Key); k != "" { + p[k] = renderTemplate(f.Value, data) + } + } + } } return p } +// actionRunOutput captures, after an action node ran, a small summary of what it +// did for run history: the rendered templatable fields (channel/url/message/deal/ +// task), plus node-specific output — an HTTP call's status/ok and the values a +// set-variables node wrote. Returns nil when there is nothing to show. Kept +// scalar + bounded so it stays cheap to store in the run's node_results jsonb. +func actionRunOutput(n models.AutomationNode, data map[string]any) map[string]any { + out := actionPreview(n, data) + switch n.Action { + case models.IntegrationActionHTTPRequest: + if steps, ok := data["steps"].(map[string]any); ok { + if r, ok := steps[n.ID].(map[string]any); ok { + if v, ok := r["status"]; ok { + out["status"] = v + } + if v, ok := r["ok"]; ok { + out["ok"] = v + } + } + } + case models.IntegrationActionSetVariables: + cfg := parseNativeConfig(n.Config) + for _, v := range cfg.SetVars { + if k := strings.TrimSpace(v.Key); k != "" { + out[k] = truncate(valueString(data[k]), 200) + } + } + } + if len(out) == 0 { + return nil + } + return out +} + // sampleEventData builds a placeholder event payload for a trigger so a dry-run // has something to render/evaluate against when the caller supplies none. func sampleEventData(triggerEvent string) map[string]any { diff --git a/internal/app/integration/native_actions.go b/internal/app/integration/native_actions.go index f505c080..0b4a537c 100644 --- a/internal/app/integration/native_actions.go +++ b/internal/app/integration/native_actions.go @@ -29,10 +29,45 @@ func validateNativeActionConfig(action models.IntegrationAction, raw json.RawMes if strings.TrimSpace(cfg.AutomationID) == "" { return fmt.Errorf("a run-automation action needs a target automation") } + case models.IntegrationActionLabelEmail: + if len(parseUUIDList(cfg.LabelIDs)) == 0 { + return fmt.Errorf("a label action needs at least one label") + } + case models.IntegrationActionHTTPRequest: + if strings.TrimSpace(cfg.HTTPURL) == "" { + return fmt.Errorf("an HTTP request needs a URL") + } + case models.IntegrationActionSetVariables: + hasOne := false + for _, v := range cfg.SetVars { + if strings.TrimSpace(v.Key) != "" { + hasOne = true + break + } + } + if !hasOne { + return fmt.Errorf("set variables needs at least one named variable") + } + case models.IntegrationActionFireEvent: + if strings.TrimSpace(cfg.EventName) == "" { + return fmt.Errorf("a fire-event action needs an event name") + } } return nil } +// parseUUIDList parses a slice of string ids into uuids, dropping any that don't +// parse. Used by the label_email action's category list. +func parseUUIDList(ids []string) []uuid.UUID { + out := make([]uuid.UUID, 0, len(ids)) + for _, s := range ids { + if id, err := uuid.Parse(strings.TrimSpace(s)); err == nil { + out = append(out, id) + } + } + return out +} + // NativeActions runs Warmbly-internal CRM/contact mutations for automation // action nodes (no external connection). It's a consumer-side interface so the // integration package needs no import of advanced/tasks/repository — a thin @@ -50,6 +85,10 @@ type NativeActions interface { CreateDeal(ctx context.Context, orgID, createdBy uuid.UUID, data *models.CreateDeal) error MoveDealStage(ctx context.Context, orgID, contactID, pipelineID, stageID uuid.UUID) error Unsubscribe(ctx context.Context, campaignID, contactID uuid.UUID) error + // LabelThread additively applies unibox conversation labels to a thread, on + // behalf of the mailbox-owner userID (categories are per user). Backs the + // "label_email" action; userID + threadID come from the reply event data. + LabelThread(ctx context.Context, userID uuid.UUID, threadID string, categoryIDs []uuid.UUID) error } // nativeActionConfig is the per-node config for native action nodes (mirrors the @@ -69,6 +108,29 @@ type nativeActionConfig struct { TaskAssignedTeamID string `json:"task_assigned_team_id"` // run_automation: the automation to launch. AutomationID string `json:"automation_id"` + // label_email: the unibox conversation labels to apply (category-registry ids). + LabelIDs []string `json:"label_ids"` + // http_request: a configurable outbound call. Method/URL/headers/query/body + // are all Go-templated against the event + prior step output. The response is + // written back into the event data under HTTPOutputKey (default "response"). + HTTPMethod string `json:"http_method"` + HTTPURL string `json:"http_url"` + HTTPHeaders map[string]string `json:"http_headers"` + HTTPQuery map[string]string `json:"http_query"` + HTTPBody string `json:"http_body"` + HTTPOutputKey string `json:"http_output_key"` + // set_variables: named values computed from templates and written back into + // the event data for later nodes to reuse. + SetVars []setVar `json:"set_vars"` + // fire_event: a developer-defined custom event published to the realtime + // gateway. EventName + each field value are Go-templated against the event data. + EventName string `json:"event_name"` + EventFields []setVar `json:"event_fields"` +} + +type setVar struct { + Key string `json:"key"` + Value string `json:"value"` } func parseNativeConfig(raw json.RawMessage) nativeActionConfig { @@ -101,6 +163,65 @@ func (s *service) execNativeAction(ctx context.Context, a models.Automation, n m return s.RunAutomationByID(ctx, a.OrganizationID, targetID, data) } + // http_request makes a configurable outbound call and writes the response + // back into `data`. It needs no contact, so handle it before resolution. + if n.Action == models.IntegrationActionHTTPRequest { + if !s.allowOutbound(ctx, a.OrganizationID) { + return fmt.Errorf("daily outbound request limit reached (%d/day); contact support to raise it", outboundDailyQuota) + } + return runHTTPRequest(ctx, a.OrganizationID, a.ID, n, cfg, data) + } + + // set_variables computes named values from templates and writes them back + // into the event data for later nodes. No contact, no external call. + if n.Action == models.IntegrationActionSetVariables { + for _, v := range cfg.SetVars { + key := strings.TrimSpace(v.Key) + if key == "" { + continue + } + data[key] = renderTemplate(v.Value, data) + } + return nil + } + + // fire_event publishes a developer-defined custom event to the realtime + // gateway (org-scoped). Subscribers receive it over the websocket with no + // public URL. Name + each field value are templated against the event data. + if n.Action == models.IntegrationActionFireEvent { + name := strings.TrimSpace(renderTemplate(cfg.EventName, data)) + if name == "" { + return fmt.Errorf("fire-event needs an event name") + } + payload := make(map[string]string, len(cfg.EventFields)) + for _, f := range cfg.EventFields { + key := strings.TrimSpace(f.Key) + if key == "" { + continue + } + payload[key] = renderTemplate(f.Value, data) + } + if s.publisher != nil { + s.publisher.PublishCustomEvent(ctx, a.OrganizationID, uuid.Nil, name, payload, "automation", a.ID.String()) + } + return nil + } + + // label_email tags the conversation the event belongs to; it needs the + // thread + mailbox owner (carried by reply triggers), not a resolved contact. + if n.Action == models.IntegrationActionLabelEmail { + threadID := stringFromMap(data, "thread_id") + ownerID, perr := uuid.Parse(stringFromMap(data, "_user_id")) + if threadID == "" || perr != nil { + return fmt.Errorf("label-email needs a reply thread (use it on a reply trigger)") + } + catIDs := parseUUIDList(cfg.LabelIDs) + if len(catIDs) == 0 { + return fmt.Errorf("a label action needs at least one label") + } + return s.native.LabelThread(ctx, ownerID, threadID, catIDs) + } + contactID := stringFromMap(data, "contact_id") email := stringFromMap(data, "contact_email", "invitee_email", "email") diff --git a/internal/app/integration/service.go b/internal/app/integration/service.go index 7dfeb349..8a3378c9 100644 --- a/internal/app/integration/service.go +++ b/internal/app/integration/service.go @@ -11,15 +11,23 @@ import ( "time" "github.com/google/uuid" + "github.com/rs/zerolog/log" "github.com/warmbly/warmbly/internal/app/cipher" "github.com/warmbly/warmbly/internal/app/webhook" "github.com/warmbly/warmbly/internal/errx" + "github.com/warmbly/warmbly/internal/infrastructure/cache" "github.com/warmbly/warmbly/internal/infrastructure/pubsub" "github.com/warmbly/warmbly/internal/models" "github.com/warmbly/warmbly/internal/repository" ) +// outboundDailyQuota caps per-org outbound HTTP-request automation actions per +// day. It is an anti-abuse ceiling (relay / scanning), set well above any +// legitimate flow, not a pricing tier — tune freely. Enforced only when a quota +// cache is wired; fail-open otherwise so infra hiccups never break automations. +const outboundDailyQuota = 1000 + // oauthStateTTL bounds how long a started OAuth handshake stays valid. const oauthStateTTL = 15 * time.Minute @@ -80,10 +88,17 @@ type Service interface { DryRunAutomation(ctx context.Context, orgID, id uuid.UUID, req models.DryRunRequest) (*models.DryRunResponse, error) // ListAutomationRuns returns recent run history for an automation. ListAutomationRuns(ctx context.Context, orgID, id uuid.UUID, limit int) ([]models.AutomationRun, error) + // TriggerInboundAutomation runs the automation whose inbound-webhook token is + // given, using body (JSON) as the event payload. Returns + // ErrInboundAutomationNotFound for an unknown/non-inbound token. + TriggerInboundAutomation(ctx context.Context, token string, body []byte) error // SetNativeActions wires the native CRM/contact action executor + the realtime // publisher post-construction (they depend on services built after this one). SetNativeActions(n NativeActions) SetPublisher(p *pubsub.StreamingPublisher) + // SetOutboundQuotaCache wires the Redis cache backing the per-org daily + // outbound-action quota (anti-abuse). Optional; nil disables the quota. + SetOutboundQuotaCache(c *cache.Cache) // ListSyncRuns returns recent observability records for a connection. ListSyncRuns(ctx context.Context, orgID, connID uuid.UUID, limit int) ([]models.IntegrationSyncRun, error) @@ -139,16 +154,48 @@ type Service interface { // Dispatch; struct payloads are ignored. DispatchAny(ctx context.Context, orgID uuid.UUID, eventType models.WebhookEventType, data any) + // NotifySlack posts a plain message to the org's connected Slack on its + // configured default channel. No-op (nil) when no Slack is connected. + NotifySlack(ctx context.Context, orgID uuid.UUID, title, body string) error + // Repo exposes the underlying repository for the inbound webhook handlers. Repo() repository.IntegrationRepository } type service struct { - repo repository.IntegrationRepository - cipher cipher.CipherService - oauth *OAuthManager - native NativeActions - publisher *pubsub.StreamingPublisher + repo repository.IntegrationRepository + cipher cipher.CipherService + oauth *OAuthManager + native NativeActions + publisher *pubsub.StreamingPublisher + quotaCache *cache.Cache +} + +// SetOutboundQuotaCache wires the Redis cache backing the per-org daily outbound +// quota (post-construction, like the other setters). Both the backend and the +// consumer run automations, so both wire it; nil leaves the quota disabled. +func (s *service) SetOutboundQuotaCache(c *cache.Cache) { s.quotaCache = c } + +// allowOutbound increments and checks the per-org daily outbound-action counter. +// Fail-open: a missing cache or a Redis error never blocks an automation. +func (s *service) allowOutbound(ctx context.Context, orgID uuid.UUID) bool { + if s.quotaCache == nil { + return true + } + key := fmt.Sprintf("automation:outbound:%s:%s", orgID, time.Now().UTC().Format("20060102")) + n, err := s.quotaCache.Incr(ctx, key).Result() + if err != nil { + return true + } + if n == 1 { + _ = s.quotaCache.Expire(ctx, key, 26*time.Hour).Err() + } + if n > outboundDailyQuota { + log.Warn().Str("org_id", orgID.String()).Int64("count", n). + Msg("automation outbound daily quota exceeded") + return false + } + return true } // NewService builds the integration service. cipherSvc seals provider secrets @@ -486,11 +533,23 @@ func (s *service) DeleteEventSubscription(ctx context.Context, orgID, id uuid.UU // --- Automations ------------------------------------------------------------ func (s *service) ListAutomations(ctx context.Context, orgID uuid.UUID) ([]models.Automation, error) { - return s.repo.ListAutomations(ctx, orgID) + list, err := s.repo.ListAutomations(ctx, orgID) + if err != nil { + return nil, err + } + for i := range list { + decorateAutomation(&list[i]) + } + return list, nil } func (s *service) GetAutomation(ctx context.Context, orgID, id uuid.UUID) (*models.Automation, error) { - return s.repo.GetAutomation(ctx, orgID, id) + a, err := s.repo.GetAutomation(ctx, orgID, id) + if err != nil { + return nil, err + } + decorateAutomation(a) + return a, nil } func (s *service) CreateAutomation(ctx context.Context, orgID uuid.UUID, w models.AutomationWrite) (*models.Automation, error) { @@ -498,10 +557,17 @@ func (s *service) CreateAutomation(ctx context.Context, orgID uuid.UUID, w model if err != nil { return nil, err } + if isInboundTrigger(a.TriggerEvent) { + tok, terr := generateAutomationInboundToken() + if terr != nil { + return nil, terr + } + a.InboundToken = tok + } if err := s.repo.CreateAutomation(ctx, a); err != nil { return nil, err } - return s.repo.GetAutomation(ctx, orgID, a.ID) + return s.GetAutomation(ctx, orgID, a.ID) } func (s *service) UpdateAutomation(ctx context.Context, orgID, id uuid.UUID, w models.AutomationWrite) (*models.Automation, error) { @@ -510,10 +576,40 @@ func (s *service) UpdateAutomation(ctx context.Context, orgID, id uuid.UUID, w m return nil, err } a.ID = id + // Preserve the inbound token across edits; mint one the first time a flow + // becomes inbound; clear it when the trigger changes away (so the old URL + // stops firing). + if isInboundTrigger(a.TriggerEvent) { + existing, _ := s.repo.GetAutomation(ctx, orgID, id) + switch { + case existing != nil && existing.InboundToken != "": + a.InboundToken = existing.InboundToken + default: + tok, terr := generateAutomationInboundToken() + if terr != nil { + return nil, terr + } + a.InboundToken = tok + } + } if err := s.repo.UpdateAutomation(ctx, a); err != nil { return nil, err } - return s.repo.GetAutomation(ctx, orgID, id) + return s.GetAutomation(ctx, orgID, id) +} + +// isInboundTrigger reports whether a trigger event is the inbound webhook. +func isInboundTrigger(trigger string) bool { + return trigger == string(models.WebhookEventInboundWebhook) +} + +// decorateAutomation fills the computed, non-stored fields on an automation +// (today: the public InboundURL derived from its token). +func decorateAutomation(a *models.Automation) { + if a == nil { + return + } + a.InboundURL = BuildInboundAutomationURL(a.InboundToken) } func (s *service) DeleteAutomation(ctx context.Context, orgID, id uuid.UUID) error { @@ -666,14 +762,20 @@ func (s *service) validateAutomationGraph(ctx context.Context, orgID uuid.UUID, if e.Source == e.Target { return errors.New("a node cannot connect to itself") } - // Branch labels are only valid (and required) on edges out of a - // condition node; every other edge is an unconditional "then". - if src.Type == models.AutomationNodeCondition { + // Branch labels: a condition's edges are the yes/no paths; an action may + // have a plain "then" edge plus an optional "on error" branch; anything + // else is an unconditional "then". + switch { + case src.Type == models.AutomationNodeCondition: if e.When != "true" && e.When != "false" { return errors.New("a condition's branches must be a yes or no path") } - } else if e.When != "" { - return errors.New("only conditions can have yes/no branches") + case src.Type == models.AutomationNodeAction: + if e.When != "" && e.When != "error" { + return errors.New("an action edge must be a plain path or an on-error branch") + } + case e.When != "": + return errors.New("only conditions and actions can have branch labels") } adj[e.Source] = append(adj[e.Source], e.Target) } @@ -1075,6 +1177,77 @@ func BuildInboundURL(provider models.IntegrationProvider, secret string) string return "" } +// ErrInboundAutomationNotFound means no enabled inbound-webhook automation owns +// the given token (unknown, or the automation's trigger is no longer inbound). +var ErrInboundAutomationNotFound = errors.New("inbound automation not found") + +// generateAutomationInboundToken returns the high-entropy secret embedded in an +// automation's inbound-webhook URL (192 bits of randomness, prefixed). +func generateAutomationInboundToken() (string, error) { + buf := make([]byte, 24) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return "wmauto_" + hex.EncodeToString(buf), nil +} + +// BuildInboundAutomationURL is the public POST path that fires an inbound-webhook +// automation. Empty token (non-inbound automation) yields an empty URL. +func BuildInboundAutomationURL(token string) string { + if token == "" { + return "" + } + return "/api/v1/integrations/inbound/automation/" + token +} + +// parseInboundPayload turns an inbound webhook body into the event data map. A +// JSON object is used as-is (minus internal underscore keys a caller must not +// set); anything else is exposed verbatim under "body" so a template/condition +// can still read it. +func parseInboundPayload(body []byte) map[string]any { + if len(body) > 0 { + var m map[string]any + if json.Unmarshal(body, &m) == nil && m != nil { + for k := range m { + if strings.HasPrefix(k, "_") { + delete(m, k) + } + } + return m + } + } + out := map[string]any{} + if len(body) > 0 { + out["body"] = string(body) + } + return out +} + +// TriggerInboundAutomation resolves the automation an inbound-webhook token +// points at and runs its graph (in the background) with the POSTed body as the +// event payload. Unknown/non-inbound token -> ErrInboundAutomationNotFound; a +// disabled automation is accepted as a no-op (it must not leak that it exists). +func (s *service) TriggerInboundAutomation(ctx context.Context, token string, body []byte) error { + a, err := s.repo.GetAutomationByInboundToken(ctx, strings.TrimSpace(token)) + if err != nil { + return err + } + if a == nil || !isInboundTrigger(a.TriggerEvent) { + return ErrInboundAutomationNotFound + } + if !a.Enabled { + return nil + } + data := parseInboundPayload(body) + data["trigger"] = "inbound" + go func(au models.Automation, d map[string]any) { + bg, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + s.executeAutomationGraph(bg, au, string(models.WebhookEventInboundWebhook), d) + }(*a, data) + return nil +} + // buildDisplayFields extracts the public, non-secret bits of the config that // the dashboard surfaces next to a connection card. func buildDisplayFields(provider models.IntegrationProvider, config map[string]any) map[string]any { @@ -1105,3 +1278,58 @@ func buildDisplayFields(provider models.IntegrationProvider, config map[string]a } return df } + +// slackChannelFor resolves the channel to post org notifications to. The +// OAuth connect flow doesn't capture a default channel, so we look (in order) +// at the connection's own config, then reuse whatever channel the org already +// configured for a Slack automation/event subscription. Empty when none. +func (s *service) slackChannelFor(ctx context.Context, orgID uuid.UUID, c models.IntegrationConnection) string { + if ch := configString(c.DisplayFields, "channel"); ch != "" { + return ch + } + if ch := configString(c.ConfigCapabilities, "channel"); ch != "" { + return ch + } + subs, err := s.repo.ListEventSubscriptions(ctx, orgID, c.ID) + if err != nil { + return "" + } + for _, sub := range subs { + if sub.Action == models.IntegrationActionSlackNotify { + if ch := configString(sub.Config, "channel"); ch != "" { + return ch + } + } + } + return "" +} + +// NotifySlack posts a one-off message to the org's connected Slack workspace, +// on the default channel chosen at connect time. Used by the notification +// system's Slack delivery channel (distinct from event-subscription actions). +// Best-effort: returns nil when no healthy Slack connection exists. +func (s *service) NotifySlack(ctx context.Context, orgID uuid.UUID, title, body string) error { + conns, err := s.repo.ListConnections(ctx, orgID) + if err != nil { + return err + } + for _, c := range conns { + if c.Provider != models.IntegrationSlack || c.Status != models.IntegrationStatusConnected { + continue + } + channel := s.slackChannelFor(ctx, orgID, c) + if channel == "" { + continue + } + sec, serr := s.repo.GetConnectionSecrets(ctx, c.ID) + if serr != nil { + continue + } + token, terr := s.accessTokenFor(ctx, sec) + if terr != nil { + continue + } + return slackPostMessage(ctx, token, channel, eventMessage{Title: title, Detail: body}) + } + return nil +} diff --git a/internal/app/nativeactions/adapter.go b/internal/app/nativeactions/adapter.go new file mode 100644 index 00000000..82a7480b --- /dev/null +++ b/internal/app/nativeactions/adapter.go @@ -0,0 +1,106 @@ +package nativeactions + +import ( + "context" + "fmt" + + "github.com/google/uuid" + + "github.com/warmbly/warmbly/internal/app/advanced" + "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/repository" +) + +// Adapter satisfies integration.NativeActions, bridging the integration +// package's automation executor to the advanced/contact/org services. It +// converts *errx.Error to error (so a nil error stays nil) and resolves the +// contact + org owner the native CRM/contact actions need. Shared by the +// backend and consumer binaries, since automation actions run in BOTH (the +// consumer dispatches reply/bounce/warmup events, the backend the rest). +type Adapter struct { + Adv advanced.Service + Contacts repository.ContactRepository + Orgs repository.OrganizationRepository +} + +func (a Adapter) ResolveContact(ctx context.Context, orgID uuid.UUID, contactID, email string) (*models.Contact, error) { + // Both lookups are ORG-SCOPED — never resolve a contact id from another org, + // even if a stale/crafted id reaches the event data. + if contactID != "" { + if id, perr := uuid.Parse(contactID); perr == nil { + if cs, e := a.Contacts.GetByIDsAndOrganization(ctx, orgID, []uuid.UUID{id}); e == nil && len(cs) > 0 { + return &cs[0], nil + } + } + } + if email != "" { + if c, e := a.Contacts.GetByEmailAndOrganization(ctx, orgID, email); e == nil && c != nil { + return c, nil + } + } + return nil, nil +} + +func (a Adapter) OrgOwner(ctx context.Context, orgID uuid.UUID) (uuid.UUID, error) { + org, err := a.Orgs.GetByID(ctx, orgID) + if err != nil { + return uuid.Nil, err + } + if org == nil { + return uuid.Nil, fmt.Errorf("organization not found") + } + return org.OwnerUserID, nil +} + +func (a Adapter) AddTag(ctx context.Context, orgID, actorID, contactID, categoryID uuid.UUID) error { + if _, e := a.Contacts.Update(ctx, actorID.String(), contactID.String(), &models.UpdateContact{ + AddCategories: []string{categoryID.String()}, + }); e != nil { + return e + } + return nil +} + +func (a Adapter) RemoveTag(ctx context.Context, orgID, actorID, contactID, categoryID uuid.UUID) error { + if _, e := a.Contacts.Update(ctx, actorID.String(), contactID.String(), &models.UpdateContact{ + RemoveCategories: []string{categoryID.String()}, + }); e != nil { + return e + } + return nil +} + +func (a Adapter) CreateTask(ctx context.Context, orgID, createdBy uuid.UUID, data *models.CreateCRMTask) error { + if _, e := a.Adv.CreateContactTask(ctx, orgID, createdBy, data); e != nil { + return e + } + return nil +} + +func (a Adapter) CreateDeal(ctx context.Context, orgID, createdBy uuid.UUID, data *models.CreateDeal) error { + if _, e := a.Adv.CreateContactDeal(ctx, orgID, createdBy, data); e != nil { + return e + } + return nil +} + +func (a Adapter) MoveDealStage(ctx context.Context, orgID, contactID, pipelineID, stageID uuid.UUID) error { + if _, e := a.Adv.MoveContactDealStage(ctx, orgID, contactID, pipelineID, stageID); e != nil { + return e + } + return nil +} + +func (a Adapter) Unsubscribe(ctx context.Context, campaignID, contactID uuid.UUID) error { + if e := a.Adv.Unsubscribe(ctx, campaignID, contactID); e != nil { + return e + } + return nil +} + +// LabelThread applies unibox conversation labels to a thread on behalf of the +// mailbox owner (the advanced service guards category ownership). The error is +// already a plain error, so it passes straight through. +func (a Adapter) LabelThread(ctx context.Context, userID uuid.UUID, threadID string, categoryIDs []uuid.UUID) error { + return a.Adv.LabelThread(ctx, userID, threadID, categoryIDs) +} diff --git a/internal/app/notification/service.go b/internal/app/notification/service.go index b5a8fdc3..b51bd003 100644 --- a/internal/app/notification/service.go +++ b/internal/app/notification/service.go @@ -7,6 +7,10 @@ package notification import ( "context" + "fmt" + "os" + "strings" + "time" "github.com/google/uuid" @@ -16,6 +20,24 @@ import ( "github.com/warmbly/warmbly/internal/repository" ) +// EmailSender delivers a notification to a user's account email. Satisfied by +// notify.EmailNotificationService. +type EmailSender interface { + Send(ctx context.Context, to, cc, bcc []string, subject, message string) error +} + +// SlackNotifier posts to the org's connected Slack. Satisfied by the +// integration service (NotifySlack). +type SlackNotifier interface { + NotifySlack(ctx context.Context, orgID uuid.UUID, title, body string) error +} + +// UserLookup resolves a user's email + name for email delivery. Satisfied by +// the user repository. +type UserLookup interface { + GetUser(ctx context.Context, id uuid.UUID) (*models.User, error) +} + type Service interface { GetPreferences(ctx context.Context, userID uuid.UUID) (*models.NotificationPreferences, *errx.Error) UpdatePreferences(ctx context.Context, userID uuid.UUID, prefs *models.NotificationPreferences) *errx.Error @@ -26,11 +48,25 @@ type Service interface { // Notify is the gated ingress — best-effort, never errors out the caller. Notify(ctx context.Context, userID uuid.UUID, orgID *uuid.UUID, category models.NotificationCategory, title, body, link string, meta map[string]any) + + // WireDelivery attaches the email + Slack + user-lookup dependencies for + // the email/Slack channels (wired post-construction in both mains). Any + // may be nil — the matching channel is then skipped. + WireDelivery(email EmailSender, slack SlackNotifier, users UserLookup) } type service struct { repo repository.NotificationRepository publisher *pubsub.StreamingPublisher + email EmailSender + slack SlackNotifier + users UserLookup +} + +func (s *service) WireDelivery(email EmailSender, slack SlackNotifier, users UserLookup) { + s.email = email + s.slack = slack + s.users = users } func NewService(repo repository.NotificationRepository, publisher *pubsub.StreamingPublisher) Service { @@ -93,22 +129,69 @@ func (s *service) Notify(ctx context.Context, userID uuid.UUID, orgID *uuid.UUID return } cat := prefs.CategoryPref(category) - if !cat.Enabled || !cat.Channels.InApp { - return // the gate + if !cat.Enabled { + return // category off — no channel fires } - created, cerr := s.repo.Create(ctx, &models.Notification{ - UserID: userID, - OrganizationID: orgID, - Category: category, - Title: title, - Body: body, - Link: link, - Metadata: meta, - }) - if cerr != nil || created == nil { - return + + // In-app: persist the feed row + push the realtime event. + if cat.Channels.InApp { + created, cerr := s.repo.Create(ctx, &models.Notification{ + UserID: userID, + OrganizationID: orgID, + Category: category, + Title: title, + Body: body, + Link: link, + Metadata: meta, + }) + if cerr == nil && created != nil && s.publisher != nil { + s.publisher.PublishNotificationCreated(ctx, userID.String(), created.ID.String(), string(category), title, link) + } } - if s.publisher != nil { - s.publisher.PublishNotificationCreated(ctx, userID.String(), created.ID.String(), string(category), title, link) + + // Email: deliver to the user's account email (detached, best-effort). + if cat.Channels.Email && s.email != nil && s.users != nil { + go s.deliverEmail(userID, category, title, body, link) + } + + // Slack: post to the org's connected workspace (detached, best-effort). + if cat.Channels.Slack && s.slack != nil && orgID != nil { + org := *orgID + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) + defer cancel() + _ = s.slack.NotifySlack(ctx, org, title, body) + }() } } + +// deliverEmail renders a minimal HTML notification and emails it to the user. +func (s *service) deliverEmail(userID uuid.UUID, category models.NotificationCategory, title, body, link string) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + user, err := s.users.GetUser(ctx, userID) + if err != nil || user == nil || user.Email == "" { + return + } + href := link + if href != "" && href[0] == '/' { + base := strings.TrimRight(os.Getenv("APP_URL"), "/") + if base == "" { + base = "https://app.warmbly.com" + } + href = base + href + } + cta := "" + if href != "" { + cta = fmt.Sprintf(`

Open in Warmbly

`, href) + } + html := fmt.Sprintf(`

%s

%s

%s

You're receiving this because email notifications are on for %s. Manage them in Settings → Notifications.

`, + htmlEscape(title), htmlEscape(body), cta, htmlEscape(string(category))) + _ = s.email.Send(ctx, []string{user.Email}, nil, nil, title, html) +} + +func htmlEscape(s string) string { + r := strings.NewReplacer("&", "&", "<", "<", ">", ">", `"`, """) + return r.Replace(s) +} diff --git a/internal/app/notification/signin.go b/internal/app/notification/signin.go new file mode 100644 index 00000000..98218420 --- /dev/null +++ b/internal/app/notification/signin.go @@ -0,0 +1,39 @@ +package notification + +import ( + "context" + "strings" + + "github.com/google/uuid" + + "github.com/warmbly/warmbly/internal/models" +) + +// SignInAlerter adapts the notification service to the token service's +// new-device hook: a new sign-in becomes a security notification (in-app + +// email when the user has those channels on). Satisfies token.SignInAlerter. +type SignInAlerter struct { + svc Service +} + +// NewSignInAlerter wraps a notification service for the token package. +func NewSignInAlerter(svc Service) *SignInAlerter { return &SignInAlerter{svc: svc} } + +// NewSignIn raises a "new device" security notification for the user. +func (a *SignInAlerter) NewSignIn(ctx context.Context, userID uuid.UUID, browser, os, city, country string) { + if a == nil || a.svc == nil { + return + } + device := strings.TrimSpace(browser + " on " + os) + if device == "on" { + device = "an unrecognized device" + } + loc := strings.Trim(strings.TrimSpace(city+", "+country), ", ") + body := "Signed in from " + device + if loc != "" { + body += " (" + loc + ")" + } + body += ". If this wasn't you, change your password and sign out other sessions." + a.svc.Notify(ctx, userID, nil, models.NotifSecuritySignIn, + "New sign-in to your account", body, "/app/settings/security", nil) +} diff --git a/internal/app/oauth/errors.go b/internal/app/oauth/errors.go new file mode 100644 index 00000000..2e3ab52c --- /dev/null +++ b/internal/app/oauth/errors.go @@ -0,0 +1,45 @@ +package oauth + +import "net/http" + +// OAuthError is a standard RFC 6749 / OAuth 2.1 error: a stable machine code plus +// a human description, rendered as {"error":...,"error_description":...}. +type OAuthError struct { + Code string `json:"error"` + Description string `json:"error_description,omitempty"` + HTTPStatus int `json:"-"` +} + +func (e *OAuthError) Error() string { return e.Code + ": " + e.Description } + +func newOAuthError(status int, code, desc string) *OAuthError { + return &OAuthError{Code: code, Description: desc, HTTPStatus: status} +} + +// The error constructors below cover every code the authorize + token endpoints +// can emit. Status codes follow RFC 6749 §5.2 (invalid_client is 401, the rest +// of the request errors are 400). +func errInvalidRequest(desc string) *OAuthError { + return newOAuthError(http.StatusBadRequest, "invalid_request", desc) +} +func errInvalidClient(desc string) *OAuthError { + return newOAuthError(http.StatusUnauthorized, "invalid_client", desc) +} +func errInvalidGrant(desc string) *OAuthError { + return newOAuthError(http.StatusBadRequest, "invalid_grant", desc) +} +func errUnauthorizedClient(desc string) *OAuthError { + return newOAuthError(http.StatusBadRequest, "unauthorized_client", desc) +} +func errUnsupportedGrantType(desc string) *OAuthError { + return newOAuthError(http.StatusBadRequest, "unsupported_grant_type", desc) +} +func errInvalidScope(desc string) *OAuthError { + return newOAuthError(http.StatusBadRequest, "invalid_scope", desc) +} +func errAccessDenied(desc string) *OAuthError { + return newOAuthError(http.StatusForbidden, "access_denied", desc) +} +func errServer(desc string) *OAuthError { + return newOAuthError(http.StatusInternalServerError, "server_error", desc) +} diff --git a/internal/app/oauth/flow.go b/internal/app/oauth/flow.go new file mode 100644 index 00000000..e8e048b2 --- /dev/null +++ b/internal/app/oauth/flow.go @@ -0,0 +1,316 @@ +package oauth + +import ( + "context" + "fmt" + "net/url" + "strings" + "time" + + "github.com/google/uuid" + + "github.com/warmbly/warmbly/internal/models" +) + +// AuthorizeRequest holds the (un-trusted) /authorize parameters. +type AuthorizeRequest struct { + ResponseType string + ClientID string + RedirectURI string + Scope string + State string + CodeChallenge string + CodeChallengeMethod string +} + +// ConsentInfo is what the dashboard consent screen renders: who is asking, for +// what, and where they'll be sent back. +type ConsentInfo struct { + ClientID string `json:"client_id"` + Name string `json:"name"` + Description string `json:"description"` + LogoURL string `json:"logo_url"` + WebsiteURL string `json:"website_url"` + RedirectURI string `json:"redirect_uri"` + Scopes []string `json:"scopes"` + State string `json:"state"` +} + +// TokenResponse is the /token success body (RFC 6749 §5.1). +type TokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + RefreshToken string `json:"refresh_token,omitempty"` + Scope string `json:"scope"` +} + +// AccessClaims is what a validated access token resolves to, for the middleware. +type AccessClaims struct { + GrantID uuid.UUID + ApplicationID uuid.UUID + OrganizationID uuid.UUID + UserID uuid.UUID + Scopes uint64 +} + +// AuthorizeDetails validates the authorize request and returns consent info, or +// an *OAuthError describing what's wrong with the client/redirect/scope. +func (s *Service) AuthorizeDetails(ctx context.Context, req AuthorizeRequest) (*ConsentInfo, error) { + app, scopes, err := s.validateAuthorize(ctx, req) + if err != nil { + return nil, err + } + return &ConsentInfo{ + ClientID: app.ClientID, + Name: app.Name, + Description: app.Description, + LogoURL: app.LogoURL, + WebsiteURL: app.WebsiteURL, + RedirectURI: req.RedirectURI, + Scopes: ScopeList(scopes), + State: req.State, + }, nil +} + +// IssueAuthorizationCode is called after the user approves consent. It re-checks +// the request, mints a single-use PKCE-bound code for this user+org, and returns +// the redirect URL (redirect_uri?code=...&state=...) for the browser to follow. +func (s *Service) IssueAuthorizationCode(ctx context.Context, orgID, userID uuid.UUID, req AuthorizeRequest) (string, error) { + app, scopes, err := s.validateAuthorize(ctx, req) + if err != nil { + return "", err + } + code, err := randomToken(models.OAuthCodePrefix) + if err != nil { + return "", errServer("could not mint code") + } + method := "" + if strings.TrimSpace(req.CodeChallenge) != "" { + method = "S256" + } + ac := &models.OAuthAuthorizationCode{ + CodeHash: hashToken(code), + ApplicationID: app.ID, + OrganizationID: orgID, + UserID: userID, + RedirectURI: req.RedirectURI, + Scopes: scopes, + CodeChallenge: req.CodeChallenge, + CodeChallengeMethod: method, + ExpiresAt: time.Now().UTC().Add(models.OAuthAuthorizationCodeTTL), + } + if err := s.repo.CreateAuthorizationCode(ctx, ac); err != nil { + return "", errServer("could not store code") + } + return buildRedirect(req.RedirectURI, code, req.State), nil +} + +// validateAuthorize enforces response_type=code, a known active client, an exact +// redirect match, mandatory PKCE (S256), and a scope set within the app's grant. +func (s *Service) validateAuthorize(ctx context.Context, req AuthorizeRequest) (*models.OAuthApplication, uint64, error) { + if req.ResponseType != "code" { + return nil, 0, errInvalidRequest("response_type must be 'code'") + } + app, err := s.repo.GetApplicationByClientID(ctx, strings.TrimSpace(req.ClientID)) + if err != nil { + return nil, 0, errServer("client lookup failed") + } + if app == nil || app.Status != models.OAuthAppActive { + return nil, 0, errUnauthorizedClient("unknown or disabled client") + } + if !redirectAllowed(app, req.RedirectURI) { + return nil, 0, errInvalidRequest("redirect_uri does not match a registered URI") + } + // PKCE is an optional extra layer; when a challenge is sent we only accept S256. + if strings.TrimSpace(req.CodeChallenge) != "" && req.CodeChallengeMethod != "S256" { + return nil, 0, errInvalidRequest("code_challenge_method must be 'S256'") + } + mask, unknown := ParseScopes(req.Scope) + if len(unknown) > 0 { + return nil, 0, errInvalidScope("unknown scope: " + strings.Join(unknown, " ")) + } + if mask == 0 { + mask = app.Scopes // default to the app's full registered scope set + } + if mask&^app.Scopes != 0 { + return nil, 0, errInvalidScope("requested scope exceeds what this app may request") + } + return app, mask, nil +} + +// ExchangeCode handles grant_type=authorization_code: authenticate the client, +// atomically consume the code, verify redirect + PKCE, and issue tokens. +func (s *Service) ExchangeCode(ctx context.Context, clientID, clientSecret, code, redirectURI, codeVerifier string) (*TokenResponse, error) { + app, err := s.authenticateClient(ctx, clientID, clientSecret) + if err != nil { + return nil, err + } + ac, err := s.repo.TakeAuthorizationCode(ctx, hashToken(code)) + if err != nil { + return nil, errServer("code lookup failed") + } + if ac == nil { + return nil, errInvalidGrant("invalid or expired authorization code") + } + if ac.ApplicationID != app.ID { + return nil, errInvalidGrant("authorization code was issued to a different client") + } + if ac.RedirectURI != redirectURI { + return nil, errInvalidGrant("redirect_uri does not match the authorization request") + } + // If the authorize request bound a PKCE challenge, the verifier must match. + if ac.CodeChallenge != "" && !verifyPKCE(codeVerifier, ac.CodeChallenge) { + return nil, errInvalidGrant("PKCE verification failed") + } + return s.issueGrant(ctx, app.ID, ac.OrganizationID, ac.UserID, ac.Scopes) +} + +// RefreshToken handles grant_type=refresh_token with rotation: the presented +// refresh token is consumed and a fresh access+refresh pair issued. +func (s *Service) RefreshToken(ctx context.Context, clientID, clientSecret, refreshToken string) (*TokenResponse, error) { + app, err := s.authenticateClient(ctx, clientID, clientSecret) + if err != nil { + return nil, err + } + g, err := s.repo.GetGrantByRefreshTokenHash(ctx, hashToken(refreshToken)) + if err != nil { + return nil, errServer("token lookup failed") + } + if g == nil || g.RevokedAt != nil || g.ApplicationID != app.ID { + return nil, errInvalidGrant("invalid refresh token") + } + if g.RefreshExpiresAt != nil && g.RefreshExpiresAt.Before(time.Now().UTC()) { + return nil, errInvalidGrant("refresh token expired") + } + access, err := randomToken(models.OAuthAccessTokenPrefix) + if err != nil { + return nil, errServer("could not mint token") + } + refresh, err := randomToken(models.OAuthRefreshTokenPrefix) + if err != nil { + return nil, errServer("could not mint token") + } + accessExp := time.Now().UTC().Add(models.OAuthAccessTokenTTL) + refreshExp := time.Now().UTC().Add(models.OAuthRefreshTokenTTL) + if err := s.repo.RotateGrantTokens(ctx, g.ID, hashToken(access), hashToken(refresh), accessExp, &refreshExp); err != nil { + return nil, errServer("could not rotate token") + } + return &TokenResponse{ + AccessToken: access, + TokenType: "Bearer", + ExpiresIn: int(models.OAuthAccessTokenTTL.Seconds()), + RefreshToken: refresh, + Scope: ScopeString(g.Scopes), + }, nil +} + +// RevokeToken revokes the grant behind an access or refresh token (RFC 7009). +// Per spec it succeeds even for an unknown token. +func (s *Service) RevokeToken(ctx context.Context, clientID, clientSecret, token string) error { + app, err := s.authenticateClient(ctx, clientID, clientSecret) + if err != nil { + return err + } + return s.repo.RevokeGrantByTokenHash(ctx, app.ID, hashToken(token)) +} + +// ValidateAccessToken resolves a bearer access token to its grant if the grant +// is active and unexpired. Used by the request-auth middleware. +func (s *Service) ValidateAccessToken(ctx context.Context, token string) (*AccessClaims, error) { + if !strings.HasPrefix(token, models.OAuthAccessTokenPrefix) { + return nil, fmt.Errorf("not an oauth access token") + } + g, err := s.repo.GetGrantByAccessTokenHash(ctx, hashToken(token)) + if err != nil { + return nil, err + } + if g == nil || g.RevokedAt != nil || g.AccessExpiresAt.Before(time.Now().UTC()) { + return nil, fmt.Errorf("invalid or expired access token") + } + return &AccessClaims{ + GrantID: g.ID, + ApplicationID: g.ApplicationID, + OrganizationID: g.OrganizationID, + UserID: g.UserID, + Scopes: g.Scopes, + }, nil +} + +// issueGrant mints and stores a new access+refresh token pair. +func (s *Service) issueGrant(ctx context.Context, appID, orgID, userID uuid.UUID, scopes uint64) (*TokenResponse, error) { + access, err := randomToken(models.OAuthAccessTokenPrefix) + if err != nil { + return nil, errServer("could not mint token") + } + refresh, err := randomToken(models.OAuthRefreshTokenPrefix) + if err != nil { + return nil, errServer("could not mint token") + } + refreshExp := time.Now().UTC().Add(models.OAuthRefreshTokenTTL) + g := &models.OAuthAccessGrant{ + ApplicationID: appID, + OrganizationID: orgID, + UserID: userID, + Scopes: scopes, + AccessTokenHash: hashToken(access), + RefreshTokenHash: hashToken(refresh), + AccessExpiresAt: time.Now().UTC().Add(models.OAuthAccessTokenTTL), + RefreshExpiresAt: &refreshExp, + } + if err := s.repo.CreateAccessGrant(ctx, g); err != nil { + return nil, errServer("could not store grant") + } + return &TokenResponse{ + AccessToken: access, + TokenType: "Bearer", + ExpiresIn: int(models.OAuthAccessTokenTTL.Seconds()), + RefreshToken: refresh, + Scope: ScopeString(scopes), + }, nil +} + +// authenticateClient resolves and authenticates the OAuth client. Every app has +// a client secret, so a matching secret is always required. +func (s *Service) authenticateClient(ctx context.Context, clientID, clientSecret string) (*models.OAuthApplication, error) { + app, err := s.repo.GetApplicationByClientID(ctx, strings.TrimSpace(clientID)) + if err != nil { + return nil, errServer("client lookup failed") + } + if app == nil || app.Status != models.OAuthAppActive { + return nil, errInvalidClient("unknown or disabled client") + } + if clientSecret == "" || app.ClientSecretHash == "" || hashToken(clientSecret) != app.ClientSecretHash { + return nil, errInvalidClient("invalid client credentials") + } + return app, nil +} + +// redirectAllowed does an exact-string match against the app's registered URIs. +func redirectAllowed(app *models.OAuthApplication, uri string) bool { + uri = strings.TrimSpace(uri) + if uri == "" { + return false + } + for _, r := range app.RedirectURIs { + if r == uri { + return true + } + } + return false +} + +// buildRedirect appends ?code=&state= to the (already-validated) redirect URI. +func buildRedirect(redirectURI, code, state string) string { + u, err := url.Parse(redirectURI) + if err != nil { + return redirectURI + } + q := u.Query() + q.Set("code", code) + if state != "" { + q.Set("state", state) + } + u.RawQuery = q.Encode() + return u.String() +} diff --git a/internal/app/oauth/scopes.go b/internal/app/oauth/scopes.go new file mode 100644 index 00000000..2cc6d436 --- /dev/null +++ b/internal/app/oauth/scopes.go @@ -0,0 +1,67 @@ +package oauth + +import ( + "sort" + "strings" + + "github.com/warmbly/warmbly/internal/models" +) + +// OAuth scope strings are the lowercased API-permission names (READ_EMAILS -> +// "read_emails"), so a token's granted scopes ARE an API-permission bitmask and +// flow through the exact same route gates as an API key. One vocabulary, no +// remapping. + +var ( + scopeToBit = map[string]uint64{} + bitToScope = map[uint64]string{} +) + +func init() { + for _, p := range models.AllAPIPermissions { + s := strings.ToLower(p.Name) + scopeToBit[s] = p.Value + bitToScope[p.Value] = s + } +} + +// ScopeString turns a permission bitmask into a stable, sorted, space-separated +// scope string (the form returned in token responses and shown on consent). +func ScopeString(mask uint64) string { + out := make([]string, 0, len(bitToScope)) + for bit, s := range bitToScope { + if mask&bit == bit { + out = append(out, s) + } + } + sort.Strings(out) + return strings.Join(out, " ") +} + +// ParseScopes parses a space-separated scope string into a bitmask plus the list +// of tokens it didn't recognize (so the caller can reject an invalid_scope). +func ParseScopes(raw string) (uint64, []string) { + var mask uint64 + var unknown []string + for _, s := range strings.Fields(raw) { + s = strings.ToLower(strings.TrimSpace(s)) + if s == "" { + continue + } + if bit, ok := scopeToBit[s]; ok { + mask |= bit + } else { + unknown = append(unknown, s) + } + } + return mask, unknown +} + +// ScopeList expands a bitmask into its individual scope strings (for the consent +// screen, which lists each permission being requested). +func ScopeList(mask uint64) []string { + if mask == 0 { + return []string{} + } + return strings.Fields(ScopeString(mask)) +} diff --git a/internal/app/oauth/service.go b/internal/app/oauth/service.go new file mode 100644 index 00000000..23c2afe6 --- /dev/null +++ b/internal/app/oauth/service.go @@ -0,0 +1,208 @@ +// Package oauth implements Warmbly's OAuth2 authorization server: third-party +// app registration, the authorization-code flow (client secret required, PKCE +// optional), token issue/refresh/revoke, and bearer-token validation. Issued +// access tokens carry an API-permission bitmask, so they authenticate through the +// same route gates as API keys. +package oauth + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "fmt" + "net/url" + "strings" + + "github.com/google/uuid" + + "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/repository" +) + +// Service is the OAuth authorization server. +type Service struct { + repo repository.OAuthRepository +} + +func NewService(repo repository.OAuthRepository) *Service { + return &Service{repo: repo} +} + +// --- credential helpers (mirror the api_key hash-at-rest scheme) --- + +func randomToken(prefix string) (string, error) { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return prefix + base64.RawURLEncoding.EncodeToString(buf), nil +} + +func hashToken(t string) string { + sum := sha256.Sum256([]byte(t)) + return hex.EncodeToString(sum[:]) +} + +// verifyPKCE checks the S256 transform: base64url(sha256(verifier)) == challenge. +func verifyPKCE(verifier, challenge string) bool { + if verifier == "" || challenge == "" { + return false + } + sum := sha256.Sum256([]byte(verifier)) + return base64.RawURLEncoding.EncodeToString(sum[:]) == challenge +} + +// --- application management --- + +// RegisterApplication creates a new OAuth client. Every app is issued a client +// secret, returned exactly once here. +func (s *Service) RegisterApplication(ctx context.Context, orgID, userID uuid.UUID, w models.OAuthApplicationWrite) (*models.OAuthApplicationWithSecret, error) { + name := strings.TrimSpace(w.Name) + if name == "" { + return nil, fmt.Errorf("a name is required") + } + uris, err := validateRedirectURIs(w.RedirectURIs) + if err != nil { + return nil, err + } + scopes := w.Scopes & models.AllAPIPermissionsMask + if scopes == 0 { + return nil, fmt.Errorf("select at least one scope") + } + clientID, err := randomToken(models.OAuthClientIDPrefix) + if err != nil { + return nil, err + } + app := &models.OAuthApplication{ + OrganizationID: orgID, + CreatedBy: userID, + Name: name, + Description: strings.TrimSpace(w.Description), + LogoURL: strings.TrimSpace(w.LogoURL), + WebsiteURL: strings.TrimSpace(w.WebsiteURL), + ClientID: clientID, + RedirectURIs: uris, + Scopes: scopes, + Status: models.OAuthAppActive, + } + secret, err := randomToken(models.OAuthClientSecretPrefix) + if err != nil { + return nil, err + } + app.ClientSecretHash = hashToken(secret) + if err := s.repo.CreateApplication(ctx, app); err != nil { + return nil, err + } + return &models.OAuthApplicationWithSecret{OAuthApplication: *app, ClientSecret: secret}, nil +} + +func (s *Service) ListApplications(ctx context.Context, orgID uuid.UUID) ([]models.OAuthApplication, error) { + return s.repo.ListApplications(ctx, orgID) +} + +func (s *Service) GetApplication(ctx context.Context, orgID, id uuid.UUID) (*models.OAuthApplication, error) { + return s.repo.GetApplication(ctx, orgID, id) +} + +// UpdateApplication edits an app's display fields, redirect URIs, scopes, and +// status. client_id and the secret are immutable here (rotate the secret +// separately). +func (s *Service) UpdateApplication(ctx context.Context, orgID, id uuid.UUID, w models.OAuthApplicationWrite) (*models.OAuthApplication, error) { + app, err := s.repo.GetApplication(ctx, orgID, id) + if err != nil { + return nil, err + } + if app == nil { + return nil, fmt.Errorf("application not found") + } + name := strings.TrimSpace(w.Name) + if name == "" { + return nil, fmt.Errorf("a name is required") + } + uris, err := validateRedirectURIs(w.RedirectURIs) + if err != nil { + return nil, err + } + scopes := w.Scopes & models.AllAPIPermissionsMask + if scopes == 0 { + return nil, fmt.Errorf("select at least one scope") + } + app.Name = name + app.Description = strings.TrimSpace(w.Description) + app.LogoURL = strings.TrimSpace(w.LogoURL) + app.WebsiteURL = strings.TrimSpace(w.WebsiteURL) + app.RedirectURIs = uris + app.Scopes = scopes + if err := s.repo.UpdateApplication(ctx, app); err != nil { + return nil, err + } + return s.repo.GetApplication(ctx, orgID, id) +} + +// RotateSecret mints a new client secret (returned once) and invalidates the old +// one. +func (s *Service) RotateSecret(ctx context.Context, orgID, id uuid.UUID) (string, error) { + app, err := s.repo.GetApplication(ctx, orgID, id) + if err != nil { + return "", err + } + if app == nil { + return "", fmt.Errorf("application not found") + } + secret, err := randomToken(models.OAuthClientSecretPrefix) + if err != nil { + return "", err + } + if err := s.repo.UpdateApplicationSecret(ctx, orgID, id, hashToken(secret)); err != nil { + return "", err + } + return secret, nil +} + +func (s *Service) DeleteApplication(ctx context.Context, orgID, id uuid.UUID) error { + return s.repo.DeleteApplication(ctx, orgID, id) +} + +func (s *Service) ListAuthorizedApps(ctx context.Context, orgID, userID uuid.UUID) ([]models.OAuthAuthorizedApp, error) { + return s.repo.ListAuthorizedApps(ctx, orgID, userID) +} + +func (s *Service) RevokeAuthorization(ctx context.Context, orgID, userID, appID uuid.UUID) error { + return s.repo.RevokeAuthorization(ctx, orgID, userID, appID) +} + +// validateRedirectURIs enforces the OAuth 2.1 redirect rules: present, absolute, +// HTTPS (loopback HTTP allowed for native apps), and no fragment. +func validateRedirectURIs(uris []string) ([]string, error) { + out := make([]string, 0, len(uris)) + seen := map[string]bool{} + for _, u := range uris { + u = strings.TrimSpace(u) + if u == "" || seen[u] { + continue + } + parsed, err := url.Parse(u) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return nil, fmt.Errorf("invalid redirect URI: %s", u) + } + if parsed.Fragment != "" { + return nil, fmt.Errorf("a redirect URI must not contain a fragment: %s", u) + } + if parsed.Scheme != "https" && !isLoopbackRedirect(parsed) { + return nil, fmt.Errorf("a redirect URI must use https: %s", u) + } + seen[u] = true + out = append(out, u) + } + if len(out) == 0 { + return nil, fmt.Errorf("at least one redirect URI is required") + } + return out, nil +} + +func isLoopbackRedirect(u *url.URL) bool { + h := u.Hostname() + return u.Scheme == "http" && (h == "127.0.0.1" || h == "::1" || h == "localhost") +} diff --git a/internal/app/organization/service.go b/internal/app/organization/service.go index 86bcf8ed..8ec57361 100644 --- a/internal/app/organization/service.go +++ b/internal/app/organization/service.go @@ -13,6 +13,7 @@ import ( "github.com/warmbly/warmbly/internal/config" "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/pkg/crypt" "github.com/warmbly/warmbly/internal/repository" ) @@ -42,7 +43,16 @@ type OrganizationService interface { GetMembership(ctx context.Context, orgID, userID uuid.UUID) (*models.OrganizationMember, *errx.Error) InviteMember(ctx context.Context, orgID uuid.UUID, inviterID uuid.UUID, req *models.InviteMemberRequest) (*models.OrganizationInvitation, *errx.Error) AcceptInvitation(ctx context.Context, token string, userID uuid.UUID, email string) (*models.OrganizationMember, *errx.Error) - UpdateMemberRole(ctx context.Context, orgID, memberUserID uuid.UUID, req *models.UpdateMemberRequest) (*models.OrganizationMember, *errx.Error) + AcceptInvitationByID(ctx context.Context, invitationID, userID uuid.UUID, email string) (*models.OrganizationMember, *errx.Error) + PreviewInvitation(ctx context.Context, token string) (*models.InvitationPreview, *errx.Error) + GetInvitationToken(ctx context.Context, orgID, invitationID uuid.UUID) (string, *errx.Error) + + // Custom roles + ListRoles(ctx context.Context, orgID uuid.UUID) ([]models.OrganizationRole, *errx.Error) + CreateRole(ctx context.Context, orgID, actorID uuid.UUID, req *models.CreateOrganizationRoleRequest) (*models.OrganizationRole, *errx.Error) + UpdateRole(ctx context.Context, orgID, actorID, roleID uuid.UUID, req *models.UpdateOrganizationRoleRequest) (*models.OrganizationRole, *errx.Error) + DeleteRole(ctx context.Context, orgID, actorID, roleID uuid.UUID) *errx.Error + UpdateMemberRole(ctx context.Context, orgID, actorID, memberUserID uuid.UUID, req *models.UpdateMemberRequest) (*models.OrganizationMember, *errx.Error) RemoveMember(ctx context.Context, orgID, memberUserID uuid.UUID) *errx.Error // Invitations @@ -205,6 +215,22 @@ func (s *organizationService) Create(ctx context.Context, userID uuid.UUID, name return nil, errx.New(errx.Internal, "failed to add owner member") } + // Seed the default roles (Admin/Manager/Viewer). Ordinary rows from here + // on: the owner can rename, reshape, or delete them. Best-effort — a + // failure leaves a usable org where roles can be created manually. + for _, seed := range models.DefaultSeedRoles() { + if err := s.orgRepo.CreateRole(ctx, &models.OrganizationRole{ + ID: uuid.New(), + OrganizationID: org.ID, + Name: seed.Name, + Description: seed.Description, + Color: seed.Color, + Permissions: seed.Permissions, + }); err != nil { + sentry.CaptureException(err) + } + } + return org, nil } @@ -256,6 +282,12 @@ func (s *organizationService) Update(ctx context.Context, orgID uuid.UUID, req * } org.Slug = req.Slug } + if req.PresenceShowOnline != nil { + org.PresenceShowOnline = *req.PresenceShowOnline + } + if req.PresenceShowActivity != nil { + org.PresenceShowActivity = *req.PresenceShowActivity + } org.UpdatedAt = time.Now() @@ -303,6 +335,9 @@ func (s *organizationService) GetMembers(ctx context.Context, orgID uuid.UUID) ( sentry.CaptureException(err) return nil, errx.New(errx.Internal, "failed to get members") } + if err := s.orgRepo.HydrateMemberRoles(ctx, orgID, members); err != nil { + sentry.CaptureException(err) + } return members, nil } @@ -331,23 +366,25 @@ func (s *organizationService) InviteMember(ctx context.Context, orgID uuid.UUID, // First, we need to check if there's already a user with this email // For now, we'll just create the invitation - // Determine role and permissions - role := string(models.RoleViewer) - if req.Role != "" && models.IsValidRole(req.Role) { - role = req.Role + // Roles are data rows: every invite lands in one or more, snapshotting + // the effective (OR) permissions + primary name onto the invitation. + roleIDs, roles, permissions, xerr := s.resolveRoleSet(ctx, orgID, req.Resolved()) + if xerr != nil { + return nil, xerr + } + // Assignment is an escalation surface: the inviter must hold every + // permission the role set grants (the owner's full mask passes trivially). + if xerr := s.validateActorHoldsPermissions(ctx, orgID, inviterID, permissions); xerr != nil { + return nil, xerr } - var permissions models.OrganizationPermission - if req.Permissions != nil { - permissions = models.OrganizationPermission(*req.Permissions) - } else { - permissions = models.GetRolePermissions(models.Role(role)) - } + role := roles[0].Name + roleID := &roles[0].ID // Generate invitation token - token, xerr := generateInvitationToken() - if xerr != nil { - sentry.CaptureException(xerr) + token, tokErr := generateInvitationToken() + if tokErr != nil { + sentry.CaptureException(tokErr) return nil, errx.New(errx.Internal, "failed to generate invitation token") } @@ -356,6 +393,7 @@ func (s *organizationService) InviteMember(ctx context.Context, orgID uuid.UUID, OrganizationID: orgID, Email: strings.ToLower(req.Email), Role: role, + RoleID: roleID, Permissions: permissions, InvitedBy: inviterID, Token: token, @@ -367,10 +405,47 @@ func (s *organizationService) InviteMember(ctx context.Context, orgID uuid.UUID, sentry.CaptureException(err) return nil, errx.New(errx.Internal, "failed to create invitation") } + if err := s.orgRepo.SetInvitationRoles(ctx, inv.ID, roleIDs); err != nil { + sentry.CaptureException(err) + return nil, errx.New(errx.Internal, "failed to attach roles") + } + inv.Roles = toMemberRoles(roles) return inv, nil } +// resolveRoleSet loads + validates a set of org role ids, returning the +// deduped ids, the role rows, and the effective (OR) permission mask. At +// least one valid role is required. +func (s *organizationService) resolveRoleSet(ctx context.Context, orgID uuid.UUID, ids []uuid.UUID) ([]uuid.UUID, []models.OrganizationRole, models.OrganizationPermission, *errx.Error) { + if len(ids) == 0 { + return nil, nil, 0, errx.New(errx.BadRequest, "at least one role is required") + } + var roles []models.OrganizationRole + var perms models.OrganizationPermission + for _, id := range ids { + role, err := s.orgRepo.GetRoleByID(ctx, orgID, id) + if err != nil { + sentry.CaptureException(err) + return nil, nil, 0, errx.New(errx.Internal, "failed to load role") + } + if role == nil { + return nil, nil, 0, errx.New(errx.BadRequest, "role not found") + } + roles = append(roles, *role) + perms |= role.Permissions + } + return ids, roles, perms, nil +} + +func toMemberRoles(roles []models.OrganizationRole) []models.MemberRole { + out := make([]models.MemberRole, 0, len(roles)) + for _, r := range roles { + out = append(out, models.MemberRole{ID: r.ID, Name: r.Name, Color: r.Color}) + } + return out +} + // AcceptInvitation accepts an invitation and adds the user as a member func (s *organizationService) AcceptInvitation(ctx context.Context, token string, userID uuid.UUID, email string) (*models.OrganizationMember, *errx.Error) { inv, err := s.orgRepo.GetInvitationByToken(ctx, token) @@ -381,6 +456,69 @@ func (s *organizationService) AcceptInvitation(ctx context.Context, token string if inv == nil { return nil, errx.New(errx.NotFound, "invitation not found") } + return s.acceptResolved(ctx, inv, userID, email) +} + +// AcceptInvitationByID accepts an invitation the logged-in user found in their +// own pending list (no token needed; the email-match check still gates it). +func (s *organizationService) AcceptInvitationByID(ctx context.Context, invitationID, userID uuid.UUID, email string) (*models.OrganizationMember, *errx.Error) { + inv, err := s.orgRepo.GetInvitationByID(ctx, invitationID) + if err != nil { + sentry.CaptureException(err) + return nil, errx.New(errx.Internal, "failed to get invitation") + } + if inv == nil { + return nil, errx.New(errx.NotFound, "invitation not found") + } + return s.acceptResolved(ctx, inv, userID, email) +} + +// PreviewInvitation returns the safe public view for the /invite landing page. +func (s *organizationService) PreviewInvitation(ctx context.Context, token string) (*models.InvitationPreview, *errx.Error) { + inv, err := s.orgRepo.GetInvitationByToken(ctx, token) + if err != nil { + sentry.CaptureException(err) + return nil, errx.New(errx.Internal, "failed to get invitation") + } + if inv == nil { + return nil, errx.New(errx.NotFound, "invitation not found") + } + preview := &models.InvitationPreview{ + Email: inv.Email, + Expired: inv.IsExpired(), + } + if inv.Organization != nil { + preview.OrganizationName = inv.Organization.Name + if inv.Organization.AvatarURL != nil { + preview.OrganizationAvatar = *inv.Organization.AvatarURL + } + } + if inviter, _ := s.userRepo.GetUser(ctx, inv.InvitedBy); inviter != nil { + preview.InviterName = strings.TrimSpace(inviter.FirstName + " " + inviter.LastName) + } + list := []models.OrganizationInvitation{*inv} + if err := s.orgRepo.HydrateInvitationRoles(ctx, list); err == nil { + preview.Roles = list[0].Roles + } + return preview, nil +} + +// GetInvitationToken returns the secure token for one of the org's pending +// invitations, so a team manager can copy a shareable /invite link. +func (s *organizationService) GetInvitationToken(ctx context.Context, orgID, invitationID uuid.UUID) (string, *errx.Error) { + inv, err := s.orgRepo.GetInvitationByID(ctx, invitationID) + if err != nil { + sentry.CaptureException(err) + return "", errx.New(errx.Internal, "failed to get invitation") + } + if inv == nil || inv.OrganizationID != orgID { + return "", errx.New(errx.NotFound, "invitation not found") + } + return inv.Token, nil +} + +// acceptResolved performs the actual join given an already-loaded invitation. +func (s *organizationService) acceptResolved(ctx context.Context, inv *models.OrganizationInvitation, userID uuid.UUID, email string) (*models.OrganizationMember, *errx.Error) { // Verify email matches if strings.ToLower(email) != strings.ToLower(inv.Email) { @@ -401,20 +539,50 @@ func (s *organizationService) AcceptInvitation(ctx context.Context, token string return existing, nil } - // Add member + // Re-resolve the invitation's role set at accept time: snapshots may be + // stale (edited) or dangling (deleted) since the invite was sent. Roles + // deleted in the meantime are dropped; at least one must survive. + invRoleIDs, ierr := s.orgRepo.GetInvitationRoles(ctx, inv.ID) + if ierr != nil { + sentry.CaptureException(ierr) + return nil, errx.New(errx.Internal, "failed to load invitation roles") + } + var liveRoleIDs []uuid.UUID + var primary *models.OrganizationRole + for _, id := range invRoleIDs { + role, rerr := s.orgRepo.GetRoleByID(ctx, inv.OrganizationID, id) + if rerr != nil { + sentry.CaptureException(rerr) + return nil, errx.New(errx.Internal, "failed to load role") + } + if role == nil { + continue + } + if primary == nil { + primary = role + } + liveRoleIDs = append(liveRoleIDs, id) + } + if len(liveRoleIDs) == 0 { + _ = s.orgRepo.DeleteInvitation(ctx, inv.ID) + return nil, errx.New(errx.BadRequest, "the roles for this invitation no longer exist — ask for a new invite") + } + + // Add the membership row, then assign the full role set (which recomputes + // the effective permission snapshot). now := time.Now() member := &models.OrganizationMember{ ID: uuid.New(), OrganizationID: inv.OrganizationID, UserID: userID, - Role: inv.Role, - Permissions: inv.Permissions, + Role: primary.Name, + RoleID: &primary.ID, + Permissions: primary.Permissions, InvitedBy: &inv.InvitedBy, InvitedAt: inv.CreatedAt, AcceptedAt: &now, } - - if err := s.orgRepo.AddMember(ctx, member); err != nil { + if err := s.orgRepo.AddMemberWithRoles(ctx, member, liveRoleIDs); err != nil { sentry.CaptureException(err) return nil, errx.New(errx.Internal, "failed to add member") } @@ -422,11 +590,14 @@ func (s *organizationService) AcceptInvitation(ctx context.Context, token string // Delete the invitation _ = s.orgRepo.DeleteInvitation(ctx, inv.ID) + if updated, _ := s.orgRepo.GetMember(ctx, inv.OrganizationID, userID); updated != nil { + member = updated + } return member, nil } // UpdateMemberRole updates a member's role and permissions -func (s *organizationService) UpdateMemberRole(ctx context.Context, orgID, memberUserID uuid.UUID, req *models.UpdateMemberRequest) (*models.OrganizationMember, *errx.Error) { +func (s *organizationService) UpdateMemberRole(ctx context.Context, orgID, actorID, memberUserID uuid.UUID, req *models.UpdateMemberRequest) (*models.OrganizationMember, *errx.Error) { member, err := s.orgRepo.GetMember(ctx, orgID, memberUserID) if err != nil { sentry.CaptureException(err) @@ -441,31 +612,33 @@ func (s *organizationService) UpdateMemberRole(ctx context.Context, orgID, membe return nil, errx.New(errx.Forbidden, "cannot modify owner role") } - if req.Role != nil { - if !models.IsValidRole(*req.Role) { - return nil, errx.New(errx.BadRequest, "invalid role") - } - // Cannot promote to owner - if *req.Role == string(models.RoleOwner) { - return nil, errx.New(errx.Forbidden, "cannot promote to owner, use transfer ownership") - } - member.Role = *req.Role - // Update permissions to match new role unless custom permissions provided - if req.Permissions == nil { - member.Permissions = models.GetRolePermissions(models.Role(*req.Role)) - } + roleIDs, _, permissions, xerr := s.resolveRoleSet(ctx, orgID, req.Resolved()) + if xerr != nil { + return nil, xerr + } + // Assignment is an escalation surface: the actor must hold every + // permission the new role set grants, and may not re-role themselves. + if actorID == memberUserID { + return nil, errx.New(errx.Forbidden, "you cannot change your own roles") + } + if xerr := s.validateActorHoldsPermissions(ctx, orgID, actorID, permissions); xerr != nil { + return nil, xerr } - if req.Permissions != nil { - member.Permissions = models.OrganizationPermission(*req.Permissions) - } - - if err := s.orgRepo.UpdateMember(ctx, member); err != nil { + if err := s.orgRepo.SetMemberRoles(ctx, orgID, memberUserID, roleIDs); err != nil { sentry.CaptureException(err) - return nil, errx.New(errx.Internal, "failed to update member") + return nil, errx.New(errx.Internal, "failed to update roles") } - return member, nil + updated, gerr := s.orgRepo.GetMember(ctx, orgID, memberUserID) + if gerr != nil { + sentry.CaptureException(gerr) + return nil, errx.New(errx.Internal, "failed to load member") + } + if updated != nil { + updated.Roles, _ = s.orgRepo.GetMemberRoles(ctx, orgID, memberUserID) + } + return updated, nil } // RemoveMember removes a member from the organization @@ -499,6 +672,9 @@ func (s *organizationService) GetPendingInvitations(ctx context.Context, orgID u sentry.CaptureException(err) return nil, errx.New(errx.Internal, "failed to get invitations") } + if err := s.orgRepo.HydrateInvitationRoles(ctx, invitations); err != nil { + sentry.CaptureException(err) + } return invitations, nil } @@ -1119,3 +1295,163 @@ func (s *organizationService) RejectLimitRequest(ctx context.Context, id, review lr.ReviewNotes = notes return lr, nil } + +// MaxCustomRolesPerOrg caps role sprawl per workspace. +const MaxCustomRolesPerOrg = 25 + +// validateRolePermissions enforces the two safety rules for custom roles: +// transfer-ownership can never be delegated through a role, and an actor can +// only put permissions into a role that they hold themselves (no privilege +// escalation by minting a stronger role and self-assigning a teammate). +func (s *organizationService) validateRolePermissions(ctx context.Context, orgID, actorID uuid.UUID, perms models.OrganizationPermission) *errx.Error { + if perms&models.PermTransferOwnership != 0 { + return errx.New(errx.BadRequest, "custom roles cannot include ownership transfer") + } + return s.validateActorHoldsPermissions(ctx, orgID, actorID, perms) +} + +// validateActorHoldsPermissions rejects granting (via role create/edit OR +// assignment) any permission the actor does not hold themselves. +func (s *organizationService) validateActorHoldsPermissions(ctx context.Context, orgID, actorID uuid.UUID, perms models.OrganizationPermission) *errx.Error { + actor, err := s.orgRepo.GetMember(ctx, orgID, actorID) + if err != nil { + sentry.CaptureException(err) + return errx.New(errx.Internal, "failed to load member") + } + if actor == nil { + return errx.New(errx.Forbidden, "not a member") + } + if perms&^actor.Permissions != 0 { + return errx.New(errx.Forbidden, "you cannot grant permissions you do not hold") + } + return nil +} + +func validateRoleName(name string) (string, *errx.Error) { + name = strings.TrimSpace(name) + if name == "" || len(name) > 50 { + return "", errx.New(errx.BadRequest, "role name must be 1-50 characters") + } + if models.IsReservedRoleName(name) { + return "", errx.New(errx.BadRequest, "that name is reserved for a built-in role") + } + return name, nil +} + +func (s *organizationService) ListRoles(ctx context.Context, orgID uuid.UUID) ([]models.OrganizationRole, *errx.Error) { + roles, err := s.orgRepo.ListRoles(ctx, orgID) + if err != nil { + sentry.CaptureException(err) + return nil, errx.New(errx.Internal, "failed to list roles") + } + if roles == nil { + roles = []models.OrganizationRole{} + } + return roles, nil +} + +func (s *organizationService) CreateRole(ctx context.Context, orgID, actorID uuid.UUID, req *models.CreateOrganizationRoleRequest) (*models.OrganizationRole, *errx.Error) { + name, xerr := validateRoleName(req.Name) + if xerr != nil { + return nil, xerr + } + perms := models.OrganizationPermission(req.Permissions) + if xerr := s.validateRolePermissions(ctx, orgID, actorID, perms); xerr != nil { + return nil, xerr + } + + count, err := s.orgRepo.CountRoles(ctx, orgID) + if err != nil { + sentry.CaptureException(err) + return nil, errx.New(errx.Internal, "failed to count roles") + } + if count >= MaxCustomRolesPerOrg { + return nil, errx.New(errx.Forbidden, "custom role limit reached") + } + + color := strings.TrimSpace(req.Color) + if color != "" && !crypt.IsValidHexColor(color) { + return nil, errx.New(errx.BadRequest, "color must be a hex value like #0ea5e9") + } + + role := &models.OrganizationRole{ + ID: uuid.New(), + OrganizationID: orgID, + Name: name, + Description: strings.TrimSpace(req.Description), + Color: color, + Permissions: perms, + } + if err := s.orgRepo.CreateRole(ctx, role); err != nil { + // Unique (org, name) violation is the only expected failure here. + return nil, errx.New(errx.BadRequest, "a role with that name already exists") + } + return role, nil +} + +func (s *organizationService) UpdateRole(ctx context.Context, orgID, actorID, roleID uuid.UUID, req *models.UpdateOrganizationRoleRequest) (*models.OrganizationRole, *errx.Error) { + role, err := s.orgRepo.GetRoleByID(ctx, orgID, roleID) + if err != nil { + sentry.CaptureException(err) + return nil, errx.New(errx.Internal, "failed to load role") + } + if role == nil { + return nil, errx.New(errx.NotFound, "role not found") + } + + if req.Name != nil { + name, xerr := validateRoleName(*req.Name) + if xerr != nil { + return nil, xerr + } + role.Name = name + } + if req.Description != nil { + role.Description = strings.TrimSpace(*req.Description) + } + if req.Color != nil { + color := strings.TrimSpace(*req.Color) + if color != "" && !crypt.IsValidHexColor(color) { + return nil, errx.New(errx.BadRequest, "color must be a hex value like #0ea5e9") + } + role.Color = color + } + if req.Permissions != nil { + perms := models.OrganizationPermission(*req.Permissions) + if xerr := s.validateRolePermissions(ctx, orgID, actorID, perms); xerr != nil { + return nil, xerr + } + role.Permissions = perms + } + + // Write-through: assigned members pick up the new name + permissions + // atomically (their effective access changes live via the audit spine). + if err := s.orgRepo.UpdateRole(ctx, role); err != nil { + sentry.CaptureException(err) + return nil, errx.New(errx.Internal, "failed to update role") + } + return role, nil +} + +func (s *organizationService) DeleteRole(ctx context.Context, orgID, actorID, roleID uuid.UUID) *errx.Error { + // Deleting a role strips its permissions from everyone holding it, so the + // actor must hold those permissions themselves (symmetry with the grant + // paths — otherwise a team-manager could de-privilege admins by deleting + // the Admin role). + role, rerr := s.orgRepo.GetRoleByID(ctx, orgID, roleID) + if rerr != nil { + sentry.CaptureException(rerr) + return errx.New(errx.Internal, "failed to load role") + } + if role == nil { + return nil // already gone — idempotent + } + if xerr := s.validateActorHoldsPermissions(ctx, orgID, actorID, role.Permissions); xerr != nil { + return xerr + } + if err := s.orgRepo.DeleteRole(ctx, orgID, roleID); err != nil { + sentry.CaptureException(err) + return errx.New(errx.Internal, "failed to delete role") + } + return nil +} diff --git a/internal/app/token/gen.go b/internal/app/token/gen.go index 3a345cb6..2cdbb45f 100644 --- a/internal/app/token/gen.go +++ b/internal/app/token/gen.go @@ -81,6 +81,23 @@ func (s *tokenService) GenerateSessionWithOrg(ctx context.Context, userID uuid.U userAgentInfo := useragent.Parse(userAgent) + // New-device check (before inserting this session): does the user already + // have an active session from this OS+browser? Only meaningful when they + // have a prior session to compare against, so a first/fresh login is quiet. + newDevice := false + if s.signInAlert != nil { + if prior, perr := s.tokenRepository.ListSessionsByUser(ctx, userID); perr == nil && len(prior) > 0 { + seen := false + for _, p := range prior { + if p.OSName == userAgentInfo.OS && p.BrowserName == userAgentInfo.Name { + seen = true + break + } + } + newDevice = !seen + } + } + session := &models.Session{ ID: uuid.New(), UserID: userID, @@ -147,6 +164,17 @@ func (s *tokenService) GenerateSessionWithOrg(ctx context.Context, userID uuid.U return nil, errx.InternalError() } + if newDevice && s.signInAlert != nil { + alerter := s.signInAlert + uid, browser, osName := userID, session.BrowserName, session.OSName + city, country := session.LocationCity, session.LocationCountry + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) + defer cancel() + alerter.NewSignIn(ctx, uid, browser, osName, city, country) + }() + } + return &models.Token{ AccessToken: accessToken, AccessTokenExpiresAt: accessTokenExpiresAt, diff --git a/internal/app/token/service.go b/internal/app/token/service.go index 6f787687..69625af3 100644 --- a/internal/app/token/service.go +++ b/internal/app/token/service.go @@ -18,6 +18,7 @@ type TokenService interface { VerifyToken(tokenStr string) (*TokenClaims, *errx.Error) GenerateSession(ctx context.Context, userID uuid.UUID, email, ipaddr, userAgent, authProvider string) (*models.Token, *errx.Error) GenerateSessionWithOrg(ctx context.Context, userID uuid.UUID, email, ipaddr, userAgent, authProvider string, orgID *uuid.UUID) (*models.Token, *errx.Error) + WireSignInAlerter(a SignInAlerter) GetSession(ctx context.Context, sessionID uuid.UUID) (*models.Session, *errx.Error) ValidateAccessToken(ctx context.Context, accessToken string) (*models.Session, *errx.Error) RefreshToken(ctx context.Context, refreshToken string) (*models.Token, *errx.Error) @@ -40,10 +41,21 @@ type tokenService struct { tokenRepository repository.TokenRepository geo *geo.Client cache *cache.Cache + signInAlert SignInAlerter AuthSecret string } +// SignInAlerter fires a "new device" notification when a session is created +// from a device the user has not signed in from before. Satisfied by an +// adapter over the notification service; wired post-construction (nil = off). +type SignInAlerter interface { + NewSignIn(ctx context.Context, userID uuid.UUID, browser, os, city, country string) +} + +// WireSignInAlerter attaches the new-device alerter after construction. +func (s *tokenService) WireSignInAlerter(a SignInAlerter) { s.signInAlert = a } + func NewService(db *db.DB, tokenRepository repository.TokenRepository, cache *cache.Cache, geo *geo.Client, authSecret string) TokenService { return &tokenService{ db: db, diff --git a/internal/app/unibox/overview.go b/internal/app/unibox/overview.go index cfdbee45..a6f7c023 100644 --- a/internal/app/unibox/overview.go +++ b/internal/app/unibox/overview.go @@ -13,8 +13,8 @@ import ( // Overview rolls up the counts the dashboard's scope rail and top // metric strip need into one request, so the dashboard never has to // fan out N+M follow-up queries to render those panels. -func (s *uniboxService) Overview(ctx context.Context, userID uuid.UUID) (*models.UniboxOverview, *errx.Error) { - o, err := s.uniboxRepository.Overview(ctx, userID) +func (s *uniboxService) Overview(ctx context.Context, orgID, userID uuid.UUID) (*models.UniboxOverview, *errx.Error) { + o, err := s.uniboxRepository.Overview(ctx, orgID) if err != nil { sentry.CaptureException(err) return nil, errx.InternalError() diff --git a/internal/app/unibox/search.go b/internal/app/unibox/search.go index 8cbdf505..f533b89b 100644 --- a/internal/app/unibox/search.go +++ b/internal/app/unibox/search.go @@ -12,6 +12,7 @@ import ( // Search searches emails with filters func (s *uniboxService) Search( ctx context.Context, + orgID uuid.UUID, userID uuid.UUID, params *models.MailSearchParams, ) (*models.MailSearchResult, *errx.Error) { @@ -28,7 +29,7 @@ func (s *uniboxService) Search( // sender-only fast path (GetBySender) returned un-collapsed, // label-less rows, so it can't serve the stacked list anymore — the // sender filter is handled inside Search via params.Sender. - resp, err := s.uniboxRepository.Search(ctx, userID, params) + resp, err := s.uniboxRepository.Search(ctx, orgID, userID, params) if err != nil { sentry.CaptureException(err) return nil, errx.InternalError() @@ -40,10 +41,10 @@ func (s *uniboxService) Search( // GetUnseenCount returns the count of unseen emails func (s *uniboxService) GetUnseenCount( ctx context.Context, - userID uuid.UUID, + orgID uuid.UUID, emailAccountID *uuid.UUID, ) (int64, *errx.Error) { - count, err := s.uniboxRepository.GetUnseenCount(ctx, userID, emailAccountID) + count, err := s.uniboxRepository.GetUnseenCount(ctx, orgID, emailAccountID) if err != nil { sentry.CaptureException(err) return 0, errx.InternalError() diff --git a/internal/app/unibox/seen.go b/internal/app/unibox/seen.go index db782726..f6c00d19 100644 --- a/internal/app/unibox/seen.go +++ b/internal/app/unibox/seen.go @@ -18,12 +18,12 @@ func (s *uniboxService) MarkSeen(ctx context.Context, userID, emailID uuid.UUID, return nil } -func (s *uniboxService) MarkSeenBulk(ctx context.Context, userID uuid.UUID, data *models.MarkSeen) (*models.MarkSeen, *errx.Error) { +func (s *uniboxService) MarkSeenBulk(ctx context.Context, orgID uuid.UUID, data *models.MarkSeen) (*models.MarkSeen, *errx.Error) { if len(data.EmailIDs) > 500 { return nil, errx.ErrSeenMax } - if err := s.uniboxRepository.MarkSeenBulk(ctx, userID, data.EmailIDs, data.Seen); err != nil { + if err := s.uniboxRepository.MarkSeenBulk(ctx, orgID, data.EmailIDs, data.Seen); err != nil { sentry.CaptureException(err) return nil, errx.InternalError() } diff --git a/internal/app/unibox/service.go b/internal/app/unibox/service.go index ac1a6d2d..087a8777 100644 --- a/internal/app/unibox/service.go +++ b/internal/app/unibox/service.go @@ -21,6 +21,7 @@ type UniboxService interface { ) (*models.MailSearchResult, *errx.Error) Search( ctx context.Context, + orgID uuid.UUID, userID uuid.UUID, params *models.MailSearchParams, ) (*models.MailSearchResult, *errx.Error) @@ -30,16 +31,16 @@ type UniboxService interface { ) (*models.EmailMessage, *errx.Error) GetByThread( ctx context.Context, - userID, emailID uuid.UUID, + orgID, emailID uuid.UUID, threadID, limit, cursor string, ) (*models.MailSearchResult, *errx.Error) GetUnseenCount( ctx context.Context, - userID uuid.UUID, + orgID uuid.UUID, emailAccountID *uuid.UUID, ) (int64, *errx.Error) MarkSeen(ctx context.Context, userID, emailID uuid.UUID, seen bool) *errx.Error - MarkSeenBulk(ctx context.Context, userID uuid.UUID, data *models.MarkSeen) (*models.MarkSeen, *errx.Error) + MarkSeenBulk(ctx context.Context, orgID uuid.UUID, data *models.MarkSeen) (*models.MarkSeen, *errx.Error) // Snooze hides a thread until `until`. Unsnooze drops the row. Snooze(ctx context.Context, userID uuid.UUID, threadID string, until time.Time) (*models.UniboxSnooze, *errx.Error) @@ -47,7 +48,7 @@ type UniboxService interface { ListSnoozes(ctx context.Context, userID uuid.UUID) ([]models.UniboxSnooze, *errx.Error) // Overview powers the scope rail + top metric strip in one call. - Overview(ctx context.Context, userID uuid.UUID) (*models.UniboxOverview, *errx.Error) + Overview(ctx context.Context, orgID, userID uuid.UUID) (*models.UniboxOverview, *errx.Error) // Conversation labels. SetThreadLabels replaces a thread's full // label set (idempotent); ListThreadLabels reads the current set. diff --git a/internal/app/unibox/thread.go b/internal/app/unibox/thread.go index 2ef18b01..f1a46538 100644 --- a/internal/app/unibox/thread.go +++ b/internal/app/unibox/thread.go @@ -15,7 +15,7 @@ import ( // empty and the service returns up to ThreadLimitMax messages. func (s *uniboxService) GetByThread( ctx context.Context, - userID, emailID uuid.UUID, + orgID, emailID uuid.UUID, threadID, limit, cursor string, ) (*models.MailSearchResult, *errx.Error) { l := DefaultThreadLimit @@ -30,7 +30,7 @@ func (s *uniboxService) GetByThread( l = parsed } - resp, err := s.uniboxRepository.GetByThread(ctx, userID, emailID, threadID, l, cursor) + resp, err := s.uniboxRepository.GetByThread(ctx, orgID, emailID, threadID, l, cursor) if err != nil { sentry.CaptureException(err) return nil, errx.InternalError() diff --git a/internal/app/warmup/service.go b/internal/app/warmup/service.go index abfb6423..0b3ba63b 100644 --- a/internal/app/warmup/service.go +++ b/internal/app/warmup/service.go @@ -19,11 +19,11 @@ type WebhookDispatcher interface { Dispatch(ctx context.Context, orgID uuid.UUID, eventType models.WebhookEventType, data any) (uuid.UUID, error) } -// HealthRealtimePublisher pushes a health transition to the owning user's +// HealthRealtimePublisher pushes a health transition to the owning org's // realtime stream. Narrow + primitive-typed so the warmup package doesn't // import the pubsub event types. *pubsub.StreamingPublisher satisfies it. type HealthRealtimePublisher interface { - PublishAccountHealth(ctx context.Context, userID, accountID, email, prevState, newState, reason string) + PublishAccountHealth(ctx context.Context, orgID, userID, accountID, email, prevState, newState, reason string) } const ( @@ -152,7 +152,7 @@ func (s *service) dispatchHealthEvent(ctx context.Context, accountID uuid.UUID, // Realtime push to the dashboard (independent of webhooks). if s.realtime != nil { - s.realtime.PublishAccountHealth(ctx, account.UserID, accountID.String(), account.Email, string(oldState), string(newState), reason) + s.realtime.PublishAccountHealth(ctx, account.OrganizationID.String(), account.UserID, accountID.String(), account.Email, string(oldState), string(newState), reason) } if s.webhooks == nil { diff --git a/internal/app/webhook/service.go b/internal/app/webhook/service.go index 8aed68cc..35110a52 100644 --- a/internal/app/webhook/service.go +++ b/internal/app/webhook/service.go @@ -26,6 +26,7 @@ import ( "github.com/warmbly/warmbly/internal/infrastructure/cache" "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/pkg/safehttp" "github.com/warmbly/warmbly/internal/repository" ) @@ -394,7 +395,10 @@ func NewDeliveryWorker(repo repository.WebhookRepository, opts DeliveryWorkerOpt opts.PollEvery = 2 * time.Second } if opts.HTTPClient == nil { - opts.HTTPClient = &http.Client{Timeout: 15 * time.Second} + // SSRF-hardened: customer webhook URLs are user-supplied, so block delivery + // to non-public hosts at dial time (covers DNS-resolved + rebinding cases + // the literal-IP ValidateOutboundURL check on write cannot catch). + opts.HTTPClient = safehttp.Client(15 * time.Second) } return &DeliveryWorker{ repo: repo, diff --git a/internal/infrastructure/db/migrations/000040_machine_opens.down.sql b/internal/infrastructure/db/migrations/000040_machine_opens.down.sql new file mode 100644 index 00000000..1e41ec4a --- /dev/null +++ b/internal/infrastructure/db/migrations/000040_machine_opens.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE campaign_contact_progress + DROP COLUMN IF EXISTS opened_machine; diff --git a/internal/infrastructure/db/migrations/000040_machine_opens.up.sql b/internal/infrastructure/db/migrations/000040_machine_opens.up.sql new file mode 100644 index 00000000..faa04455 --- /dev/null +++ b/internal/infrastructure/db/migrations/000040_machine_opens.up.sql @@ -0,0 +1,5 @@ +-- Label automated opens (Apple Mail Privacy Protection prefetches, UA-less +-- fetchers) so analytics can separate human opens from machine opens instead +-- of silently inflating open rates. A later human open clears the flag. +ALTER TABLE campaign_contact_progress + ADD COLUMN IF NOT EXISTS opened_machine boolean NOT NULL DEFAULT false; diff --git a/internal/infrastructure/db/migrations/000041_tracked_links.down.sql b/internal/infrastructure/db/migrations/000041_tracked_links.down.sql new file mode 100644 index 00000000..f144338f --- /dev/null +++ b/internal/infrastructure/db/migrations/000041_tracked_links.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS tracked_links; diff --git a/internal/infrastructure/db/migrations/000041_tracked_links.up.sql b/internal/infrastructure/db/migrations/000041_tracked_links.up.sql new file mode 100644 index 00000000..e56b5fcc --- /dev/null +++ b/internal/infrastructure/db/migrations/000041_tracked_links.up.sql @@ -0,0 +1,16 @@ +-- Server-side click-link store. Emails carry only an opaque ticket +-- (https:///c/); the tracking service resolves the +-- destination here via the backend internal API. Nothing to forge: the +-- destination never travels inside the link, which closes the open-redirect +-- hole without any signing secret. +CREATE TABLE tracked_links ( + id uuid PRIMARY KEY, + task_id uuid NOT NULL, + campaign_id uuid NOT NULL, + destination text NOT NULL, + created_at timestamp with time zone NOT NULL DEFAULT now() +); + +-- Cleanup horizon scans + per-task lookups (analytics/debugging). +CREATE INDEX idx_tracked_links_created ON tracked_links (created_at); +CREATE INDEX idx_tracked_links_task ON tracked_links (task_id); diff --git a/internal/infrastructure/db/migrations/000042_custom_roles.down.sql b/internal/infrastructure/db/migrations/000042_custom_roles.down.sql new file mode 100644 index 00000000..cde4c70b --- /dev/null +++ b/internal/infrastructure/db/migrations/000042_custom_roles.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE organization_invitations DROP COLUMN IF EXISTS role_id; +ALTER TABLE organization_members DROP COLUMN IF EXISTS role_id; +DROP TABLE IF EXISTS organization_roles; diff --git a/internal/infrastructure/db/migrations/000042_custom_roles.up.sql b/internal/infrastructure/db/migrations/000042_custom_roles.up.sql new file mode 100644 index 00000000..b61ba5bd --- /dev/null +++ b/internal/infrastructure/db/migrations/000042_custom_roles.up.sql @@ -0,0 +1,25 @@ +-- Custom org roles: a named, org-scoped permission set members can be +-- assigned to. Effective permissions stay denormalized on +-- organization_members.permissions (write-through on assign/edit), so every +-- existing reader (Go middleware, realtime auth, API) needs no JOIN; role_id +-- exists to propagate role edits, block deleting in-use roles, and display. +CREATE TABLE organization_roles ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + name varchar(50) NOT NULL, + description text NOT NULL DEFAULT '', + permissions integer NOT NULL DEFAULT 0, + created_at timestamp with time zone NOT NULL DEFAULT now(), + updated_at timestamp with time zone NOT NULL DEFAULT now(), + UNIQUE (organization_id, name) +); + +CREATE INDEX idx_org_roles_org ON organization_roles (organization_id); + +ALTER TABLE organization_members + ADD COLUMN role_id uuid REFERENCES organization_roles(id) ON DELETE SET NULL; + +ALTER TABLE organization_invitations + ADD COLUMN role_id uuid REFERENCES organization_roles(id) ON DELETE SET NULL; + +CREATE INDEX idx_org_members_role ON organization_members (role_id) WHERE role_id IS NOT NULL; diff --git a/internal/infrastructure/db/migrations/000043_seeded_roles.down.sql b/internal/infrastructure/db/migrations/000043_seeded_roles.down.sql new file mode 100644 index 00000000..69dde4a2 --- /dev/null +++ b/internal/infrastructure/db/migrations/000043_seeded_roles.down.sql @@ -0,0 +1 @@ +ALTER TABLE organization_roles DROP COLUMN IF EXISTS color; diff --git a/internal/infrastructure/db/migrations/000043_seeded_roles.up.sql b/internal/infrastructure/db/migrations/000043_seeded_roles.up.sql new file mode 100644 index 00000000..9c783d92 --- /dev/null +++ b/internal/infrastructure/db/migrations/000043_seeded_roles.up.sql @@ -0,0 +1,30 @@ +-- Roles become pure data: every org gets seeded default roles (Admin, +-- Manager, Viewer) that are editable and deletable like any other role, and +-- members reference roles only via role_id. "Owner" stays a special status +-- on the membership row, not a role. Permission values are the defined-bit +-- bundles: Admin = all 15 defined bits minus transfer-ownership (28671), +-- Manager = operational bundle (19964), Viewer = read-only (3104). +ALTER TABLE organization_roles ADD COLUMN color varchar(7) NOT NULL DEFAULT ''; + +INSERT INTO organization_roles (id, organization_id, name, description, permissions, color) +SELECT gen_random_uuid(), o.id, d.name, d.description, d.permissions, d.color +FROM organizations o +CROSS JOIN (VALUES + ('Admin', 'Everything except transferring ownership.', 28671, '#8b5cf6'), + ('Manager', 'Runs campaigns, contacts, mailboxes, and integrations. No team, billing, or settings access.', 19964, '#10b981'), + ('Viewer', 'Read-only access to campaigns, contacts, and reports.', 3104, '#f59e0b') +) AS d(name, description, permissions, color) +ON CONFLICT (organization_id, name) DO NOTHING; + +-- Re-home existing members onto the seeded roles (owner rows stay as-is). +UPDATE organization_members om +SET role_id = r.id, role = r.name, permissions = r.permissions +FROM organization_roles r +WHERE om.role_id IS NULL + AND om.role <> 'owner' + AND r.organization_id = om.organization_id + AND r.name = CASE + WHEN om.role = 'admin' THEN 'Admin' + WHEN om.role IN ('manager', 'member') THEN 'Manager' + ELSE 'Viewer' + END; diff --git a/internal/infrastructure/db/migrations/000044_member_multi_roles.down.sql b/internal/infrastructure/db/migrations/000044_member_multi_roles.down.sql new file mode 100644 index 00000000..71ec39a9 --- /dev/null +++ b/internal/infrastructure/db/migrations/000044_member_multi_roles.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS organization_invitation_roles; +DROP TABLE IF EXISTS organization_member_roles; diff --git a/internal/infrastructure/db/migrations/000044_member_multi_roles.up.sql b/internal/infrastructure/db/migrations/000044_member_multi_roles.up.sql new file mode 100644 index 00000000..96a5fb2e --- /dev/null +++ b/internal/infrastructure/db/migrations/000044_member_multi_roles.up.sql @@ -0,0 +1,43 @@ +-- Members can hold several roles at once. Assignments live in a join table; +-- organization_members.permissions stays the denormalized effective snapshot +-- (the bitwise OR of every assigned role's permissions) so all readers (Go +-- middleware, realtime auth) remain JOIN-free. Owner is unaffected: it is a +-- membership status with no role rows and keeps its full mask. +CREATE TABLE organization_member_roles ( + organization_id uuid NOT NULL, + user_id uuid NOT NULL, + role_id uuid NOT NULL REFERENCES organization_roles(id) ON DELETE CASCADE, + created_at timestamp with time zone NOT NULL DEFAULT now(), + PRIMARY KEY (organization_id, user_id, role_id) +); +CREATE INDEX idx_member_roles_role ON organization_member_roles (role_id); +CREATE INDEX idx_member_roles_member ON organization_member_roles (organization_id, user_id); + +-- Invitations can likewise carry several roles. +CREATE TABLE organization_invitation_roles ( + invitation_id uuid NOT NULL REFERENCES organization_invitations(id) ON DELETE CASCADE, + role_id uuid NOT NULL REFERENCES organization_roles(id) ON DELETE CASCADE, + PRIMARY KEY (invitation_id, role_id) +); +CREATE INDEX idx_invitation_roles_invite ON organization_invitation_roles (invitation_id); + +-- Backfill: each member's single role_id becomes one assignment row. +INSERT INTO organization_member_roles (organization_id, user_id, role_id) +SELECT organization_id, user_id, role_id +FROM organization_members +WHERE role_id IS NOT NULL +ON CONFLICT DO NOTHING; + +INSERT INTO organization_invitation_roles (invitation_id, role_id) +SELECT id, role_id FROM organization_invitations WHERE role_id IS NOT NULL +ON CONFLICT DO NOTHING; + +-- Recompute every non-owner member's effective permission snapshot. +UPDATE organization_members om +SET permissions = COALESCE(( + SELECT bit_or(r.permissions) + FROM organization_member_roles mr + JOIN organization_roles r ON r.id = mr.role_id + WHERE mr.organization_id = om.organization_id AND mr.user_id = om.user_id +), 0) +WHERE om.role <> 'owner'; diff --git a/internal/infrastructure/db/migrations/000045_org_presence_privacy.down.sql b/internal/infrastructure/db/migrations/000045_org_presence_privacy.down.sql new file mode 100644 index 00000000..1c8fe1d2 --- /dev/null +++ b/internal/infrastructure/db/migrations/000045_org_presence_privacy.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE organizations + DROP COLUMN IF EXISTS presence_show_activity, + DROP COLUMN IF EXISTS presence_show_online; diff --git a/internal/infrastructure/db/migrations/000045_org_presence_privacy.up.sql b/internal/infrastructure/db/migrations/000045_org_presence_privacy.up.sql new file mode 100644 index 00000000..21a628b2 --- /dev/null +++ b/internal/infrastructure/db/migrations/000045_org_presence_privacy.up.sql @@ -0,0 +1,8 @@ +-- Org-wide team presence privacy controls. The realtime service reads these on +-- channel join: presence_show_online gates whether members are tracked at all +-- (who is online), and presence_show_activity gates the viewing/editing detail. +-- Both default to true so existing workspaces keep current behavior; a workspace +-- admin can turn either off from workspace settings for privacy. +ALTER TABLE organizations + ADD COLUMN presence_show_online BOOLEAN NOT NULL DEFAULT true, + ADD COLUMN presence_show_activity BOOLEAN NOT NULL DEFAULT true; diff --git a/internal/infrastructure/db/migrations/000046_automation_inbound_token.down.sql b/internal/infrastructure/db/migrations/000046_automation_inbound_token.down.sql new file mode 100644 index 00000000..408c4bd7 --- /dev/null +++ b/internal/infrastructure/db/migrations/000046_automation_inbound_token.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS idx_automations_inbound_token; +ALTER TABLE automations DROP COLUMN IF EXISTS inbound_token; diff --git a/internal/infrastructure/db/migrations/000046_automation_inbound_token.up.sql b/internal/infrastructure/db/migrations/000046_automation_inbound_token.up.sql new file mode 100644 index 00000000..13c734e2 --- /dev/null +++ b/internal/infrastructure/db/migrations/000046_automation_inbound_token.up.sql @@ -0,0 +1,10 @@ +-- Inbound webhook trigger: an automation whose trigger is "inbound.webhook" gets +-- a high-entropy token embedded in a public POST URL. An external system hitting +-- that URL runs this automation's graph with the JSON body as the event payload. +-- Token is the credential, so it is globally unique (partial index skips the +-- NULLs that every non-inbound automation carries). +ALTER TABLE automations ADD COLUMN inbound_token text; + +CREATE UNIQUE INDEX idx_automations_inbound_token + ON automations (inbound_token) + WHERE inbound_token IS NOT NULL; diff --git a/internal/infrastructure/db/migrations/000047_oauth_apps.down.sql b/internal/infrastructure/db/migrations/000047_oauth_apps.down.sql new file mode 100644 index 00000000..deb56d58 --- /dev/null +++ b/internal/infrastructure/db/migrations/000047_oauth_apps.down.sql @@ -0,0 +1,3 @@ +DROP TABLE IF EXISTS oauth_access_grants; +DROP TABLE IF EXISTS oauth_authorization_codes; +DROP TABLE IF EXISTS oauth_applications; diff --git a/internal/infrastructure/db/migrations/000047_oauth_apps.up.sql b/internal/infrastructure/db/migrations/000047_oauth_apps.up.sql new file mode 100644 index 00000000..f21d132d --- /dev/null +++ b/internal/infrastructure/db/migrations/000047_oauth_apps.up.sql @@ -0,0 +1,66 @@ +-- OAuth2 authorization server. Third-party apps register here; org members grant +-- them scoped access via the authorization-code flow (every app holds a client +-- secret, with optional PKCE on top); the issued bearer tokens authenticate API +-- calls with the SAME permission bitmask as API keys, reusing every route gate. + +-- A registered third-party application (the OAuth client). +CREATE TABLE oauth_applications ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id uuid NOT NULL REFERENCES organizations (id) ON DELETE CASCADE, + created_by uuid NOT NULL REFERENCES users (id) ON DELETE CASCADE, + name text NOT NULL, + description text NOT NULL DEFAULT '', + logo_url text NOT NULL DEFAULT '', + website_url text NOT NULL DEFAULT '', + client_id text NOT NULL UNIQUE, + -- SHA-256 of the client secret (one-way, like api_keys.key_hash). Every app + -- is issued a secret and authenticates the token exchange with it. + client_secret_hash text NOT NULL DEFAULT '', + -- Exact-match redirect URIs (no fuzzy matching). + redirect_uris text[] NOT NULL DEFAULT '{}', + -- Bitmask of the API permissions this app is allowed to request. + scopes bigint NOT NULL DEFAULT 0, + status text NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'disabled')), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX idx_oauth_applications_org ON oauth_applications (organization_id, created_at DESC); + +-- A short-lived authorization code minted on user consent, consumed once at the +-- token endpoint. Holds the PKCE challenge + the exact scopes/redirect granted. +CREATE TABLE oauth_authorization_codes ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + code_hash text NOT NULL UNIQUE, + application_id uuid NOT NULL REFERENCES oauth_applications (id) ON DELETE CASCADE, + organization_id uuid NOT NULL, + user_id uuid NOT NULL, + redirect_uri text NOT NULL, + scopes bigint NOT NULL DEFAULT 0, + -- PKCE is an optional extra layer; empty when the app did not send a challenge. + code_challenge text NOT NULL DEFAULT '', + code_challenge_method text NOT NULL DEFAULT '', + used_at timestamptz, + expires_at timestamptz NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); + +-- An issued access+refresh token pair. Tokens are stored hashed (lookup by hash, +-- like API keys); the refresh token rotates on every use. +CREATE TABLE oauth_access_grants ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + application_id uuid NOT NULL REFERENCES oauth_applications (id) ON DELETE CASCADE, + organization_id uuid NOT NULL, + user_id uuid NOT NULL, + scopes bigint NOT NULL DEFAULT 0, + access_token_hash text NOT NULL UNIQUE, + refresh_token_hash text UNIQUE, + access_expires_at timestamptz NOT NULL, + refresh_expires_at timestamptz, + revoked_at timestamptz, + last_used_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX idx_oauth_grants_app_org ON oauth_access_grants (application_id, organization_id); +CREATE INDEX idx_oauth_grants_org_user ON oauth_access_grants (organization_id, user_id) WHERE revoked_at IS NULL; diff --git a/internal/infrastructure/pubsub/client.go b/internal/infrastructure/pubsub/client.go index 78ceebf4..1615ca95 100644 --- a/internal/infrastructure/pubsub/client.go +++ b/internal/infrastructure/pubsub/client.go @@ -9,6 +9,8 @@ import ( "cloud.google.com/go/pubsub" "github.com/getsentry/sentry-go" "github.com/google/uuid" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" ) // Client wraps Google Pub/Sub for real-time streaming @@ -53,6 +55,48 @@ func (c *Client) getTopic(ctx context.Context, topicID string) (*pubsub.Topic, e return topic, nil } +// realtimeTopology is the full topic -> pull-subscription set the Elixir +// realtime service consumes. It is the single source of truth for provisioning: +// every topic the StreamingPublisher writes to gets exactly one subscription +// named "-sub", matching :realtime, :pubsub_subscriptions in the Elixir +// config (realtime/config/{config,runtime}.exs). Keep the two lists in lockstep. +var realtimeTopology = map[string]string{ + TopicTaskStatus: TopicTaskStatus + "-sub", + TopicCampaignUpdate: TopicCampaignUpdate + "-sub", + TopicWarmupUpdate: TopicWarmupUpdate + "-sub", + TopicEmailError: TopicEmailError + "-sub", + TopicEmailWarning: TopicEmailWarning + "-sub", + TopicUserEvents: TopicUserEvents + "-sub", + TopicEmailInbox: TopicEmailInbox + "-sub", + TopicBulkOps: TopicBulkOps + "-sub", + TopicContactsSync: TopicContactsSync + "-sub", +} + +// EnsureRealtimeTopology idempotently creates every realtime topic and its pull +// subscription. Safe to call on every boot and from multiple services +// concurrently: AlreadyExists is treated as success. Provisioning here (the +// control plane) means the Elixir Broadway producers always find their +// subscriptions, instead of silently consuming from a subscription that was +// never created. Called only when PUBSUB_ENABLED=true. +func (c *Client) EnsureRealtimeTopology(ctx context.Context) error { + for topicID, subID := range realtimeTopology { + topic, err := c.client.CreateTopic(ctx, topicID) + if err != nil { + if status.Code(err) != codes.AlreadyExists { + return fmt.Errorf("create topic %s: %w", topicID, err) + } + topic = c.client.Topic(topicID) + } + if _, err := c.client.CreateSubscription(ctx, subID, pubsub.SubscriptionConfig{ + Topic: topic, + AckDeadline: 30 * time.Second, + }); err != nil && status.Code(err) != codes.AlreadyExists { + return fmt.Errorf("create subscription %s: %w", subID, err) + } + } + return nil +} + // Publish publishes a message to a topic func (c *Client) Publish(ctx context.Context, topicID string, data interface{}, attributes map[string]string) error { topic, err := c.getTopic(ctx, topicID) @@ -78,13 +122,21 @@ func (c *Client) Publish(ctx context.Context, topicID string, data interface{}, return nil } +// eventBus is the transport the StreamingPublisher writes to. Both the Google +// Pub/Sub Client and the Redis bridge implement it, so the publish helpers stay +// transport-agnostic and the same code path serves GCP and non-GCP (local dev) +// environments. +type eventBus interface { + Publish(ctx context.Context, topicID string, data interface{}, attributes map[string]string) error +} + // StreamingPublisher handles real-time streaming to users type StreamingPublisher struct { - client *Client + client eventBus } // NewStreamingPublisher creates a new streaming publisher -func NewStreamingPublisher(client *Client) *StreamingPublisher { +func NewStreamingPublisher(client eventBus) *StreamingPublisher { return &StreamingPublisher{ client: client, } diff --git a/internal/infrastructure/pubsub/events.go b/internal/infrastructure/pubsub/events.go index ab1596a1..d10a89f5 100644 --- a/internal/infrastructure/pubsub/events.go +++ b/internal/infrastructure/pubsub/events.go @@ -53,6 +53,9 @@ const ( EventEmailOpened EventType = "EMAIL_OPENED" EventEmailClicked EventType = "EMAIL_CLICKED" + // A human reply landed for a campaign contact (org-scoped pulse). + EventEmailReplied EventType = "EMAIL_REPLIED" + // Task progress events EventTaskProgress EventType = "TASK_PROGRESS" @@ -67,6 +70,11 @@ const ( EventAutomationDeleted EventType = "AUTOMATION_DELETED" EventAutomationRun EventType = "AUTOMATION_RUN" + // Developer "fire event": a custom, org-scoped event emitted from an + // automation action or campaign step, delivered over the realtime gateway so + // API-key subscribers receive it without hosting a public webhook URL. + EventCustomFired EventType = "CUSTOM_EVENT" + // In-app notification feed (user-scoped). The web client refreshes the bell // feed + may toast on any event type containing "NOTIFICATION". EventNotificationCreated EventType = "NOTIFICATION_CREATED" @@ -76,6 +84,11 @@ const ( EventMeetingBooked EventType = "MEETING_BOOKED" EventMeetingRescheduled EventType = "MEETING_RESCHEDULED" EventMeetingCanceled EventType = "MEETING_CANCELED" + + // Org-wide presence privacy policy changed. The realtime OrgChannel handles + // this internally (re-track / untrack / strip activity) to apply the new + // policy live; it is not forwarded to web clients. + EventPresencePolicyUpdated EventType = "PRESENCE_POLICY_UPDATED" ) // BaseEvent contains common fields for all events @@ -88,6 +101,7 @@ type BaseEvent struct { // EmailInboxEvent for new/updated emails type EmailInboxEvent struct { BaseEvent + OrgID string `json:"org_id,omitempty"` EmailAccountID string `json:"email_account_id"` MessageID string `json:"message_id"` ThreadID string `json:"thread_id,omitempty"` @@ -120,6 +134,7 @@ type BulkOperationEvent struct { // CampaignEvent for campaign changes type CampaignEvent struct { BaseEvent + OrgID string `json:"org_id,omitempty"` CampaignID string `json:"campaign_id"` Name string `json:"name,omitempty"` Status string `json:"status,omitempty"` @@ -139,6 +154,7 @@ type CampaignProgressData struct { // AccountEvent for email account status changes type AccountEvent struct { BaseEvent + OrgID string `json:"org_id,omitempty"` EmailAccountID string `json:"email_account_id"` Email string `json:"email"` Provider string `json:"provider,omitempty"` @@ -164,25 +180,30 @@ type WarmupStatsEvent struct { // TrackingEventPayload for email open/click tracking events type TrackingEventPayload struct { BaseEvent + OrgID string `json:"org_id,omitempty"` CampaignID string `json:"campaign_id"` ContactID string `json:"contact_id,omitempty"` ContactEmail string `json:"contact_email,omitempty"` - SequenceID string `json:"sequence_id,omitempty"` + SequenceID string `json:"step_id,omitempty"` OriginalURL string `json:"original_url,omitempty"` // For click events + // Machine marks an automated open (Apple MPP prefetch, UA-less fetcher) + // so live views can badge it instead of presenting it as a human open. + Machine bool `json:"machine,omitempty"` } // TaskProgressEvent for detailed campaign task progress type TaskProgressEvent struct { BaseEvent + OrgID string `json:"org_id,omitempty"` CampaignID string `json:"campaign_id"` TaskID string `json:"task_id"` Status string `json:"status"` // pending, active, completed, failed ContactID string `json:"contact_id"` ContactEmail string `json:"contact_email"` ContactName string `json:"contact_name"` - SequenceID string `json:"sequence_id"` - SequenceName string `json:"sequence_name"` - SequenceIndex int `json:"sequence_index"` + SequenceID string `json:"step_id"` + SequenceName string `json:"step_name"` + SequenceIndex int `json:"step_index"` Progress int `json:"progress"` // Percentage 0-100 TotalContacts int `json:"total_contacts"` ProcessedCount int `json:"processed_count"` @@ -375,7 +396,7 @@ func (p *StreamingPublisher) PublishAccountEvent(ctx context.Context, event *Acc // owning user's realtime stream. The dashboard treats it as an ACCOUNT event // and refreshes account status live; the explicit state fields let consumers // react without a refetch. -func (p *StreamingPublisher) PublishAccountHealth(ctx context.Context, userID, accountID, email, prevState, newState, reason string) { +func (p *StreamingPublisher) PublishAccountHealth(ctx context.Context, orgID, userID, accountID, email, prevState, newState, reason string) { if p == nil || p.client == nil { return } @@ -384,6 +405,7 @@ func (p *StreamingPublisher) PublishAccountHealth(ctx context.Context, userID, a EventType: EventAccountHealthChanged, UserID: userID, }, + OrgID: orgID, EmailAccountID: accountID, Email: email, Status: newState, @@ -474,6 +496,83 @@ func (p *StreamingPublisher) PublishAutomationEvent(ctx context.Context, orgID, } } +// CustomEvent is a developer-defined "fire event" signal. Name is the +// caller-chosen event name (what subscribers match on) and Payload is the +// fully-customizable key/value data. Source/SourceID record where it was fired +// from (an automation or a campaign step). Routed to org: like any +// org-scoped event; the gateway delivers it to API-key websocket subscribers. +type CustomEvent struct { + BaseEvent + OrgID string `json:"org_id"` + Name string `json:"name"` + Payload map[string]string `json:"payload,omitempty"` + Source string `json:"source,omitempty"` + SourceID string `json:"source_id,omitempty"` +} + +// PublishCustomEvent emits an org-scoped developer "fire event". actorID may be +// uuid.Nil for system-fired events. Best-effort: a publish hiccup never blocks +// the automation/campaign that fired it. +func (p *StreamingPublisher) PublishCustomEvent(ctx context.Context, orgID, actorID uuid.UUID, name string, payload map[string]string, source, sourceID string) { + if p == nil || p.client == nil || orgID == uuid.Nil { + return + } + event := &CustomEvent{ + BaseEvent: BaseEvent{ + EventType: EventCustomFired, + UserID: actorID.String(), + Timestamp: time.Now(), + }, + OrgID: orgID.String(), + Name: name, + Payload: payload, + Source: source, + SourceID: sourceID, + } + attrs := map[string]string{ + "user_id": actorID.String(), + "org_id": orgID.String(), + "event_type": string(EventCustomFired), + } + if err := p.client.Publish(ctx, TopicUserEvents, event, attrs); err != nil { + // Best-effort: realtime is a nicety, not a requirement. + } +} + +// PresencePolicyEvent tells the realtime service to re-gate team presence for an +// org live, so a privacy toggle applies without waiting for members to reconnect. +// Handled inside the OrgChannel (not pushed to web clients). +type PresencePolicyEvent struct { + BaseEvent + OrgID string `json:"org_id"` + PresenceShowOnline bool `json:"presence_show_online"` + PresenceShowActivity bool `json:"presence_show_activity"` +} + +// PublishPresencePolicy emits an org-scoped presence policy change so connected +// OrgChannels re-evaluate tracking immediately. +func (p *StreamingPublisher) PublishPresencePolicy(ctx context.Context, orgID uuid.UUID, showOnline, showActivity bool) { + if p == nil || p.client == nil || orgID == uuid.Nil { + return + } + event := &PresencePolicyEvent{ + BaseEvent: BaseEvent{ + EventType: EventPresencePolicyUpdated, + Timestamp: time.Now(), + }, + OrgID: orgID.String(), + PresenceShowOnline: showOnline, + PresenceShowActivity: showActivity, + } + attrs := map[string]string{ + "org_id": orgID.String(), + "event_type": string(EventPresencePolicyUpdated), + } + if err := p.client.Publish(ctx, TopicUserEvents, event, attrs); err != nil { + // Best-effort: realtime is a nicety, not a requirement. + } +} + // PublishToUser publishes a generic event to a user func (p *StreamingPublisher) PublishToUser(ctx context.Context, userID string, event interface{}) { if p.client == nil { @@ -564,6 +663,48 @@ func (p *StreamingPublisher) PublishTaskProgress(ctx context.Context, event *Tas } } +// PublishEmailReplied emits an org-scoped EMAIL_REPLIED pulse when a human +// reply lands for a campaign contact. Primitive-typed so app packages can wire +// it through a narrow local interface. +func (p *StreamingPublisher) PublishEmailReplied(ctx context.Context, orgID, userID, campaignID, contactID, contactEmail, sequenceID string) { + if p == nil || p.client == nil { + return + } + p.PublishTrackingEvent(ctx, &TrackingEventPayload{ + BaseEvent: BaseEvent{ + EventType: EventEmailReplied, + UserID: userID, + }, + OrgID: orgID, + CampaignID: campaignID, + ContactID: contactID, + ContactEmail: contactEmail, + SequenceID: sequenceID, + }) +} + +// PublishEmailSent emits an org-scoped EMAIL_SENT pulse when a campaign email +// goes out, carrying the same rich payload as task progress so the dashboard +// can show which contact/step just fired without a refetch. +func (p *StreamingPublisher) PublishEmailSent(ctx context.Context, event *TaskProgressEvent) { + if p == nil || p.client == nil { + return + } + + event.EventType = EventEmailSent + event.Timestamp = time.Now() + + attrs := map[string]string{ + "user_id": event.UserID, + "campaign_id": event.CampaignID, + "event_type": string(EventEmailSent), + } + + if err := p.client.Publish(ctx, TopicCampaignUpdate, event, attrs); err != nil { + // Best-effort: realtime is a nicety, not a requirement. + } +} + // Subscription info for clients type RealtimeSubscriptionInfo struct { WebsocketURL string `json:"websocket_url"` diff --git a/internal/infrastructure/pubsub/redis_bus.go b/internal/infrastructure/pubsub/redis_bus.go new file mode 100644 index 00000000..cd3b5170 --- /dev/null +++ b/internal/infrastructure/pubsub/redis_bus.go @@ -0,0 +1,46 @@ +package pubsub + +import ( + "context" + "encoding/json" + + "github.com/redis/go-redis/v9" +) + +// DefaultRedisEventChannel is the Redis pub/sub channel the realtime service +// subscribes to. Events published here are decoded and rebroadcast to the +// matching org/user/entity Phoenix topics, exactly like the Google Pub/Sub path. +const DefaultRedisEventChannel = "realtime:events" + +// RedisBus bridges realtime events to the Elixir realtime service over Redis +// pub/sub. It is the active transport whenever Google Pub/Sub is not configured +// (local dev and any environment without GCP). Routing is by event body fields +// (user_id, org_id, campaign_id, ...), so the topic and attributes are not +// needed on the wire. +type RedisBus struct { + rdb *redis.Client + channel string +} + +// NewRedisBus builds a Redis-backed event bus. channel defaults to +// DefaultRedisEventChannel when empty. +func NewRedisBus(rdb *redis.Client, channel string) *RedisBus { + if channel == "" { + channel = DefaultRedisEventChannel + } + return &RedisBus{rdb: rdb, channel: channel} +} + +// Publish marshals the event and pushes it onto the Redis channel. topicID and +// attributes are intentionally ignored: the realtime subscriber routes on the +// event body, the same fields the Google Pub/Sub consumer reads. +func (b *RedisBus) Publish(ctx context.Context, _ string, data interface{}, _ map[string]string) error { + if b == nil || b.rdb == nil { + return nil + } + payload, err := json.Marshal(data) + if err != nil { + return err + } + return b.rdb.Publish(ctx, b.channel, payload).Err() +} diff --git a/internal/models/advanced_outreach.go b/internal/models/advanced_outreach.go index 4a85c8ee..d13f61e2 100644 --- a/internal/models/advanced_outreach.go +++ b/internal/models/advanced_outreach.go @@ -142,7 +142,7 @@ type CampaignABVariant struct { CampaignID uuid.UUID `json:"campaign_id"` // SequenceID scopes the variant to one step. nil = campaign-level (applies // to every step, legacy behavior). - SequenceID *uuid.UUID `json:"sequence_id,omitempty"` + SequenceID *uuid.UUID `json:"step_id,omitempty"` Name string `json:"name"` Weight int `json:"weight"` Subject string `json:"subject"` @@ -157,7 +157,7 @@ type CampaignABVariant struct { type CreateCampaignABVariantRequest struct { Name string `json:"name" binding:"required"` - SequenceID *uuid.UUID `json:"sequence_id,omitempty"` + SequenceID *uuid.UUID `json:"step_id,omitempty"` Weight int `json:"weight"` Subject string `json:"subject,omitempty"` BodyHTML string `json:"body_html,omitempty"` diff --git a/internal/models/analytics.go b/internal/models/analytics.go index bd6cbf87..34f5661d 100644 --- a/internal/models/analytics.go +++ b/internal/models/analytics.go @@ -45,7 +45,7 @@ type CampaignAnalytics struct { Status string `json:"status"` DateRange DateRange `json:"date_range"` Summary CampaignSummary `json:"summary"` - Sequences []SequenceStats `json:"sequences"` + Sequences []SequenceStats `json:"steps"` DailyStats []CampaignDailyStats `json:"daily_stats,omitempty"` } @@ -54,10 +54,13 @@ type CampaignSummary struct { EmailsSent int `json:"emails_sent"` EmailsPending int `json:"emails_pending"` UniqueOpens int `json:"unique_opens"` - UniqueClicks int `json:"unique_clicks"` - Replies int `json:"replies"` - Bounces int `json:"bounces"` - Unsubscribes int `json:"unsubscribes"` + // MachineOpens is the subset of UniqueOpens from automated fetchers + // (Apple MPP prefetch, UA-less clients). Human opens = unique - machine. + MachineOpens int `json:"machine_opens"` + UniqueClicks int `json:"unique_clicks"` + Replies int `json:"replies"` + Bounces int `json:"bounces"` + Unsubscribes int `json:"unsubscribes"` OpenRate float64 `json:"open_rate"` // percentage ClickRate float64 `json:"click_rate"` // percentage @@ -66,7 +69,7 @@ type CampaignSummary struct { } type SequenceStats struct { - SequenceID uuid.UUID `json:"sequence_id"` + SequenceID uuid.UUID `json:"step_id"` Name string `json:"name"` Position int `json:"position"` EmailsSent int `json:"emails_sent"` @@ -209,8 +212,10 @@ type DashboardAnalytics struct { // DashboardOverallStats contains aggregate statistics for the dashboard type DashboardOverallStats struct { - TotalEmailsSent int `json:"total_emails_sent"` - TotalOpens int `json:"total_opens"` + TotalEmailsSent int `json:"total_emails_sent"` + TotalOpens int `json:"total_opens"` + // MachineOpens is the subset of TotalOpens from automated fetchers. + MachineOpens int `json:"machine_opens"` TotalClicks int `json:"total_clicks"` TotalReplies int `json:"total_replies"` TotalBounces int `json:"total_bounces"` diff --git a/internal/models/attachment.go b/internal/models/attachment.go index 337d8979..f0e30668 100644 --- a/internal/models/attachment.go +++ b/internal/models/attachment.go @@ -11,7 +11,7 @@ import ( type CampaignAttachment struct { ID uuid.UUID `json:"id"` CampaignID uuid.UUID `json:"campaign_id"` - SequenceID *uuid.UUID `json:"sequence_id,omitempty"` + SequenceID *uuid.UUID `json:"step_id,omitempty"` UserID uuid.UUID `json:"user_id"` Filename string `json:"filename"` Size int64 `json:"size"` diff --git a/internal/models/audit.go b/internal/models/audit.go index 99333209..dc04c32b 100644 --- a/internal/models/audit.go +++ b/internal/models/audit.go @@ -54,7 +54,7 @@ const ( AuditEntityContact AuditEntityType = "contact" AuditEntityEmailAccount AuditEntityType = "email_account" AuditEntityAPIKey AuditEntityType = "api_key" - AuditEntitySequence AuditEntityType = "sequence" + AuditEntitySequence AuditEntityType = "step" AuditEntityUser AuditEntityType = "user" AuditEntityOrganization AuditEntityType = "organization" AuditEntityWorker AuditEntityType = "worker" @@ -84,6 +84,13 @@ const ( // Inbox AuditEntityUnibox AuditEntityType = "unibox" + + // Collaboration / automation surfaces + AuditEntityTeam AuditEntityType = "team" + AuditEntityAutomation AuditEntityType = "automation" + AuditEntityLeadSyncSource AuditEntityType = "lead_sync_source" + AuditEntityMeeting AuditEntityType = "meeting" + AuditEntityRole AuditEntityType = "role" ) // AuditActor is the minimal identity of the member who performed an action, diff --git a/internal/models/campaign.go b/internal/models/campaign.go index 39bffd18..2039b1d7 100644 --- a/internal/models/campaign.go +++ b/internal/models/campaign.go @@ -285,7 +285,7 @@ type CreateCampaign struct { TrackingDomain *string `json:"tracking_domain,omitempty"` // Initial sequences (in order) — caller can also create them after. - Sequences []CreateSequenceInput `json:"sequences,omitempty"` + Sequences []CreateSequenceInput `json:"steps,omitempty"` // A/B variants for the first sequence — useful for "create + test" in one shot. Variants []CreateCampaignABVariantRequest `json:"variants,omitempty"` diff --git a/internal/models/contact.go b/internal/models/contact.go index 04703ca8..a403e47d 100644 --- a/internal/models/contact.go +++ b/internal/models/contact.go @@ -147,8 +147,8 @@ type ContactSentEmail struct { // Campaign + sequence context CampaignID *uuid.UUID `json:"campaign_id,omitempty"` CampaignName *string `json:"campaign_name,omitempty"` - SequenceID *uuid.UUID `json:"sequence_id,omitempty"` - SequenceName *string `json:"sequence_name,omitempty"` + SequenceID *uuid.UUID `json:"step_id,omitempty"` + SequenceName *string `json:"step_name,omitempty"` // Engagement (from campaign_contact_progress, may be nil). OpenedAt *time.Time `json:"opened_at,omitempty"` @@ -201,8 +201,8 @@ type ContactTimelineEvent struct { // and out-of-campaign reply intents don't always have one. CampaignID *uuid.UUID `json:"campaign_id,omitempty"` CampaignName *string `json:"campaign_name,omitempty"` - SequenceID *uuid.UUID `json:"sequence_id,omitempty"` - SequenceName *string `json:"sequence_name,omitempty"` + SequenceID *uuid.UUID `json:"step_id,omitempty"` + SequenceName *string `json:"step_name,omitempty"` // Task linkage for engagement events. TaskID *uuid.UUID `json:"task_id,omitempty"` @@ -285,7 +285,6 @@ type SearchContacts struct { UpdatedBefore *time.Time `json:"updated_before"` // Contacts updated before this date SortBy string `json:"sort_by"` // e.g., "first_name ASC", "campaign_count DESC" Reverse bool `json:"reverse"` // ASC or DESC - Offset int `json:"offset"` // Pagination } type BulkEditContactsFieldType string diff --git a/internal/models/crm.go b/internal/models/crm.go index 30a78663..e93a5e33 100644 --- a/internal/models/crm.go +++ b/internal/models/crm.go @@ -205,21 +205,14 @@ type SearchDeals struct { Reverse bool `json:"reverse"` // true = ASC, false = DESC (default) } -// DealsSearchResult is the offset-paginated result of POST /crm/deals/search. -// Offset (not keyset) pagination is used because the sortable columns include -// nullable value/expected_close_date, where a keyset cursor would silently -// drop NULL-valued rows. Total is exact so the UI can show "N of M". +// DealsSearchResult is the result of POST /crm/deals/search. It uses offset +// pagination under the hood (the sortable columns include nullable +// value/expected_close_date, where a keyset cursor would silently drop NULL +// rows), but exposes the standard {total, next_cursor, has_more} envelope with +// an OPAQUE cursor, so it looks identical to every other list. Total is exact. type DealsSearchResult struct { - Data []Deal `json:"data"` - Pagination DealsSearchPagination `json:"pagination"` -} - -type DealsSearchPagination struct { - Total int64 `json:"total"` - Limit int `json:"limit"` - Offset int `json:"offset"` - HasMore bool `json:"has_more"` - NextOffset *int `json:"next_offset,omitempty"` + Data []Deal `json:"data"` + Pagination Pagination `json:"pagination"` } // DealsSummary is the server-side aggregate over the SAME filter body as a @@ -385,21 +378,13 @@ type SearchTasks struct { Reverse bool `json:"reverse"` // true = ASC, false = DESC (default) } -// TasksSearchResult is the offset-paginated result of POST /crm/tasks/search. -// Offset (not keyset) pagination is used because the sortable columns include -// the nullable due_date, where a keyset cursor would silently drop NULL-valued -// rows. Total is exact so the UI can show "N of M". +// TasksSearchResult is the result of POST /crm/tasks/search. Offset pagination +// under the hood (the sortable nullable due_date rules out a keyset cursor), but +// it exposes the standard {total, next_cursor, has_more} envelope with an OPAQUE +// cursor like every other list. Total is exact so the UI can show "N of M". type TasksSearchResult struct { - Data []CRMTask `json:"data"` - Pagination TasksSearchPagination `json:"pagination"` -} - -type TasksSearchPagination struct { - Total int64 `json:"total"` - Limit int `json:"limit"` - Offset int `json:"offset"` - HasMore bool `json:"has_more"` - NextOffset *int `json:"next_offset,omitempty"` + Data []CRMTask `json:"data"` + Pagination Pagination `json:"pagination"` } // TasksSummary is the server-side aggregate over the SAME filter body as a diff --git a/internal/models/integration.go b/internal/models/integration.go index a5335872..556806d3 100644 --- a/internal/models/integration.go +++ b/internal/models/integration.go @@ -263,10 +263,34 @@ const ( IntegrationActionCreateDeal IntegrationAction = "warmbly.create_deal" IntegrationActionMoveDealStage IntegrationAction = "warmbly.move_deal_stage" IntegrationActionUnsubscribe IntegrationAction = "warmbly.unsubscribe" + // IntegrationActionLabelEmail applies unibox conversation labels (categories) + // to the thread the event belongs to. Reply triggers carry the thread_id + + // mailbox owner; on other triggers (no thread) it is a logged no-op. + IntegrationActionLabelEmail IntegrationAction = "warmbly.label_email" // IntegrationActionRunAutomation launches another automation's flow, passing // the current event data through. Bounded by the chain-depth guard so it // cannot loop forever or fan out unbounded compute. IntegrationActionRunAutomation IntegrationAction = "warmbly.run_automation" + // IntegrationActionHTTPRequest makes a configurable outbound HTTP call + // (method/url/headers/query/body, all templated from the event + prior step + // output) and writes the response back into the event data so downstream + // nodes can use it (e.g. {{.response.body.id}}) and condition nodes can + // branch on {{.response.ok}}. SSRF-guarded + bounded retry. This is the + // generic "send a webhook / call any API" node. + IntegrationActionHTTPRequest IntegrationAction = "warmbly.http_request" + // IntegrationActionSetVariables computes one or more named values from Go + // templates (against the event + prior step output) and writes them back into + // the event data, so later nodes can reuse a transformed/normalized value + // without recomputing it. The safe "transform" node — it runs the same + // sandboxed text/template engine as every other action value (no I/O, no + // arbitrary code), not a general code runtime. + IntegrationActionSetVariables IntegrationAction = "warmbly.set_variables" + // IntegrationActionFireEvent publishes a developer-defined custom event to the + // realtime gateway (org-scoped). The event name + a fully-custom key/value + // payload are Go-templated against the event data. Subscribers (an API key + // with REALTIME_SUBSCRIBE on the org websocket) receive it with no public URL, + // so it replaces an outbound webhook for "tell my system this happened". + IntegrationActionFireEvent IntegrationAction = "warmbly.fire_event" ) // IsNativeAction reports whether an action is a Warmbly-internal CRM/contact @@ -275,7 +299,8 @@ func IsNativeAction(a IntegrationAction) bool { switch a { case IntegrationActionAddTag, IntegrationActionRemoveTag, IntegrationActionCreateTask, IntegrationActionCreateDeal, IntegrationActionMoveDealStage, IntegrationActionUnsubscribe, - IntegrationActionRunAutomation: + IntegrationActionRunAutomation, IntegrationActionLabelEmail, IntegrationActionHTTPRequest, + IntegrationActionSetVariables, IntegrationActionFireEvent: return true default: return false @@ -318,6 +343,13 @@ type Automation struct { Graph AutomationGraph `json:"graph"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` + // InboundToken is the per-automation secret embedded in the inbound-webhook + // URL, set only when TriggerEvent is inbound.webhook. Server-only; the URL + // that carries it is surfaced to clients via InboundURL instead. + InboundToken string `json:"-"` + // InboundURL is the public POST path that fires this automation when its + // trigger is the inbound webhook. Computed from InboundToken, never stored. + InboundURL string `json:"inbound_url,omitempty"` } // AutomationGraph is the editable flow: nodes + the edges connecting them. @@ -398,8 +430,11 @@ type AutomationNodeResult struct { // DryRunRequest tests an automation without side effects. Data is the sample // event payload; when empty the server builds a sample from the trigger. +// SkipNodeIDs are action nodes the caller toggled off for this test: they are +// recorded as "skipped" in the trace and never previewed. type DryRunRequest struct { - Data map[string]any `json:"data,omitempty"` + Data map[string]any `json:"data,omitempty"` + SkipNodeIDs []string `json:"skip_node_ids,omitempty"` } // DryRunResponse is the trace of a dry run. @@ -499,17 +534,10 @@ type MeetingBookingSummary struct { Canceled int `json:"canceled"` } -// MeetingBookingPage is an offset-paginated meetings result (Total is exact so -// the UI can show "N of M"). +// MeetingBookingPage is a meetings result. Offset pagination under the hood, but +// it exposes the standard {total, next_cursor, has_more} envelope with an OPAQUE +// cursor like every other list (Total is exact so the UI can show "N of M"). type MeetingBookingPage struct { - Data []MeetingBooking `json:"data"` - Pagination MeetingBookingPagination `json:"pagination"` -} - -type MeetingBookingPagination struct { - Total int64 `json:"total"` - Limit int `json:"limit"` - Offset int `json:"offset"` - HasMore bool `json:"has_more"` - NextOffset *int `json:"next_offset,omitempty"` + Data []MeetingBooking `json:"data"` + Pagination Pagination `json:"pagination"` } diff --git a/internal/models/notification.go b/internal/models/notification.go index e1f3c64a..e2bb204e 100644 --- a/internal/models/notification.go +++ b/internal/models/notification.go @@ -17,13 +17,15 @@ const ( NotifHealthBounce NotificationCategory = "health_bounce" NotifHealthComplaint NotificationCategory = "health_complaint" NotifWorkerDowntime NotificationCategory = "health_worker_downtime" + NotifSecuritySignIn NotificationCategory = "security_new_signin" ) // ChannelPrefs is the per-category delivery toggles. Only InApp is delivered -// today; Email/Slack are modeled for forward-compat and rendered "coming soon". +// today across in-app, email, and a connected Slack workspace. type ChannelPrefs struct { InApp bool `json:"in_app"` - Email bool `json:"email"` // reserved; not enforced yet + Email bool `json:"email"` + Slack bool `json:"slack"` } // CategoryPref is the enable flag + channel toggles for one category. @@ -40,6 +42,7 @@ type NotificationPreferences struct { HealthBounce CategoryPref `json:"health_bounce"` HealthComplaint CategoryPref `json:"health_complaint"` WorkerDowntime CategoryPref `json:"health_worker_downtime"` + SecuritySignIn CategoryPref `json:"security_new_signin"` } // DefaultNotificationPreferences is the merge base. Health categories default ON @@ -54,6 +57,7 @@ func DefaultNotificationPreferences() NotificationPreferences { HealthBounce: on, HealthComplaint: on, WorkerDowntime: on, + SecuritySignIn: on, } } @@ -70,6 +74,8 @@ func (p NotificationPreferences) CategoryPref(c NotificationCategory) CategoryPr return p.HealthComplaint case NotifWorkerDowntime: return p.WorkerDowntime + case NotifSecuritySignIn: + return p.SecuritySignIn default: return CategoryPref{} } diff --git a/internal/models/oauth_app.go b/internal/models/oauth_app.go new file mode 100644 index 00000000..a99f4932 --- /dev/null +++ b/internal/models/oauth_app.go @@ -0,0 +1,117 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +// OAuth2 authorization server domain types. Apps register as OAuth clients; +// users grant them scoped access via the authorization-code flow (client secret +// required, PKCE optional); the issued access token carries an API-permission +// bitmask (Scopes) and authenticates API calls through the same gates as an API key. + +type OAuthAppStatus string + +const ( + OAuthAppActive OAuthAppStatus = "active" + OAuthAppDisabled OAuthAppStatus = "disabled" +) + +// Credential prefixes mirror the api_keys `wmbly_` convention so a leaked token +// is greppable and self-describing. +const ( + OAuthClientIDPrefix = "wmcid_" + OAuthClientSecretPrefix = "wmcs_" + OAuthAccessTokenPrefix = "wmat_" + OAuthRefreshTokenPrefix = "wmrt_" + OAuthCodePrefix = "wmac_" +) + +// Lifetimes. The authorization code is single-use and short; access tokens are +// short-lived; refresh tokens are long-lived and rotate on every exchange. +const ( + OAuthAuthorizationCodeTTL = 10 * time.Minute + OAuthAccessTokenTTL = time.Hour + OAuthRefreshTokenTTL = 90 * 24 * time.Hour +) + +// OAuthApplication is a registered third-party OAuth client. +type OAuthApplication struct { + ID uuid.UUID `json:"id"` + OrganizationID uuid.UUID `json:"organization_id"` + CreatedBy uuid.UUID `json:"created_by"` + Name string `json:"name"` + Description string `json:"description"` + LogoURL string `json:"logo_url"` + WebsiteURL string `json:"website_url"` + ClientID string `json:"client_id"` + ClientSecretHash string `json:"-"` + RedirectURIs []string `json:"redirect_uris"` + Scopes uint64 `json:"scopes"` + Status OAuthAppStatus `json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// OAuthApplicationWithSecret is returned exactly once, on create or secret +// rotation; the plaintext secret is never stored or shown again. +type OAuthApplicationWithSecret struct { + OAuthApplication + ClientSecret string `json:"client_secret,omitempty"` +} + +// OAuthApplicationWrite is the create/update payload from the developer UI. +type OAuthApplicationWrite struct { + Name string `json:"name"` + Description string `json:"description"` + LogoURL string `json:"logo_url"` + WebsiteURL string `json:"website_url"` + RedirectURIs []string `json:"redirect_uris"` + Scopes uint64 `json:"scopes"` +} + +// OAuthAuthorizationCode is a single-use code bound to a PKCE challenge and the +// exact scopes/redirect the user consented to. +type OAuthAuthorizationCode struct { + ID uuid.UUID + CodeHash string + ApplicationID uuid.UUID + OrganizationID uuid.UUID + UserID uuid.UUID + RedirectURI string + Scopes uint64 + CodeChallenge string + CodeChallengeMethod string + UsedAt *time.Time + ExpiresAt time.Time + CreatedAt time.Time +} + +// OAuthAccessGrant is an issued access+refresh token pair (tokens stored hashed). +type OAuthAccessGrant struct { + ID uuid.UUID `json:"id"` + ApplicationID uuid.UUID `json:"application_id"` + OrganizationID uuid.UUID `json:"organization_id"` + UserID uuid.UUID `json:"user_id"` + Scopes uint64 `json:"scopes"` + AccessTokenHash string `json:"-"` + RefreshTokenHash string `json:"-"` + AccessExpiresAt time.Time `json:"access_expires_at"` + RefreshExpiresAt *time.Time `json:"refresh_expires_at,omitempty"` + RevokedAt *time.Time `json:"revoked_at,omitempty"` + LastUsedAt *time.Time `json:"last_used_at,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// OAuthAuthorizedApp is one row in a user's "apps you've authorized" list (a +// grant joined to its application's display fields). +type OAuthAuthorizedApp struct { + ApplicationID uuid.UUID `json:"application_id"` + Name string `json:"name"` + LogoURL string `json:"logo_url"` + WebsiteURL string `json:"website_url"` + Scopes uint64 `json:"scopes"` + AuthorizedAt time.Time `json:"authorized_at"` + LastUsedAt *time.Time `json:"last_used_at,omitempty"` +} diff --git a/internal/models/organization.go b/internal/models/organization.go index 69fb8234..15d0251e 100644 --- a/internal/models/organization.go +++ b/internal/models/organization.go @@ -22,6 +22,14 @@ type Organization struct { DeletionScheduledAt *time.Time `json:"deletion_scheduled_at,omitempty"` DeletionScheduledFor *time.Time `json:"deletion_scheduled_for,omitempty"` + // Team presence privacy (org-wide, admin-controlled). When + // PresenceShowOnline is false the realtime service tracks no member, so + // nobody can see who is online. When PresenceShowActivity is false, online + // is still shown but the viewing/editing detail is stripped. The realtime + // service reads both on channel join. + PresenceShowOnline bool `json:"presence_show_online"` + PresenceShowActivity bool `json:"presence_show_activity"` + // Joined data Owner *User `json:"owner,omitempty"` } @@ -34,14 +42,19 @@ func (o *Organization) IsPendingDeletion() bool { // OrganizationMember represents a user's membership in an organization type OrganizationMember struct { - ID uuid.UUID `json:"id"` - OrganizationID uuid.UUID `json:"organization_id"` - UserID uuid.UUID `json:"user_id"` - Role string `json:"role"` - Permissions OrganizationPermission `json:"permissions"` - InvitedBy *uuid.UUID `json:"invited_by,omitempty"` - InvitedAt time.Time `json:"invited_at"` - AcceptedAt *time.Time `json:"accepted_at,omitempty"` + ID uuid.UUID `json:"id"` + OrganizationID uuid.UUID `json:"organization_id"` + UserID uuid.UUID `json:"user_id"` + Role string `json:"role"` + // RoleID is the member's primary role (first assigned), kept for legacy + // single-role consumers. Roles is the full assigned set; Permissions is + // the effective OR snapshot across all of them. + RoleID *uuid.UUID `json:"role_id,omitempty"` + Roles []MemberRole `json:"roles,omitempty"` + Permissions OrganizationPermission `json:"permissions"` + InvitedBy *uuid.UUID `json:"invited_by,omitempty"` + InvitedAt time.Time `json:"invited_at"` + AcceptedAt *time.Time `json:"accepted_at,omitempty"` // Joined data User *User `json:"user,omitempty"` @@ -70,6 +83,8 @@ type OrganizationInvitation struct { OrganizationID uuid.UUID `json:"organization_id"` Email string `json:"email"` Role string `json:"role"` + RoleID *uuid.UUID `json:"role_id,omitempty"` + Roles []MemberRole `json:"roles,omitempty"` Permissions OrganizationPermission `json:"permissions"` InvitedBy uuid.UUID `json:"invited_by"` Token string `json:"-"` // Never expose token in JSON @@ -120,18 +135,100 @@ type CreateOrganizationRequest struct { type UpdateOrganizationRequest struct { Name *string `json:"name,omitempty"` Slug *string `json:"slug,omitempty"` + // Org-wide team presence privacy toggles (admin-controlled). + PresenceShowOnline *bool `json:"presence_show_online,omitempty"` + PresenceShowActivity *bool `json:"presence_show_activity,omitempty"` } // InviteMemberRequest represents the request to invite a new member type InviteMemberRequest struct { - Email string `json:"email" binding:"required,email"` - Role string `json:"role,omitempty"` - Permissions *uint16 `json:"permissions,omitempty"` + Email string `json:"email" binding:"required,email"` + // RoleIDs are the workspace roles the invitee lands in (at least one). + // RoleID stays accepted as a single-role shorthand. + RoleIDs []uuid.UUID `json:"role_ids,omitempty"` + RoleID *uuid.UUID `json:"role_id,omitempty"` +} + +// Resolved returns the requested role ids, merging the single-role shorthand. +func (r *InviteMemberRequest) Resolved() []uuid.UUID { + ids := append([]uuid.UUID(nil), r.RoleIDs...) + if r.RoleID != nil { + ids = append(ids, *r.RoleID) + } + return dedupeUUIDs(ids) } // UpdateMemberRequest represents the request to update a member's role/permissions type UpdateMemberRequest struct { - Role *string `json:"role,omitempty"` + // RoleIDs replaces the member's assigned role set (at least one). RoleID + // stays accepted as a single-role shorthand. + RoleIDs []uuid.UUID `json:"role_ids,omitempty"` + RoleID *uuid.UUID `json:"role_id,omitempty"` +} + +// Resolved returns the requested role ids, merging the single-role shorthand. +func (r *UpdateMemberRequest) Resolved() []uuid.UUID { + ids := append([]uuid.UUID(nil), r.RoleIDs...) + if r.RoleID != nil { + ids = append(ids, *r.RoleID) + } + return dedupeUUIDs(ids) +} + +func dedupeUUIDs(ids []uuid.UUID) []uuid.UUID { + seen := make(map[uuid.UUID]struct{}, len(ids)) + out := make([]uuid.UUID, 0, len(ids)) + for _, id := range ids { + if id == uuid.Nil { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + return out +} + +// MemberRole is a lightweight role reference for rendering a member's +// assigned roles (chips) without the full permission payload. +type MemberRole struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Color string `json:"color"` +} + +// OrganizationRole is an org-scoped custom role: a named permission set +// members can be assigned to. Editing a role writes through to every +// assigned member's permissions snapshot, so all permission readers stay +// JOIN-free. +type OrganizationRole struct { + ID uuid.UUID `json:"id"` + OrganizationID uuid.UUID `json:"organization_id"` + Name string `json:"name"` + Description string `json:"description"` + Color string `json:"color"` + Permissions OrganizationPermission `json:"permissions"` + MemberCount int `json:"member_count"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// CreateOrganizationRoleRequest creates a custom role. +type CreateOrganizationRoleRequest struct { + Name string `json:"name" binding:"required"` + Description string `json:"description,omitempty"` + Color string `json:"color,omitempty"` + Permissions uint16 `json:"permissions"` +} + +// UpdateOrganizationRoleRequest edits a custom role (edits propagate to +// every member assigned to it). +type UpdateOrganizationRoleRequest struct { + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + Color *string `json:"color,omitempty"` Permissions *uint16 `json:"permissions,omitempty"` } @@ -142,7 +239,22 @@ type TransferOwnershipRequest struct { // AcceptInvitationRequest represents the request to accept an invitation type AcceptInvitationRequest struct { - Token string `json:"token" binding:"required"` + // Either a secure token (public /invite link) or the invitation id (the + // logged-in user accepting from their own pending list). + Token string `json:"token,omitempty"` + InvitationID *uuid.UUID `json:"invitation_id,omitempty"` +} + +// InvitationPreview is the safe, public view of an invitation rendered on the +// /invite landing page. It deliberately omits the token, permissions bitmask, +// and ids — only what a human needs to decide to accept. +type InvitationPreview struct { + OrganizationName string `json:"organization_name"` + OrganizationAvatar string `json:"organization_avatar,omitempty"` + InviterName string `json:"inviter_name,omitempty"` + Email string `json:"email"` + Roles []MemberRole `json:"roles"` + Expired bool `json:"expired"` } // OrganizationCounts represents resource counts for an organization diff --git a/internal/models/organization_permission.go b/internal/models/organization_permission.go index e3f1fe12..8c386499 100644 --- a/internal/models/organization_permission.go +++ b/internal/models/organization_permission.go @@ -1,6 +1,8 @@ package models import ( + "strings" + "database/sql/driver" "fmt" ) @@ -99,6 +101,35 @@ var RolePermissions = map[Role]OrganizationPermission{ RoleViewer: PermViewCampaigns | PermViewContacts | PermViewAnalytics, } +// IsReservedRoleName reports whether a role name collides with the owner +// status (case-insensitive). Owner is a membership flag, never a role row. +func IsReservedRoleName(name string) bool { + return strings.EqualFold(strings.TrimSpace(name), string(RoleOwner)) +} + +// SeedRole is one of the default roles minted for every new workspace. +// They are ordinary rows afterwards: renameable, editable, deletable. +type SeedRole struct { + Name string + Description string + Color string + Permissions OrganizationPermission +} + +// DefaultSeedRoles returns the roles seeded at organization creation, +// mirroring migration 000043 for orgs created after it ran. +func DefaultSeedRoles() []SeedRole { + allDefined := PermManageTeam | PermManageBilling | PermManageCampaigns | PermManageContacts | + PermManageEmails | PermViewAnalytics | PermSendCampaigns | PermAccessUnibox | + PermManageSequences | PermManageSettings | PermViewCampaigns | PermViewContacts | + PermManageAPIKeys | PermUseIntegrations + return []SeedRole{ + {Name: "Admin", Description: "Everything except transferring ownership.", Color: "#8b5cf6", Permissions: allDefined}, + {Name: "Manager", Description: "Runs campaigns, contacts, mailboxes, and integrations. No team, billing, or settings access.", Color: "#10b981", Permissions: GetRolePermissions(RoleManager)}, + {Name: "Viewer", Description: "Read-only access to campaigns, contacts, and reports.", Color: "#f59e0b", Permissions: GetRolePermissions(RoleViewer)}, + } +} + // GetRolePermissions returns the default permissions for a role func GetRolePermissions(role Role) OrganizationPermission { if perms, ok := RolePermissions[role]; ok { diff --git a/internal/models/pagination.go b/internal/models/pagination.go index 2f701a14..26d329e6 100644 --- a/internal/models/pagination.go +++ b/internal/models/pagination.go @@ -1,11 +1,12 @@ package models -import "github.com/google/uuid" - +// Pagination is the keyset list envelope. NextCursor is an OPAQUE token (see +// internal/utils/cursor), not a raw record id, so clients cannot couple to the +// cursor's internal format. A nil NextCursor means there is no next page. type Pagination struct { - Total *int64 `json:"total"` - NextCursor *uuid.UUID `json:"next_cursor"` - HasMore bool `json:"has_more"` + Total *int64 `json:"total"` + NextCursor *string `json:"next_cursor"` + HasMore bool `json:"has_more"` } type CPagination struct { diff --git a/internal/models/sequence.go b/internal/models/sequence.go index 0ea02c85..2bb364ed 100644 --- a/internal/models/sequence.go +++ b/internal/models/sequence.go @@ -42,7 +42,7 @@ type Sequence struct { // ActionConfig is the persisted config for a non-email (action/wait) node. Type // is the switch the task executes on; the remaining fields are type-scoped. type ActionConfig struct { - Type string `json:"type"` // wait | add_tag | remove_tag | unsubscribe | notify | create_task | create_deal | move_deal_stage | end + Type string `json:"type"` // wait | add_tag | remove_tag | label_email | unsubscribe | notify | create_task | create_deal | move_deal_stage | run_automation | http_request | fire_event | end // wait WaitMinutes *int `json:"wait_minutes,omitempty"` @@ -50,9 +50,10 @@ type ActionConfig struct { // add_tag / remove_tag — a contact category id (product "tags" == categories) CategoryID *uuid.UUID `json:"category_id,omitempty"` - // notify — webhook / integration fan-out - NotifyEvent string `json:"notify_event,omitempty"` - NotifyData map[string]any `json:"notify_data,omitempty"` + // label_email — apply unibox conversation labels to the contact's most recent + // thread. Labels are the same registry as contact tags (categories), but in + // the inbox they're "labels", so the field is label_ids. Reply-branch only. + LabelIDs []uuid.UUID `json:"label_ids,omitempty"` // create_task — open a CRM task for the lead when they reach this step // (e.g. a Call task). TaskAssignedTo is the teammate chosen on the step; @@ -82,6 +83,21 @@ type ActionConfig struct { // Values render against the contact ({{.FirstName}} / {{.Company}} etc.). AutomationID *uuid.UUID `json:"automation_id,omitempty"` AutomationValues []ActionKV `json:"automation_values,omitempty"` + + // http_request — a configurable outbound call when the contact reaches this + // step. URL/headers/body are templated against the contact and SSRF-guarded + // (https + no internal targets). Best-effort; failures are logged, not fatal. + HTTPMethod string `json:"http_method,omitempty"` + HTTPURL string `json:"http_url,omitempty"` + HTTPHeaders map[string]string `json:"http_headers,omitempty"` + HTTPBody string `json:"http_body,omitempty"` + + // fire_event — publish a developer-defined custom event to the realtime + // gateway. Subscribers (an API key with REALTIME_SUBSCRIBE on the org + // websocket) receive it with no public URL. EventName + each field value are + // templated against the contact; the fields become the event payload. + EventName string `json:"event_name,omitempty"` + EventFields []ActionKV `json:"event_fields,omitempty"` } // ActionKV is one templated input passed to a launched automation. @@ -133,7 +149,7 @@ type Branch struct { // TargetSequenceID is the step to route to when this branch matches. nil // means STOP (send the contact no further step). A target that no longer // exists (a deleted step) is treated as STOP at schedule time. - TargetSequenceID *uuid.UUID `json:"target_sequence_id"` + TargetSequenceID *uuid.UUID `json:"target_step_id"` // Conditions are ANDed together — every condition must hold for the branch // to match. An empty list is an unconditional/catch-all branch ("otherwise"). Conditions []BranchCondition `json:"conditions,omitempty"` diff --git a/internal/models/webhook.go b/internal/models/webhook.go index 8de9b333..88086035 100644 --- a/internal/models/webhook.go +++ b/internal/models/webhook.go @@ -52,6 +52,11 @@ const ( WebhookEventMeetingBooked WebhookEventType = "meeting.booked" WebhookEventMeetingRescheduled WebhookEventType = "meeting.rescheduled" WebhookEventMeetingCanceled WebhookEventType = "meeting.canceled" + + // Inbound webhook: a trigger only (never emitted outbound). An external + // system POSTs JSON to a per-automation URL, running that one automation with + // the body as the event payload. Routed by its URL token, not org fan-out. + WebhookEventInboundWebhook WebhookEventType = "inbound.webhook" ) // AllWebhookEventTypes lists every emitted event so the CRUD endpoint can @@ -81,6 +86,7 @@ var AllWebhookEventTypes = []WebhookEventType{ WebhookEventMeetingBooked, WebhookEventMeetingRescheduled, WebhookEventMeetingCanceled, + WebhookEventInboundWebhook, } func IsValidWebhookEventType(s string) bool { diff --git a/internal/pkg/safehttp/safehttp.go b/internal/pkg/safehttp/safehttp.go new file mode 100644 index 00000000..d59eb935 --- /dev/null +++ b/internal/pkg/safehttp/safehttp.go @@ -0,0 +1,120 @@ +// Package safehttp provides an HTTP client hardened against SSRF for requests to +// user-supplied URLs (webhook deliveries, the HTTP-request automation action, +// integration action webhooks). +// +// The literal-IP check most apps ship is not enough: a hostname like evil.com +// can have a DNS A-record pointing at 169.254.169.254 (cloud metadata), 10.x, or +// 127.0.0.1, and DNS rebinding can flip a host from public at validation time to +// private at fetch time. So the guard runs at DIAL time: it resolves the host, +// refuses to connect if ANY resolved address is non-public, and dials the +// validated IP directly (no second lookup), which closes the rebinding window. +// TLS still uses the original hostname for SNI/cert verification, so HTTPS is +// unaffected. Redirects are re-validated and capped. +package safehttp + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "os" + "strings" + "time" + + "github.com/rs/zerolog/log" +) + +// ErrBlockedAddress is returned when a request targets a non-public address. +var ErrBlockedAddress = errors.New("destination address is not publicly routable") + +// allowUnsafe mirrors the existing WARMBLY_ALLOW_UNSAFE_WEBHOOK_URLS escape hatch +// so local/self-hosted development can reach private hosts. +func allowUnsafe() bool { + return strings.EqualFold(os.Getenv("WARMBLY_ALLOW_UNSAFE_WEBHOOK_URLS"), "true") +} + +// IsBlockedIP reports whether an IP must never be dialed for a user-supplied URL: +// loopback, RFC1918 private, IPv6 ULA, link-local (includes the 169.254.169.254 +// cloud metadata endpoint), multicast, unspecified, carrier-grade NAT, and the +// 0.0.0.0/8 "this network" range. +func IsBlockedIP(ip net.IP) bool { + if ip == nil { + return true + } + if v4 := ip.To4(); v4 != nil { + ip = v4 + } + if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || + ip.IsLinkLocalMulticast() || ip.IsMulticast() || ip.IsUnspecified() { + return true + } + if v4 := ip.To4(); v4 != nil { + // 0.0.0.0/8 "this network" and 100.64.0.0/10 carrier-grade NAT. + if v4[0] == 0 { + return true + } + if v4[0] == 100 && v4[1] >= 64 && v4[1] <= 127 { + return true + } + } + return false +} + +// safeDialContext resolves addr's host, blocks the connection if any resolved IP +// is non-public, and dials the validated IP directly so no rebinding can occur +// between validation and connect. +func safeDialContext(dialer *net.Dialer) func(context.Context, string, string) (net.Conn, error) { + return func(ctx context.Context, network, addr string) (net.Conn, error) { + if allowUnsafe() { + return dialer.DialContext(ctx, network, addr) + } + host, port, err := net.SplitHostPort(addr) + if err != nil { + return nil, err + } + ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host) + if err != nil { + return nil, err + } + if len(ips) == 0 { + return nil, ErrBlockedAddress + } + // Fail closed: one private answer in a mixed result set is enough to block, + // so an attacker can't slip a private IP past us alongside a public one. + for _, ip := range ips { + if IsBlockedIP(ip) { + log.Warn().Str("host", host).Str("ip", ip.String()).Msg("safehttp: blocked SSRF attempt to non-public address") + return nil, ErrBlockedAddress + } + } + return dialer.DialContext(ctx, network, net.JoinHostPort(ips[0].String(), port)) + } +} + +// Client returns an SSRF-hardened *http.Client with the given overall timeout. +func Client(timeout time.Duration) *http.Client { + dialer := &net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second} + transport := &http.Transport{ + DialContext: safeDialContext(dialer), + ForceAttemptHTTP2: true, + MaxIdleConns: 100, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + } + return &http.Client{ + Timeout: timeout, + Transport: transport, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= 5 { + return errors.New("too many redirects") + } + // The dialer re-checks the IP on every hop; also keep the scheme safe. + if !allowUnsafe() && req.URL.Scheme != "https" { + return fmt.Errorf("insecure redirect to %s", req.URL.Scheme) + } + return nil + }, + } +} diff --git a/internal/repository/pg_admin.go b/internal/repository/pg_admin.go index f782b1c5..98896579 100644 --- a/internal/repository/pg_admin.go +++ b/internal/repository/pg_admin.go @@ -12,6 +12,7 @@ import ( "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/utils/paging" ) // AdminRepository defines the interface for admin data access @@ -291,7 +292,7 @@ func (r *adminRepository) SearchUsers(ctx context.Context, search *models.AdminU if len(users) > limit { result.Data = users[:limit] lastID := users[limit-1].ID - result.Pagination.NextCursor = &lastID + result.Pagination.NextCursor = paging.UUIDString(lastID) } // Get total count @@ -607,7 +608,7 @@ func (r *adminRepository) GetUserEmails(ctx context.Context, userID uuid.UUID, c if len(emails) > limit { emails = emails[:limit] - pagination.NextCursor = &emails[limit-1].ID + pagination.NextCursor = paging.UUIDString(emails[limit-1].ID) } return emails, pagination, nil @@ -680,7 +681,7 @@ func (r *adminRepository) ListAdmins(ctx context.Context, cursor *uuid.UUID, lim if len(admins) > limit { result.Data = admins[:limit] lastID := admins[limit-1].ID - result.Pagination.NextCursor = &lastID + result.Pagination.NextCursor = paging.UUIDString(lastID) } return result, nil @@ -741,7 +742,7 @@ func (r *adminRepository) ListWorkers(ctx context.Context, cursor *uuid.UUID, li if len(workers) > limit { result.Data = workers[:limit] lastID := workers[limit-1].ID - result.Pagination.NextCursor = &lastID + result.Pagination.NextCursor = paging.UUIDString(lastID) } return result, nil @@ -877,7 +878,7 @@ func (r *adminRepository) GetWorkerEmails(ctx context.Context, workerID uuid.UUI if len(emails) > limit { emails = emails[:limit] - pagination.NextCursor = &emails[limit-1].ID + pagination.NextCursor = paging.UUIDString(emails[limit-1].ID) } return emails, pagination, nil @@ -1024,7 +1025,7 @@ func (r *adminRepository) GetPoolParticipants(ctx context.Context, poolType stri } if len(participants) > limit { result.Data = participants[:limit] - result.Pagination.NextCursor = &participants[limit-1].ID + result.Pagination.NextCursor = paging.UUIDString(participants[limit-1].ID) } return result, nil @@ -1095,7 +1096,7 @@ func (r *adminRepository) ListBlockedAccounts(ctx context.Context, cursor *uuid. } if len(accounts) > limit { result.Data = accounts[:limit] - result.Pagination.NextCursor = &accounts[limit-1].ID + result.Pagination.NextCursor = paging.UUIDString(accounts[limit-1].ID) } return result, nil @@ -1219,7 +1220,7 @@ func (r *adminRepository) ListAppeals(ctx context.Context, status string, cursor if len(appeals) > limit { result.Data = appeals[:limit] lastID := appeals[limit-1].ID - result.Pagination.NextCursor = &lastID + result.Pagination.NextCursor = paging.UUIDString(lastID) } return result, nil @@ -1505,7 +1506,7 @@ func (r *adminRepository) SearchCampaigns(ctx context.Context, search *models.Ad if len(campaigns) > limit { result.Data = campaigns[:limit] lastID := campaigns[limit-1].ID - result.Pagination.NextCursor = &lastID + result.Pagination.NextCursor = paging.UUIDString(lastID) } // Total count for the same filter — drop the trailing LIMIT arg. @@ -1676,7 +1677,7 @@ func (r *adminRepository) SearchAuditLogs(ctx context.Context, search *models.Ad if len(logs) > limit { result.Data = logs[:limit] lastID := logs[limit-1].ID - result.Pagination.NextCursor = &lastID + result.Pagination.NextCursor = paging.UUIDString(lastID) } return result, nil @@ -2042,7 +2043,7 @@ func (r *adminRepository) SearchPlansForAdmin(ctx context.Context, search *model if len(plans) > limit { result.Data = plans[:limit] last := plans[limit-1].ID - result.Pagination.NextCursor = &last + result.Pagination.NextCursor = paging.UUIDString(last) } countQuery := `SELECT COUNT(*) FROM plans p LEFT JOIN durations d ON d.id = p.duration_id ` + where @@ -2322,7 +2323,7 @@ func (r *adminRepository) ListEnterpriseInquiries(ctx context.Context, search *m if len(inquiries) > limit { result.Data = inquiries[:limit] lastID := inquiries[limit-1].ID - result.Pagination.NextCursor = &lastID + result.Pagination.NextCursor = paging.UUIDString(lastID) } // Total count for the same filter — drop the trailing LIMIT arg. @@ -2650,7 +2651,7 @@ func (r *adminRepository) SearchMailboxesForAdmin(ctx context.Context, search *m if len(items) > limit { result.Data = items[:limit] last := items[limit-1].ID - result.Pagination.NextCursor = &last + result.Pagination.NextCursor = paging.UUIDString(last) } // Total count for the same filter (drop the trailing LIMIT arg). diff --git a/internal/repository/pg_admin_outreach.go b/internal/repository/pg_admin_outreach.go index 277876d0..4439d253 100644 --- a/internal/repository/pg_admin_outreach.go +++ b/internal/repository/pg_admin_outreach.go @@ -8,6 +8,7 @@ import ( "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/utils/paging" ) // AdminOutreachRepository is the persistence layer for the @@ -199,7 +200,7 @@ func (r *adminOutreachRepository) Search(ctx context.Context, search *models.Adm if len(items) > limit { result.Data = items[:limit] last := items[limit-1].ID - result.Pagination.NextCursor = &last + result.Pagination.NextCursor = paging.UUIDString(last) } countQuery := `SELECT COUNT(*) FROM admin_outreach_messages m JOIN users s ON s.id = m.sent_by LEFT JOIN users u ON u.id = m.to_user_id ` + where diff --git a/internal/repository/pg_analytics.go b/internal/repository/pg_analytics.go index 65ca5828..940f46ba 100644 --- a/internal/repository/pg_analytics.go +++ b/internal/repository/pg_analytics.go @@ -95,6 +95,7 @@ func (r *analyticsRepository) GetCampaignSummary(ctx context.Context, userID, ca COUNT(CASE WHEN ccp.sent_at IS NOT NULL THEN 1 END) as emails_sent, COUNT(CASE WHEN ccp.sent_at IS NULL THEN 1 END) as emails_pending, COUNT(CASE WHEN ccp.opened_at IS NOT NULL THEN 1 END) as unique_opens, + COUNT(CASE WHEN ccp.opened_at IS NOT NULL AND ccp.opened_machine THEN 1 END) as machine_opens, COUNT(CASE WHEN ccp.clicked_at IS NOT NULL THEN 1 END) as unique_clicks, COUNT(CASE WHEN ccp.replied_at IS NOT NULL THEN 1 END) as replies, COUNT(CASE WHEN ccp.bounced_at IS NOT NULL THEN 1 END) as bounces @@ -111,6 +112,7 @@ func (r *analyticsRepository) GetCampaignSummary(ctx context.Context, userID, ca &summary.EmailsSent, &summary.EmailsPending, &summary.UniqueOpens, + &summary.MachineOpens, &summary.UniqueClicks, &summary.Replies, &summary.Bounces, @@ -336,27 +338,29 @@ func (r *analyticsRepository) GetContactCounts(ctx context.Context, userID uuid. // Dashboard Analytics Methods -func (r *analyticsRepository) GetDashboardOverallStats(ctx context.Context, userID uuid.UUID, from, to time.Time) (*models.DashboardOverallStats, *errx.Error) { +func (r *analyticsRepository) GetDashboardOverallStats(ctx context.Context, orgID uuid.UUID, from, to time.Time) (*models.DashboardOverallStats, *errx.Error) { query := ` SELECT COUNT(CASE WHEN ccp.sent_at IS NOT NULL AND ccp.sent_at >= $2 AND ccp.sent_at <= $3 THEN 1 END) as total_sent, COUNT(CASE WHEN ccp.opened_at IS NOT NULL AND ccp.sent_at >= $2 AND ccp.sent_at <= $3 THEN 1 END) as total_opens, + COUNT(CASE WHEN ccp.opened_at IS NOT NULL AND ccp.opened_machine AND ccp.sent_at >= $2 AND ccp.sent_at <= $3 THEN 1 END) as machine_opens, COUNT(CASE WHEN ccp.clicked_at IS NOT NULL AND ccp.sent_at >= $2 AND ccp.sent_at <= $3 THEN 1 END) as total_clicks, COUNT(CASE WHEN ccp.replied_at IS NOT NULL AND ccp.sent_at >= $2 AND ccp.sent_at <= $3 THEN 1 END) as total_replies, COUNT(CASE WHEN ccp.bounced_at IS NOT NULL AND ccp.sent_at >= $2 AND ccp.sent_at <= $3 THEN 1 END) as total_bounces, - (SELECT COUNT(*) FROM campaigns WHERE user_id = $1 AND status = 'active') as active_campaigns, - (SELECT COUNT(*) FROM email_accounts WHERE user_id = $1 AND status = 'active') as active_accounts + (SELECT COUNT(*) FROM campaigns WHERE organization_id = $1 AND status = 'active') as active_campaigns, + (SELECT COUNT(*) FROM email_accounts WHERE organization_id = $1 AND status = 'active') as active_accounts FROM campaign_contact_progress ccp JOIN campaigns c ON c.id = ccp.campaign_id - WHERE c.user_id = $1 + WHERE c.organization_id = $1 ` - params := []any{userID, from, to} + params := []any{orgID, from, to} var stats models.DashboardOverallStats err := r.DB.QueryRow(ctx, query, params...).Scan( &stats.TotalEmailsSent, &stats.TotalOpens, + &stats.MachineOpens, &stats.TotalClicks, &stats.TotalReplies, &stats.TotalBounces, @@ -379,7 +383,7 @@ func (r *analyticsRepository) GetDashboardOverallStats(ctx context.Context, user return &stats, nil } -func (r *analyticsRepository) GetRecentActivity(ctx context.Context, userID uuid.UUID, limit int) ([]models.RecentActivityItem, *errx.Error) { +func (r *analyticsRepository) GetRecentActivity(ctx context.Context, orgID uuid.UUID, limit int) ([]models.RecentActivityItem, *errx.Error) { // Union query to get recent opens, clicks, replies, and bounces query := ` WITH recent_events AS ( @@ -389,7 +393,7 @@ func (r *analyticsRepository) GetRecentActivity(ctx context.Context, userID uuid FROM campaign_contact_progress ccp JOIN campaigns c ON c.id = ccp.campaign_id JOIN contacts co ON co.id = ccp.contact_id - WHERE c.user_id = $1 AND ccp.opened_at IS NOT NULL + WHERE c.organization_id = $1 AND ccp.opened_at IS NOT NULL UNION ALL @@ -399,7 +403,7 @@ func (r *analyticsRepository) GetRecentActivity(ctx context.Context, userID uuid FROM campaign_contact_progress ccp JOIN campaigns c ON c.id = ccp.campaign_id JOIN contacts co ON co.id = ccp.contact_id - WHERE c.user_id = $1 AND ccp.clicked_at IS NOT NULL + WHERE c.organization_id = $1 AND ccp.clicked_at IS NOT NULL UNION ALL @@ -409,7 +413,7 @@ func (r *analyticsRepository) GetRecentActivity(ctx context.Context, userID uuid FROM campaign_contact_progress ccp JOIN campaigns c ON c.id = ccp.campaign_id JOIN contacts co ON co.id = ccp.contact_id - WHERE c.user_id = $1 AND ccp.replied_at IS NOT NULL + WHERE c.organization_id = $1 AND ccp.replied_at IS NOT NULL UNION ALL @@ -419,7 +423,7 @@ func (r *analyticsRepository) GetRecentActivity(ctx context.Context, userID uuid FROM campaign_contact_progress ccp JOIN campaigns c ON c.id = ccp.campaign_id JOIN contacts co ON co.id = ccp.contact_id - WHERE c.user_id = $1 AND ccp.bounced_at IS NOT NULL + WHERE c.organization_id = $1 AND ccp.bounced_at IS NOT NULL ) SELECT type, campaign_id, campaign_name, contact_email, contact_id, timestamp, COALESCE(link, '') as link FROM recent_events @@ -427,7 +431,7 @@ func (r *analyticsRepository) GetRecentActivity(ctx context.Context, userID uuid LIMIT $2 ` - params := []any{userID, limit} + params := []any{orgID, limit} rows, err := r.DB.Query(ctx, query, params...) if err != nil { @@ -449,7 +453,7 @@ func (r *analyticsRepository) GetRecentActivity(ctx context.Context, userID uuid return activities, nil } -func (r *analyticsRepository) GetTopCampaigns(ctx context.Context, userID uuid.UUID, from, to time.Time, limit int, sortBy string) ([]models.TopCampaignStats, *errx.Error) { +func (r *analyticsRepository) GetTopCampaigns(ctx context.Context, orgID uuid.UUID, from, to time.Time, limit int, sortBy string) ([]models.TopCampaignStats, *errx.Error) { // Default sort by emails_sent orderClause := "emails_sent DESC" switch sortBy { @@ -479,14 +483,14 @@ func (r *analyticsRepository) GetTopCampaigns(ctx context.Context, userID uuid.U FROM campaigns c LEFT JOIN campaign_contact_progress ccp ON ccp.campaign_id = c.id AND ccp.sent_at >= $2 AND ccp.sent_at <= $3 - WHERE c.user_id = $1 + WHERE c.organization_id = $1 GROUP BY c.id, c.name, c.status HAVING COUNT(CASE WHEN ccp.sent_at IS NOT NULL THEN 1 END) > 0 ORDER BY ` + orderClause + ` LIMIT $4 ` - params := []any{userID, from, to, limit} + params := []any{orgID, from, to, limit} rows, err := r.DB.Query(ctx, query, params...) if err != nil { @@ -508,7 +512,7 @@ func (r *analyticsRepository) GetTopCampaigns(ctx context.Context, userID uuid.U return campaigns, nil } -func (r *analyticsRepository) GetDashboardDailyTrend(ctx context.Context, userID uuid.UUID, from, to time.Time) ([]models.DashboardDailyStats, *errx.Error) { +func (r *analyticsRepository) GetDashboardDailyTrend(ctx context.Context, orgID uuid.UUID, from, to time.Time) ([]models.DashboardDailyStats, *errx.Error) { query := ` SELECT sent_at::date::text as date, @@ -518,7 +522,7 @@ func (r *analyticsRepository) GetDashboardDailyTrend(ctx context.Context, userID COUNT(CASE WHEN replied_at IS NOT NULL THEN 1 END) as replies FROM campaign_contact_progress ccp JOIN campaigns c ON c.id = ccp.campaign_id - WHERE c.user_id = $1 + WHERE c.organization_id = $1 AND ccp.sent_at IS NOT NULL AND ccp.sent_at::date >= $2 AND ccp.sent_at::date <= $3 @@ -526,7 +530,7 @@ func (r *analyticsRepository) GetDashboardDailyTrend(ctx context.Context, userID ORDER BY sent_at::date ASC ` - params := []any{userID, from, to} + params := []any{orgID, from, to} rows, err := r.DB.Query(ctx, query, params...) if err != nil { @@ -548,7 +552,7 @@ func (r *analyticsRepository) GetDashboardDailyTrend(ctx context.Context, userID return stats, nil } -func (r *analyticsRepository) GetAccountHealthSummary(ctx context.Context, userID uuid.UUID) (*models.AccountHealthSummary, *errx.Error) { +func (r *analyticsRepository) GetAccountHealthSummary(ctx context.Context, orgID uuid.UUID) (*models.AccountHealthSummary, *errx.Error) { query := ` SELECT COUNT(*) as total, @@ -565,13 +569,13 @@ func (r *analyticsRepository) GetAccountHealthSummary(ctx context.Context, userI WHERE eae.email_account_id = ea.id AND eae.resolved_at IS NULL AND eae.severity = 'CRITICAL' ) THEN 1 END) as error FROM email_accounts ea - WHERE ea.user_id = $1 + WHERE ea.organization_id = $1 ` var summary models.AccountHealthSummary - err := r.DB.QueryRow(ctx, query, userID).Scan(&summary.TotalAccounts, &summary.HealthyAccounts, &summary.WarningAccounts, &summary.ErrorAccounts) + err := r.DB.QueryRow(ctx, query, orgID).Scan(&summary.TotalAccounts, &summary.HealthyAccounts, &summary.WarningAccounts, &summary.ErrorAccounts) if err != nil { - db.CaptureError(err, query, []any{userID}, "queryrow") + db.CaptureError(err, query, []any{orgID}, "queryrow") return nil, errx.InternalError() } diff --git a/internal/repository/pg_api_key.go b/internal/repository/pg_api_key.go index 4193e57e..0015fa40 100644 --- a/internal/repository/pg_api_key.go +++ b/internal/repository/pg_api_key.go @@ -12,6 +12,7 @@ import ( "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/infrastructure/db" "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/utils/paging" ) type APIKeyRepository interface { @@ -177,11 +178,11 @@ func (r *apiKeyRepository) List(ctx context.Context, orgID uuid.UUID, limit int, keys = append(keys, key) } - var nextCursor *uuid.UUID + var nextCursor *string hasMore := false if len(keys) > limit { hasMore = true - nextCursor = &keys[limit].ID + nextCursor = paging.EncodeUUID(keys[limit].ID) keys = keys[:limit] } @@ -501,11 +502,11 @@ func (r *apiKeyRepository) ListUsageLogs(ctx context.Context, orgID, keyID uuid. logs = append(logs, l) } - var nextCursor *uuid.UUID + var nextCursor *string hasMore := false if len(logs) > limit { hasMore = true - nextCursor = &logs[limit].ID + nextCursor = paging.EncodeUUID(logs[limit].ID) logs = logs[:limit] } diff --git a/internal/repository/pg_audit.go b/internal/repository/pg_audit.go index b1b06762..fe45f16f 100644 --- a/internal/repository/pg_audit.go +++ b/internal/repository/pg_audit.go @@ -8,6 +8,7 @@ import ( "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/utils/paging" ) // AuditRepository persists and queries the organization-wide audit trail. @@ -183,8 +184,7 @@ func (r *auditRepository) Search(ctx context.Context, params *models.AuditLogSea } if len(logs) > limit { result.Data = logs[:limit] - next := logs[limit-1].ID.String() - result.Pagination.NextCursor = &next + result.Pagination.NextCursor = paging.EncodeUUID(logs[limit-1].ID) } return result, nil diff --git a/internal/repository/pg_auth.go b/internal/repository/pg_auth.go index 07230309..c86c0e78 100644 --- a/internal/repository/pg_auth.go +++ b/internal/repository/pg_auth.go @@ -19,6 +19,7 @@ type AuthRepository interface { IsValidCredentials(ctx context.Context, email, password string) (uuid.UUID, *errx.Error) ExternalLogin(ctx context.Context, email string) (*models.User, *errx.Error) ResetPassword(ctx context.Context, userID uuid.UUID, password string) *errx.Error + GetPasswordHash(ctx context.Context, userID uuid.UUID) (string, *errx.Error) } type authRepository struct { @@ -113,6 +114,24 @@ func (r *authRepository) ExternalLogin(ctx context.Context, email string) (*mode return &u, nil } +// GetPasswordHash returns the stored argon2 hash for a user (empty when the +// account is OAuth-only / passwordless). +func (r *authRepository) GetPasswordHash(ctx context.Context, userID uuid.UUID) (string, *errx.Error) { + var hash *string + err := r.DB.QueryRow(ctx, `SELECT password_hash FROM users WHERE id = $1`, userID).Scan(&hash) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return "", errx.ErrNotFound + } + db.CaptureError(err, "get password hash", []any{userID}, "queryrow") + return "", errx.InternalError() + } + if hash == nil { + return "", nil + } + return *hash, nil +} + func (r *authRepository) ResetPassword(ctx context.Context, userID uuid.UUID, passwordHash string) *errx.Error { query := ` UPDATE users diff --git a/internal/repository/pg_campaign.go b/internal/repository/pg_campaign.go index df06bfb8..997fc2a0 100644 --- a/internal/repository/pg_campaign.go +++ b/internal/repository/pg_campaign.go @@ -16,6 +16,7 @@ import ( "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/infrastructure/db" "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/utils/paging" "github.com/warmbly/warmbly/internal/utils/validate" ) @@ -44,6 +45,10 @@ type CampaignRepository interface { StopCampaign(ctx context.Context, campaignID uuid.UUID) error ValidateCampaignReady(ctx context.Context, campaignID uuid.UUID) error GetPendingCampaignTasks(ctx context.Context, campaignID uuid.UUID) ([]Task, error) + // ListCampaignScheduleCandidates returns active campaigns that have NO pending + // task — their self-perpetuating chain died and needs re-seeding. Used by the + // campaign reconciler. + ListCampaignScheduleCandidates(ctx context.Context, limit int) ([]uuid.UUID, error) CountActiveForOrganization(ctx context.Context, orgID uuid.UUID) (int, error) AccountHasActiveCampaign(ctx context.Context, accountID uuid.UUID) (bool, error) // CountActiveCampaignsForAccount returns how many active campaigns send @@ -563,7 +568,7 @@ func (r *campaignRepository) Create(ctx context.Context, userID string, orgID *u return &campaign, nil } -func (r *campaignRepository) Get(ctx context.Context, userID, id string) (*models.Campaign, error) { +func (r *campaignRepository) Get(ctx context.Context, orgID, id string) (*models.Campaign, error) { var campaign models.Campaign query := fmt.Sprintf( @@ -571,13 +576,13 @@ func (r *campaignRepository) Get(ctx context.Context, userID, id string) (*model FROM campaigns c LEFT JOIN campaign_email_tags cet ON cet.campaign_id = c.id LEFT JOIN campaign_folders cec ON cec.campaign_id = c.id - WHERE c.user_id = $1 AND c.id = $2 + WHERE c.organization_id = $1 AND c.id = $2 GROUP BY c.id`, CAMPAIGN_SELECT_FULL, ) params := []any{ - userID, + orgID, id, } @@ -598,7 +603,7 @@ func (r *campaignRepository) Get(ctx context.Context, userID, id string) (*model return &campaign, nil } -func (r *campaignRepository) Search(ctx context.Context, userID, query string, cursor, folder *string, limit int32) (*models.CampaignsResult, error) { +func (r *campaignRepository) Search(ctx context.Context, orgID, query string, cursor, folder *string, limit int32) (*models.CampaignsResult, error) { tx, err := r.DB.Begin(ctx) if err != nil { db.CaptureError(err, "", nil, "begin") @@ -612,7 +617,7 @@ func (r *campaignRepository) Search(ctx context.Context, userID, query string, c FROM campaigns c LEFT JOIN campaign_email_tags cet ON cet.campaign_id = c.id LEFT JOIN campaign_folders cec ON cec.campaign_id = c.id - WHERE user_id = $1 + WHERE c.organization_id = $1 AND ($2::uuid IS NULL OR (c.created_at, c.id) < ( SELECT created_at, id FROM campaigns @@ -634,7 +639,7 @@ func (r *campaignRepository) Search(ctx context.Context, userID, query string, c SELECT COUNT(DISTINCT c.id) FROM campaigns c LEFT JOIN campaign_folders cec ON cec.campaign_id = c.id - WHERE user_id = $1 + WHERE c.organization_id = $1 AND ($2 = '' OR c.name ILIKE '%%' || $2 || '%%') AND ($3::uuid IS NULL OR EXISTS ( SELECT 1 FROM campaign_folders cf WHERE cf.campaign_id = c.id AND cf.folder_id = $3 @@ -643,7 +648,7 @@ func (r *campaignRepository) Search(ctx context.Context, userID, query string, c } params := []any{ - userID, + orgID, cursor, query, folder, @@ -675,22 +680,22 @@ func (r *campaignRepository) Search(ctx context.Context, userID, query string, c } var total *int64 - var nextCursor *uuid.UUID + var nextCursor *string var hasMore bool if len(campaigns) > int(limit) { hasMore = true - nextCursor = &campaigns[limit].ID + nextCursor = paging.EncodeUUID(campaigns[limit].ID) campaigns = campaigns[:limit] } if cursor == nil && countSQL != "" { params := []any{ - userID, + orgID, query, folder, } var tmp int64 - err = tx.QueryRow(ctx, countSQL, userID, query, folder).Scan(&tmp) + err = tx.QueryRow(ctx, countSQL, orgID, query, folder).Scan(&tmp) if err != nil { db.CaptureError(err, countSQL, params, "queryrow") return nil, err @@ -1337,6 +1342,40 @@ func (r *campaignRepository) GetPendingCampaignTasks(ctx context.Context, campai return tasks, rows.Err() } +// ListCampaignScheduleCandidates returns active campaigns with no pending task, +// i.e. chains that stalled (a swallowed enqueue or a crash between ticks left no +// successor). The reconciler re-seeds each. createCampaignTask's advisory lock +// makes a concurrent real-tick enqueue safe (one wins, the other no-ops). +func (r *campaignRepository) ListCampaignScheduleCandidates(ctx context.Context, limit int) ([]uuid.UUID, error) { + query := ` + SELECT c.id + FROM campaigns c + WHERE c.status = 'active' + AND NOT EXISTS ( + SELECT 1 + FROM campaign_tasks ct + JOIN tasks t ON t.id = ct.task_id + WHERE ct.campaign_id = c.id AND t.status = 'pending' + ) + LIMIT $1` + + rows, err := r.DB.Query(ctx, query, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var ids []uuid.UUID + for rows.Next() { + var id uuid.UUID + if err := rows.Scan(&id); err != nil { + return nil, err + } + ids = append(ids, id) + } + return ids, rows.Err() +} + func (r *campaignRepository) CountActiveForOrganization(ctx context.Context, orgID uuid.UUID) (int, error) { query := `SELECT COUNT(*) FROM campaigns WHERE organization_id = $1 AND status = 'active'` var count int diff --git a/internal/repository/pg_campaign_progress.go b/internal/repository/pg_campaign_progress.go index 26051d6d..68181195 100644 --- a/internal/repository/pg_campaign_progress.go +++ b/internal/repository/pg_campaign_progress.go @@ -70,7 +70,7 @@ type CampaignSequencePair struct { type CampaignProgressRepository interface { // Record email status RecordEmailSent(ctx context.Context, campaignID, contactID, sequenceID uuid.UUID) error - RecordEmailOpened(ctx context.Context, campaignID, contactID, sequenceID uuid.UUID) error + RecordEmailOpened(ctx context.Context, campaignID, contactID, sequenceID uuid.UUID, machine bool) error RecordEmailClicked(ctx context.Context, campaignID, contactID, sequenceID uuid.UUID) error RecordEmailReplied(ctx context.Context, campaignID, contactID, sequenceID uuid.UUID) error RecordEmailBounced(ctx context.Context, campaignID, contactID, sequenceID uuid.UUID) error @@ -144,18 +144,22 @@ func (r *campaignProgressRepository) RecordEmailSent(ctx context.Context, campai return err } -// RecordEmailOpened records that an email was opened -func (r *campaignProgressRepository) RecordEmailOpened(ctx context.Context, campaignID, contactID, sequenceID uuid.UUID) error { +// RecordEmailOpened records that an email was opened. machine marks automated +// fetches (Apple MPP prefetch, UA-less clients): the first open stamps +// opened_at with the flag, and a later HUMAN open upgrades a machine open to +// human (keeping the original timestamp). Human opens are never downgraded. +func (r *campaignProgressRepository) RecordEmailOpened(ctx context.Context, campaignID, contactID, sequenceID uuid.UUID, machine bool) error { query := ` UPDATE campaign_contact_progress - SET opened_at = NOW() + SET opened_at = COALESCE(opened_at, NOW()), + opened_machine = $4 WHERE campaign_id = $1 AND contact_id = $2 AND sequence_id = $3 - AND opened_at IS NULL + AND (opened_at IS NULL OR (opened_machine = true AND $4 = false)) ` - _, err := r.db.Exec(ctx, query, campaignID, contactID, sequenceID) + _, err := r.db.Exec(ctx, query, campaignID, contactID, sequenceID, machine) return err } diff --git a/internal/repository/pg_contact.go b/internal/repository/pg_contact.go index 2866802b..58b04cd7 100644 --- a/internal/repository/pg_contact.go +++ b/internal/repository/pg_contact.go @@ -19,6 +19,7 @@ import ( "github.com/warmbly/warmbly/internal/pkg/emailverify" "github.com/warmbly/warmbly/internal/pkg/encrypt" "github.com/warmbly/warmbly/internal/utils" + "github.com/warmbly/warmbly/internal/utils/paging" ) type ContactRepository interface { @@ -562,7 +563,7 @@ func (r *contactRepository) GetByIDsAndOrganization(ctx context.Context, organiz func (r *contactRepository) Search( ctx context.Context, - userID string, + orgID string, category, cursor *string, filters models.SearchContacts, @@ -572,15 +573,11 @@ func (r *contactRepository) Search( var args []any argIndex := 1 - if filters.Offset < 0 { - filters.Offset = 0 - } - // ----------------------------- // Base filter: user_id // ----------------------------- - whereClauses = append(whereClauses, fmt.Sprintf("c.user_id = $%d", argIndex)) - args = append(args, userID) + whereClauses = append(whereClauses, fmt.Sprintf("c.organization_id = $%d", argIndex)) + args = append(args, orgID) argIndex++ // ----------------------------- @@ -857,7 +854,7 @@ func (r *contactRepository) Search( FROM campaign_leads cl2 JOIN campaigns cam ON cl2.campaign_id = cam.id WHERE cl2.contact_id = c.id - AND cam.user_id = $%d + AND cam.organization_id = $%d ), '[]'::json ) AS campaigns, COALESCE( @@ -881,7 +878,7 @@ func (r *contactRepository) Search( LIMIT $%d `, argIndex, argIndex, leadProgressSelect, whereSQL, sortBy, direction, argIndex+1) - args = append(args, userID, limit+1) + args = append(args, orgID, limit+1) // Skip total count if cursor exists var totalCount *int64 @@ -1009,12 +1006,12 @@ func (r *contactRepository) Search( } // Next cursor - var nextCursor *uuid.UUID + var nextCursor *string var hasMore bool if len(contacts) > int(limit) { hasMore = true nextID := contacts[limit].ID - nextCursor = &nextID + nextCursor = paging.EncodeUUID(nextID) contacts = contacts[:limit] } @@ -1749,7 +1746,13 @@ func (r *contactRepository) ExportAll(ctx context.Context, userID string, filter if !page.Pagination.HasMore || page.Pagination.NextCursor == nil { break } - s := page.Pagination.NextCursor.String() + // NextCursor is now an opaque token; decode it back to the id the next + // Search call keys on. + id, derr := paging.DecodeUUID(*page.Pagination.NextCursor) + if derr != nil { + break + } + s := id.String() cursor = &s } return out, nil @@ -1984,10 +1987,10 @@ func (r *contactRepository) ListSentEmails(ctx context.Context, userID, contactI } hasMore := false - var nextCursor *uuid.UUID + var nextCursor *string if len(out) > limit { hasMore = true - nextCursor = &out[limit].TaskID + nextCursor = paging.EncodeUUID(out[limit].TaskID) out = out[:limit] } diff --git a/internal/repository/pg_crm.go b/internal/repository/pg_crm.go index 56116789..c2197c5b 100644 --- a/internal/repository/pg_crm.go +++ b/internal/repository/pg_crm.go @@ -11,6 +11,7 @@ import ( "github.com/jackc/pgx/v5/pgxpool" "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/utils/paging" ) type CRMRepository interface { @@ -143,11 +144,11 @@ func (r *crmRepository) ListNotes(ctx context.Context, orgID, contactID uuid.UUI notes = append(notes, note) } - var nextCursor *uuid.UUID + var nextCursor *string hasMore := false if len(notes) > limit { hasMore = true - nextCursor = ¬es[limit].ID + nextCursor = paging.EncodeUUID(notes[limit].ID) notes = notes[:limit] } @@ -235,11 +236,11 @@ func (r *crmRepository) ListActivities(ctx context.Context, orgID, contactID uui activities = append(activities, a) } - var nextCursor *uuid.UUID + var nextCursor *string hasMore := false if len(activities) > limit { hasMore = true - nextCursor = &activities[limit].ID + nextCursor = paging.EncodeUUID(activities[limit].ID) activities = activities[:limit] } @@ -654,11 +655,11 @@ func (r *crmRepository) ListDeals(ctx context.Context, orgID uuid.UUID, pipeline deals = append(deals, deal) } - var nextCursor *uuid.UUID + var nextCursor *string hasMore := false if len(deals) > limit { hasMore = true - nextCursor = &deals[limit].ID + nextCursor = paging.EncodeUUID(deals[limit].ID) deals = deals[:limit] } @@ -982,21 +983,14 @@ func (r *crmRepository) SearchDeals(ctx context.Context, orgID uuid.UUID, filter } hasMore := int64(offset+len(deals)) < total - var nextOffset *int + pag := models.Pagination{Total: &total, HasMore: hasMore} if hasMore { - n := offset + limit - nextOffset = &n + pag.NextCursor = paging.EncodeOffset(offset + limit) } return &models.DealsSearchResult{ - Data: deals, - Pagination: models.DealsSearchPagination{ - Total: total, - Limit: limit, - Offset: offset, - HasMore: hasMore, - NextOffset: nextOffset, - }, + Data: deals, + Pagination: pag, }, nil } @@ -1177,11 +1171,11 @@ func (r *crmRepository) ListCRMTasks(ctx context.Context, orgID uuid.UUID, conta tasks = append(tasks, task) } - var nextCursor *uuid.UUID + var nextCursor *string hasMore := false if len(tasks) > limit { hasMore = true - nextCursor = &tasks[limit].ID + nextCursor = paging.EncodeUUID(tasks[limit].ID) tasks = tasks[:limit] } @@ -1345,21 +1339,14 @@ func (r *crmRepository) SearchCRMTasks(ctx context.Context, orgID uuid.UUID, fil } hasMore := int64(offset+len(tasks)) < total - var nextOffset *int + pag := models.Pagination{Total: &total, HasMore: hasMore} if hasMore { - n := offset + limit - nextOffset = &n + pag.NextCursor = paging.EncodeOffset(offset + limit) } return &models.TasksSearchResult{ - Data: tasks, - Pagination: models.TasksSearchPagination{ - Total: total, - Limit: limit, - Offset: offset, - HasMore: hasMore, - NextOffset: nextOffset, - }, + Data: tasks, + Pagination: pag, }, nil } diff --git a/internal/repository/pg_discount.go b/internal/repository/pg_discount.go index 09a5538e..1aa69ef9 100644 --- a/internal/repository/pg_discount.go +++ b/internal/repository/pg_discount.go @@ -10,6 +10,7 @@ import ( "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/utils/paging" ) // Sentinel errors so the service layer can map redemption capacity failures to @@ -361,7 +362,7 @@ func (r *discountCodeRepository) List(ctx context.Context, search *models.AdminD result.Data = codes[:limit] ids = ids[:limit] lastID := result.Data[limit-1].ID - result.Pagination.NextCursor = &lastID + result.Pagination.NextCursor = paging.UUIDString(lastID) } // Attach plan eligibility for the returned page in one query. @@ -646,7 +647,7 @@ func (r *discountRedemptionRepository) ListByCode(ctx context.Context, codeID uu if len(items) > limit { result.Data = items[:limit] lastID := result.Data[limit-1].ID - result.Pagination.NextCursor = &lastID + result.Pagination.NextCursor = paging.UUIDString(lastID) } return result, nil diff --git a/internal/repository/pg_email.go b/internal/repository/pg_email.go index 92a718ec..2e932ef5 100644 --- a/internal/repository/pg_email.go +++ b/internal/repository/pg_email.go @@ -16,6 +16,7 @@ import ( "github.com/warmbly/warmbly/internal/models" "github.com/warmbly/warmbly/internal/pkg/encrypt" "github.com/warmbly/warmbly/internal/utils" + "github.com/warmbly/warmbly/internal/utils/paging" "github.com/warmbly/warmbly/internal/utils/validate" ) @@ -403,7 +404,7 @@ func (r *emailRepository) NewSMTPIMAPAccount(ctx context.Context, userID string, }, nil } -func (r *emailRepository) Search(ctx context.Context, userID, search string, cursor, tag *string, limit int32, allowedAccountIDs []uuid.UUID) (*models.EmailsResult, *errx.Error) { +func (r *emailRepository) Search(ctx context.Context, orgID, search string, cursor, tag *string, limit int32, allowedAccountIDs []uuid.UUID) (*models.EmailsResult, *errx.Error) { tx, err := r.DB.Begin(ctx) if err != nil { db.CaptureError(err, "", nil, "begin") @@ -427,7 +428,7 @@ func (r *emailRepository) Search(ctx context.Context, userID, search string, cur ) AS tags FROM email_accounts ea LEFT JOIN email_tags eat ON eat.email_id = ea.id - WHERE ea.user_id = $1 + WHERE ea.organization_id = $1 AND ($2::uuid IS NULL OR (ea.created_at, ea.id) < ( SELECT created_at, id FROM email_accounts @@ -448,7 +449,7 @@ func (r *emailRepository) Search(ctx context.Context, userID, search string, cur allowedAccountParam = allowedAccountIDs } params := []any{ - userID, + orgID, cursor, "%" + search + "%", tag, @@ -481,12 +482,12 @@ func (r *emailRepository) Search(ctx context.Context, userID, search string, cur } var total *int64 - var nextCursor *uuid.UUID + var nextCursor *string var hasMore bool if len(inboxes) > int(limit) { hasMore = true - nextCursor = &inboxes[limit].ID + nextCursor = paging.EncodeUUID(inboxes[limit].ID) inboxes = inboxes[:limit] } @@ -495,7 +496,7 @@ func (r *emailRepository) Search(ctx context.Context, userID, search string, cur SELECT COUNT(DISTINCT ea.id) FROM email_accounts ea LEFT JOIN email_tags et ON et.email_id = ea.id - WHERE ea.user_id = $1 + WHERE ea.organization_id = $1 AND (ea.name ILIKE $2 OR ea.email ILIKE $2) AND ($3::uuid IS NULL OR EXISTS ( SELECT 1 FROM email_tags cf WHERE cf.email_id = ea.id AND cf.tag_id = $3 @@ -504,7 +505,7 @@ func (r *emailRepository) Search(ctx context.Context, userID, search string, cur ` params = []any{ - userID, + orgID, "%" + search + "%", tag, allowedAccountParam, @@ -533,7 +534,7 @@ func (r *emailRepository) Search(ctx context.Context, userID, search string, cur }, nil } -func (r *emailRepository) Get(ctx context.Context, userID, emailAccountID string) (*models.Email, *errx.Error) { +func (r *emailRepository) Get(ctx context.Context, orgID, emailAccountID string) (*models.Email, *errx.Error) { query := ` SELECT ea.id, ea.email, ea.name, ea.signature_plain, ea.signature_html, ea.signature_sync, ea.signature_code, @@ -544,12 +545,12 @@ func (r *emailRepository) Get(ctx context.Context, userID, emailAccountID string COALESCE(array_agg(eat.tag_id) FILTER (WHERE eat.tag_id IS NOT NULL), '{}') AS tags FROM email_accounts ea LEFT JOIN email_tags eat ON eat.email_id = ea.id - WHERE ea.user_id = $1 AND ea.id = $2 + WHERE ea.organization_id = $1 AND ea.id = $2 GROUP BY ea.id ` params := []any{ - userID, + orgID, emailAccountID, } diff --git a/internal/repository/pg_integration.go b/internal/repository/pg_integration.go index 9473db85..695c8a1b 100644 --- a/internal/repository/pg_integration.go +++ b/internal/repository/pg_integration.go @@ -14,6 +14,7 @@ import ( "github.com/jackc/pgx/v5/pgxpool" "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/utils/paging" ) // ConnectionWrite is the full upsert payload for an integration connection. @@ -78,6 +79,9 @@ type IntegrationRepository interface { ListAutomations(ctx context.Context, orgID uuid.UUID) ([]models.Automation, error) ListEnabledAutomationsForEvent(ctx context.Context, orgID uuid.UUID, eventType string) ([]models.Automation, error) GetAutomation(ctx context.Context, orgID, id uuid.UUID) (*models.Automation, error) + // GetAutomationByInboundToken resolves the automation an inbound-webhook URL + // token points at, across orgs (the token is the credential). nil = no match. + GetAutomationByInboundToken(ctx context.Context, token string) (*models.Automation, error) UpdateAutomation(ctx context.Context, a *models.Automation) error DeleteAutomation(ctx context.Context, orgID, id uuid.UUID) error // CampaignsUsingAutomation returns the names of campaigns whose sequence has a @@ -443,11 +447,11 @@ func (r *integrationRepository) DeleteEventSubscription(ctx context.Context, org // the automation id + its trigger event + enabled flag (so the dispatcher runs // them). Caller has already merged the automation filter into each step Config. // automationCols is the shared projection for an automation row. -const automationCols = `id, organization_id, name, enabled, trigger_event, filter, graph, created_at, updated_at` +const automationCols = `id, organization_id, name, enabled, trigger_event, filter, graph, created_at, updated_at, COALESCE(inbound_token, '')` func scanAutomation(row pgx.Row, a *models.Automation) error { var graph []byte - if err := row.Scan(&a.ID, &a.OrganizationID, &a.Name, &a.Enabled, &a.TriggerEvent, &a.Filter, &graph, &a.CreatedAt, &a.UpdatedAt); err != nil { + if err := row.Scan(&a.ID, &a.OrganizationID, &a.Name, &a.Enabled, &a.TriggerEvent, &a.Filter, &graph, &a.CreatedAt, &a.UpdatedAt, &a.InboundToken); err != nil { return err } a.Graph = models.AutomationGraph{Nodes: []models.AutomationNode{}, Edges: []models.AutomationEdge{}} @@ -476,9 +480,9 @@ func (r *integrationRepository) CreateAutomation(ctx context.Context, a *models. } graph, _ := json.Marshal(a.Graph) _, err := r.db.Exec(ctx, ` - INSERT INTO automations (id, organization_id, name, enabled, trigger_event, filter, graph, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8)`, - a.ID, a.OrganizationID, a.Name, a.Enabled, a.TriggerEvent, filter, graph, now) + INSERT INTO automations (id, organization_id, name, enabled, trigger_event, filter, graph, created_at, updated_at, inbound_token) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8, NULLIF($9, ''))`, + a.ID, a.OrganizationID, a.Name, a.Enabled, a.TriggerEvent, filter, graph, now, a.InboundToken) return err } @@ -491,9 +495,9 @@ func (r *integrationRepository) UpdateAutomation(ctx context.Context, a *models. } graph, _ := json.Marshal(a.Graph) tag, err := r.db.Exec(ctx, ` - UPDATE automations SET name = $3, enabled = $4, trigger_event = $5, filter = $6, graph = $7, updated_at = $8 + UPDATE automations SET name = $3, enabled = $4, trigger_event = $5, filter = $6, graph = $7, updated_at = $8, inbound_token = NULLIF($9, '') WHERE id = $1 AND organization_id = $2`, - a.ID, a.OrganizationID, a.Name, a.Enabled, a.TriggerEvent, filter, graph, now) + a.ID, a.OrganizationID, a.Name, a.Enabled, a.TriggerEvent, filter, graph, now, a.InboundToken) if err != nil { return err } @@ -545,6 +549,21 @@ func (r *integrationRepository) GetAutomation(ctx context.Context, orgID, id uui return &a, nil } +func (r *integrationRepository) GetAutomationByInboundToken(ctx context.Context, token string) (*models.Automation, error) { + if strings.TrimSpace(token) == "" { + return nil, nil + } + var a models.Automation + row := r.db.QueryRow(ctx, `SELECT `+automationCols+` FROM automations WHERE inbound_token = $1`, token) + if err := scanAutomation(row, &a); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return nil, err + } + return &a, nil +} + func (r *integrationRepository) ListAutomations(ctx context.Context, orgID uuid.UUID) ([]models.Automation, error) { rows, err := r.db.Query(ctx, `SELECT `+automationCols+` FROM automations WHERE organization_id = $1 ORDER BY created_at DESC`, orgID) if err != nil { @@ -1057,18 +1076,13 @@ func (r *integrationRepository) SearchMeetingBookings(ctx context.Context, orgID } hasMore := offset+len(data) < total + total64 := int64(total) page := &models.MeetingBookingPage{ - Data: data, - Pagination: models.MeetingBookingPagination{ - Total: int64(total), - Limit: limit, - Offset: offset, - HasMore: hasMore, - }, + Data: data, + Pagination: models.Pagination{Total: &total64, HasMore: hasMore}, } if hasMore { - next := offset + limit - page.Pagination.NextOffset = &next + page.Pagination.NextCursor = paging.EncodeOffset(offset + limit) } return page, nil } diff --git a/internal/repository/pg_member_roles.go b/internal/repository/pg_member_roles.go new file mode 100644 index 00000000..2002afcb --- /dev/null +++ b/internal/repository/pg_member_roles.go @@ -0,0 +1,244 @@ +package repository + +import ( + "context" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/warmbly/warmbly/internal/models" +) + +// Multi-role assignment storage. organization_members.permissions stays the +// effective OR snapshot across every assigned role, recomputed in the same +// transaction as any membership/role change so all permission readers stay +// JOIN-free. + +// recomputeMemberPermissions sets a member's permission snapshot to the +// bitwise OR of its assigned roles (0 when none). Owner is never touched. +func recomputeMemberPermissions(ctx context.Context, tx pgx.Tx, orgID, userID uuid.UUID) error { + _, err := tx.Exec(ctx, ` + UPDATE organization_members om + SET permissions = COALESCE(( + SELECT bit_or(r.permissions) + FROM organization_member_roles mr + JOIN organization_roles r ON r.id = mr.role_id + WHERE mr.organization_id = om.organization_id AND mr.user_id = om.user_id + ), 0), + role = COALESCE(( + SELECT r.name FROM organization_member_roles mr + JOIN organization_roles r ON r.id = mr.role_id + WHERE mr.organization_id = om.organization_id AND mr.user_id = om.user_id + ORDER BY r.created_at ASC LIMIT 1 + ), om.role), + role_id = ( + SELECT r.id FROM organization_member_roles mr + JOIN organization_roles r ON r.id = mr.role_id + WHERE mr.organization_id = om.organization_id AND mr.user_id = om.user_id + ORDER BY r.created_at ASC LIMIT 1 + ) + WHERE om.organization_id = $1 AND om.user_id = $2 AND om.role <> 'owner' + `, orgID, userID) + return err +} + +// AddMemberWithRoles inserts a membership row and its role assignments and +// recomputes the effective permission snapshot, all in one transaction. +// Used by invite-accept so a partial failure can never strand a member with +// no role rows. +func (r *organizationRepository) AddMemberWithRoles(ctx context.Context, member *models.OrganizationMember, roleIDs []uuid.UUID) error { + tx, err := r.db.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) //nolint:errcheck + + if _, err := tx.Exec(ctx, ` + INSERT INTO organization_members (id, organization_id, user_id, role, role_id, permissions, invited_by, invited_at, accepted_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + `, member.ID, member.OrganizationID, member.UserID, member.Role, member.RoleID, + member.Permissions, member.InvitedBy, member.InvitedAt, member.AcceptedAt); err != nil { + return err + } + for _, roleID := range roleIDs { + if _, err := tx.Exec(ctx, ` + INSERT INTO organization_member_roles (organization_id, user_id, role_id) + VALUES ($1, $2, $3) ON CONFLICT DO NOTHING + `, member.OrganizationID, member.UserID, roleID); err != nil { + return err + } + } + if err := recomputeMemberPermissions(ctx, tx, member.OrganizationID, member.UserID); err != nil { + return err + } + return tx.Commit(ctx) +} + +// HydrateInvitationRoles fills the Roles slice on each pending invitation +// from one query (mirrors HydrateMemberRoles for the roster). +func (r *organizationRepository) HydrateInvitationRoles(ctx context.Context, invitations []models.OrganizationInvitation) error { + if len(invitations) == 0 { + return nil + } + ids := make([]uuid.UUID, 0, len(invitations)) + for _, inv := range invitations { + ids = append(ids, inv.ID) + } + rows, err := r.db.Query(ctx, ` + SELECT ir.invitation_id, r.id, r.name, r.color + FROM organization_invitation_roles ir + JOIN organization_roles r ON r.id = ir.role_id + WHERE ir.invitation_id = ANY($1) + ORDER BY r.created_at ASC + `, ids) + if err != nil { + return err + } + defer rows.Close() + + byInvite := make(map[uuid.UUID][]models.MemberRole) + for rows.Next() { + var invID uuid.UUID + var mr models.MemberRole + if err := rows.Scan(&invID, &mr.ID, &mr.Name, &mr.Color); err != nil { + return err + } + byInvite[invID] = append(byInvite[invID], mr) + } + for i := range invitations { + invitations[i].Roles = byInvite[invitations[i].ID] + } + return nil +} + +// SetMemberRoles replaces a member's assigned role set and recomputes the +// effective permission snapshot atomically. All role ids must belong to the +// org (enforced by the FK + the caller's validation). +func (r *organizationRepository) SetMemberRoles(ctx context.Context, orgID, userID uuid.UUID, roleIDs []uuid.UUID) error { + tx, err := r.db.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) //nolint:errcheck + + if _, err := tx.Exec(ctx, + `DELETE FROM organization_member_roles WHERE organization_id = $1 AND user_id = $2`, + orgID, userID); err != nil { + return err + } + for _, roleID := range roleIDs { + if _, err := tx.Exec(ctx, ` + INSERT INTO organization_member_roles (organization_id, user_id, role_id) + VALUES ($1, $2, $3) ON CONFLICT DO NOTHING + `, orgID, userID, roleID); err != nil { + return err + } + } + if err := recomputeMemberPermissions(ctx, tx, orgID, userID); err != nil { + return err + } + return tx.Commit(ctx) +} + +// GetMemberRoles returns a member's assigned role refs (for display chips). +func (r *organizationRepository) GetMemberRoles(ctx context.Context, orgID, userID uuid.UUID) ([]models.MemberRole, error) { + return scanMemberRoles(ctx, r.db, ` + SELECT r.id, r.name, r.color + FROM organization_member_roles mr + JOIN organization_roles r ON r.id = mr.role_id + WHERE mr.organization_id = $1 AND mr.user_id = $2 + ORDER BY r.created_at ASC + `, orgID, userID) +} + +func scanMemberRoles(ctx context.Context, db *pgxpool.Pool, query string, args ...any) ([]models.MemberRole, error) { + rows, err := db.Query(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var out []models.MemberRole + for rows.Next() { + var mr models.MemberRole + if err := rows.Scan(&mr.ID, &mr.Name, &mr.Color); err != nil { + return nil, err + } + out = append(out, mr) + } + return out, nil +} + +// HydrateMemberRoles fills the Roles slice on each member from one query, so +// the roster shows every assigned role without an N+1. +func (r *organizationRepository) HydrateMemberRoles(ctx context.Context, orgID uuid.UUID, members []models.OrganizationMember) error { + if len(members) == 0 { + return nil + } + rows, err := r.db.Query(ctx, ` + SELECT mr.user_id, r.id, r.name, r.color + FROM organization_member_roles mr + JOIN organization_roles r ON r.id = mr.role_id + WHERE mr.organization_id = $1 + ORDER BY r.created_at ASC + `, orgID) + if err != nil { + return err + } + defer rows.Close() + + byUser := make(map[uuid.UUID][]models.MemberRole) + for rows.Next() { + var userID uuid.UUID + var mr models.MemberRole + if err := rows.Scan(&userID, &mr.ID, &mr.Name, &mr.Color); err != nil { + return err + } + byUser[userID] = append(byUser[userID], mr) + } + for i := range members { + members[i].Roles = byUser[members[i].UserID] + } + return nil +} + +// SetInvitationRoles replaces an invitation's role set (used at invite time). +func (r *organizationRepository) SetInvitationRoles(ctx context.Context, invitationID uuid.UUID, roleIDs []uuid.UUID) error { + tx, err := r.db.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) //nolint:errcheck + + if _, err := tx.Exec(ctx, + `DELETE FROM organization_invitation_roles WHERE invitation_id = $1`, invitationID); err != nil { + return err + } + for _, roleID := range roleIDs { + if _, err := tx.Exec(ctx, ` + INSERT INTO organization_invitation_roles (invitation_id, role_id) + VALUES ($1, $2) ON CONFLICT DO NOTHING + `, invitationID, roleID); err != nil { + return err + } + } + return tx.Commit(ctx) +} + +// GetInvitationRoles returns the role ids attached to an invitation. +func (r *organizationRepository) GetInvitationRoles(ctx context.Context, invitationID uuid.UUID) ([]uuid.UUID, error) { + rows, err := r.db.Query(ctx, + `SELECT role_id FROM organization_invitation_roles WHERE invitation_id = $1`, invitationID) + if err != nil { + return nil, err + } + defer rows.Close() + var ids []uuid.UUID + for rows.Next() { + var id uuid.UUID + if err := rows.Scan(&id); err != nil { + return nil, err + } + ids = append(ids, id) + } + return ids, nil +} diff --git a/internal/repository/pg_oauth.go b/internal/repository/pg_oauth.go new file mode 100644 index 00000000..d3d16620 --- /dev/null +++ b/internal/repository/pg_oauth.go @@ -0,0 +1,316 @@ +package repository + +import ( + "context" + "errors" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/warmbly/warmbly/internal/models" +) + +// OAuthRepository owns persistence for the OAuth 2.1 authorization server: +// registered apps (clients), single-use authorization codes, and issued +// access+refresh grants. Tokens/secrets are stored hashed; lookups are by hash. +type OAuthRepository interface { + // Applications + CreateApplication(ctx context.Context, a *models.OAuthApplication) error + ListApplications(ctx context.Context, orgID uuid.UUID) ([]models.OAuthApplication, error) + GetApplication(ctx context.Context, orgID, id uuid.UUID) (*models.OAuthApplication, error) + GetApplicationByClientID(ctx context.Context, clientID string) (*models.OAuthApplication, error) + UpdateApplication(ctx context.Context, a *models.OAuthApplication) error + UpdateApplicationSecret(ctx context.Context, orgID, id uuid.UUID, secretHash string) error + DeleteApplication(ctx context.Context, orgID, id uuid.UUID) error + + // Authorization codes + CreateAuthorizationCode(ctx context.Context, c *models.OAuthAuthorizationCode) error + // TakeAuthorizationCode atomically consumes a valid, unexpired, unused code. + TakeAuthorizationCode(ctx context.Context, codeHash string) (*models.OAuthAuthorizationCode, error) + + // Access grants + CreateAccessGrant(ctx context.Context, g *models.OAuthAccessGrant) error + GetGrantByAccessTokenHash(ctx context.Context, hash string) (*models.OAuthAccessGrant, error) + GetGrantByRefreshTokenHash(ctx context.Context, hash string) (*models.OAuthAccessGrant, error) + RotateGrantTokens(ctx context.Context, id uuid.UUID, accessHash, refreshHash string, accessExp time.Time, refreshExp *time.Time) error + TouchGrantLastUsed(ctx context.Context, id uuid.UUID) error + RevokeGrant(ctx context.Context, id uuid.UUID) error + RevokeGrantByTokenHash(ctx context.Context, appID uuid.UUID, hash string) error + ListAuthorizedApps(ctx context.Context, orgID, userID uuid.UUID) ([]models.OAuthAuthorizedApp, error) + RevokeAuthorization(ctx context.Context, orgID, userID, appID uuid.UUID) error +} + +type oauthRepository struct { + db *pgxpool.Pool +} + +func NewOAuthRepository(db *pgxpool.Pool) OAuthRepository { + return &oauthRepository{db: db} +} + +const oauthAppCols = `id, organization_id, created_by, name, description, logo_url, website_url, + client_id, client_secret_hash, redirect_uris, scopes, status, created_at, updated_at` + +func scanOAuthApp(row pgx.Row, a *models.OAuthApplication) error { + var scopes int64 + var status string + if err := row.Scan(&a.ID, &a.OrganizationID, &a.CreatedBy, &a.Name, &a.Description, &a.LogoURL, &a.WebsiteURL, + &a.ClientID, &a.ClientSecretHash, &a.RedirectURIs, &scopes, &status, &a.CreatedAt, &a.UpdatedAt); err != nil { + return err + } + a.Scopes = uint64(scopes) + a.Status = models.OAuthAppStatus(status) + if a.RedirectURIs == nil { + a.RedirectURIs = []string{} + } + return nil +} + +func (r *oauthRepository) CreateApplication(ctx context.Context, a *models.OAuthApplication) error { + if a.ID == uuid.Nil { + a.ID = uuid.New() + } + now := time.Now().UTC() + a.CreatedAt = now + a.UpdatedAt = now + if a.Status == "" { + a.Status = models.OAuthAppActive + } + _, err := r.db.Exec(ctx, ` + INSERT INTO oauth_applications (id, organization_id, created_by, name, description, logo_url, website_url, + client_id, client_secret_hash, redirect_uris, scopes, status, created_at, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$13)`, + a.ID, a.OrganizationID, a.CreatedBy, a.Name, a.Description, a.LogoURL, a.WebsiteURL, + a.ClientID, a.ClientSecretHash, a.RedirectURIs, int64(a.Scopes), string(a.Status), now) + return err +} + +func (r *oauthRepository) ListApplications(ctx context.Context, orgID uuid.UUID) ([]models.OAuthApplication, error) { + rows, err := r.db.Query(ctx, `SELECT `+oauthAppCols+` FROM oauth_applications WHERE organization_id = $1 ORDER BY created_at DESC`, orgID) + if err != nil { + return nil, err + } + defer rows.Close() + out := []models.OAuthApplication{} + for rows.Next() { + var a models.OAuthApplication + if err := scanOAuthApp(rows, &a); err != nil { + return nil, err + } + out = append(out, a) + } + return out, rows.Err() +} + +func (r *oauthRepository) GetApplication(ctx context.Context, orgID, id uuid.UUID) (*models.OAuthApplication, error) { + var a models.OAuthApplication + row := r.db.QueryRow(ctx, `SELECT `+oauthAppCols+` FROM oauth_applications WHERE id = $1 AND organization_id = $2`, id, orgID) + if err := scanOAuthApp(row, &a); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return nil, err + } + return &a, nil +} + +func (r *oauthRepository) GetApplicationByClientID(ctx context.Context, clientID string) (*models.OAuthApplication, error) { + var a models.OAuthApplication + row := r.db.QueryRow(ctx, `SELECT `+oauthAppCols+` FROM oauth_applications WHERE client_id = $1`, clientID) + if err := scanOAuthApp(row, &a); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return nil, err + } + return &a, nil +} + +func (r *oauthRepository) UpdateApplication(ctx context.Context, a *models.OAuthApplication) error { + now := time.Now().UTC() + a.UpdatedAt = now + tag, err := r.db.Exec(ctx, ` + UPDATE oauth_applications SET name=$3, description=$4, logo_url=$5, website_url=$6, + redirect_uris=$7, scopes=$8, status=$9, updated_at=$10 + WHERE id=$1 AND organization_id=$2`, + a.ID, a.OrganizationID, a.Name, a.Description, a.LogoURL, a.WebsiteURL, + a.RedirectURIs, int64(a.Scopes), string(a.Status), now) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return errors.New("application not found") + } + return nil +} + +func (r *oauthRepository) UpdateApplicationSecret(ctx context.Context, orgID, id uuid.UUID, secretHash string) error { + tag, err := r.db.Exec(ctx, `UPDATE oauth_applications SET client_secret_hash=$3, updated_at=now() WHERE id=$1 AND organization_id=$2`, id, orgID, secretHash) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return errors.New("application not found") + } + return nil +} + +func (r *oauthRepository) DeleteApplication(ctx context.Context, orgID, id uuid.UUID) error { + _, err := r.db.Exec(ctx, `DELETE FROM oauth_applications WHERE id=$1 AND organization_id=$2`, id, orgID) + return err +} + +func (r *oauthRepository) CreateAuthorizationCode(ctx context.Context, c *models.OAuthAuthorizationCode) error { + if c.ID == uuid.Nil { + c.ID = uuid.New() + } + c.CreatedAt = time.Now().UTC() + _, err := r.db.Exec(ctx, ` + INSERT INTO oauth_authorization_codes (id, code_hash, application_id, organization_id, user_id, + redirect_uri, scopes, code_challenge, code_challenge_method, expires_at, created_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`, + c.ID, c.CodeHash, c.ApplicationID, c.OrganizationID, c.UserID, + c.RedirectURI, int64(c.Scopes), c.CodeChallenge, c.CodeChallengeMethod, c.ExpiresAt, c.CreatedAt) + return err +} + +func (r *oauthRepository) TakeAuthorizationCode(ctx context.Context, codeHash string) (*models.OAuthAuthorizationCode, error) { + var c models.OAuthAuthorizationCode + var scopes int64 + row := r.db.QueryRow(ctx, ` + UPDATE oauth_authorization_codes SET used_at = now() + WHERE code_hash = $1 AND used_at IS NULL AND expires_at > now() + RETURNING id, code_hash, application_id, organization_id, user_id, redirect_uri, scopes, + code_challenge, code_challenge_method, used_at, expires_at, created_at`, codeHash) + if err := row.Scan(&c.ID, &c.CodeHash, &c.ApplicationID, &c.OrganizationID, &c.UserID, &c.RedirectURI, &scopes, + &c.CodeChallenge, &c.CodeChallengeMethod, &c.UsedAt, &c.ExpiresAt, &c.CreatedAt); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return nil, err + } + c.Scopes = uint64(scopes) + return &c, nil +} + +const oauthGrantCols = `id, application_id, organization_id, user_id, scopes, access_token_hash, refresh_token_hash, + access_expires_at, refresh_expires_at, revoked_at, last_used_at, created_at` + +func scanOAuthGrant(row pgx.Row, g *models.OAuthAccessGrant) error { + var scopes int64 + var refreshHash *string + if err := row.Scan(&g.ID, &g.ApplicationID, &g.OrganizationID, &g.UserID, &scopes, &g.AccessTokenHash, &refreshHash, + &g.AccessExpiresAt, &g.RefreshExpiresAt, &g.RevokedAt, &g.LastUsedAt, &g.CreatedAt); err != nil { + return err + } + g.Scopes = uint64(scopes) + if refreshHash != nil { + g.RefreshTokenHash = *refreshHash + } + return nil +} + +func (r *oauthRepository) CreateAccessGrant(ctx context.Context, g *models.OAuthAccessGrant) error { + if g.ID == uuid.Nil { + g.ID = uuid.New() + } + g.CreatedAt = time.Now().UTC() + var refreshHash *string + if g.RefreshTokenHash != "" { + refreshHash = &g.RefreshTokenHash + } + _, err := r.db.Exec(ctx, ` + INSERT INTO oauth_access_grants (id, application_id, organization_id, user_id, scopes, + access_token_hash, refresh_token_hash, access_expires_at, refresh_expires_at, created_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, + g.ID, g.ApplicationID, g.OrganizationID, g.UserID, int64(g.Scopes), + g.AccessTokenHash, refreshHash, g.AccessExpiresAt, g.RefreshExpiresAt, g.CreatedAt) + return err +} + +func (r *oauthRepository) GetGrantByAccessTokenHash(ctx context.Context, hash string) (*models.OAuthAccessGrant, error) { + var g models.OAuthAccessGrant + row := r.db.QueryRow(ctx, `SELECT `+oauthGrantCols+` FROM oauth_access_grants WHERE access_token_hash = $1`, hash) + if err := scanOAuthGrant(row, &g); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return nil, err + } + return &g, nil +} + +func (r *oauthRepository) GetGrantByRefreshTokenHash(ctx context.Context, hash string) (*models.OAuthAccessGrant, error) { + var g models.OAuthAccessGrant + row := r.db.QueryRow(ctx, `SELECT `+oauthGrantCols+` FROM oauth_access_grants WHERE refresh_token_hash = $1`, hash) + if err := scanOAuthGrant(row, &g); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return nil, err + } + return &g, nil +} + +func (r *oauthRepository) RotateGrantTokens(ctx context.Context, id uuid.UUID, accessHash, refreshHash string, accessExp time.Time, refreshExp *time.Time) error { + var refresh *string + if refreshHash != "" { + refresh = &refreshHash + } + _, err := r.db.Exec(ctx, ` + UPDATE oauth_access_grants SET access_token_hash=$2, refresh_token_hash=$3, access_expires_at=$4, refresh_expires_at=$5, last_used_at=now() + WHERE id=$1`, id, accessHash, refresh, accessExp, refreshExp) + return err +} + +func (r *oauthRepository) TouchGrantLastUsed(ctx context.Context, id uuid.UUID) error { + _, err := r.db.Exec(ctx, `UPDATE oauth_access_grants SET last_used_at=now() WHERE id=$1`, id) + return err +} + +func (r *oauthRepository) RevokeGrant(ctx context.Context, id uuid.UUID) error { + _, err := r.db.Exec(ctx, `UPDATE oauth_access_grants SET revoked_at=now() WHERE id=$1 AND revoked_at IS NULL`, id) + return err +} + +func (r *oauthRepository) RevokeGrantByTokenHash(ctx context.Context, appID uuid.UUID, hash string) error { + _, err := r.db.Exec(ctx, ` + UPDATE oauth_access_grants SET revoked_at=now() + WHERE application_id=$1 AND (access_token_hash=$2 OR refresh_token_hash=$2) AND revoked_at IS NULL`, appID, hash) + return err +} + +func (r *oauthRepository) ListAuthorizedApps(ctx context.Context, orgID, userID uuid.UUID) ([]models.OAuthAuthorizedApp, error) { + rows, err := r.db.Query(ctx, ` + SELECT a.id, a.name, a.logo_url, a.website_url, + bit_or(g.scopes)::bigint AS scopes, min(g.created_at) AS authorized_at, max(g.last_used_at) AS last_used_at + FROM oauth_access_grants g + JOIN oauth_applications a ON a.id = g.application_id + WHERE g.organization_id = $1 AND g.user_id = $2 AND g.revoked_at IS NULL + GROUP BY a.id, a.name, a.logo_url, a.website_url + ORDER BY authorized_at DESC`, orgID, userID) + if err != nil { + return nil, err + } + defer rows.Close() + out := []models.OAuthAuthorizedApp{} + for rows.Next() { + var ap models.OAuthAuthorizedApp + var scopes int64 + if err := rows.Scan(&ap.ApplicationID, &ap.Name, &ap.LogoURL, &ap.WebsiteURL, &scopes, &ap.AuthorizedAt, &ap.LastUsedAt); err != nil { + return nil, err + } + ap.Scopes = uint64(scopes) + out = append(out, ap) + } + return out, rows.Err() +} + +func (r *oauthRepository) RevokeAuthorization(ctx context.Context, orgID, userID, appID uuid.UUID) error { + _, err := r.db.Exec(ctx, ` + UPDATE oauth_access_grants SET revoked_at=now() + WHERE organization_id=$1 AND user_id=$2 AND application_id=$3 AND revoked_at IS NULL`, orgID, userID, appID) + return err +} diff --git a/internal/repository/pg_organization.go b/internal/repository/pg_organization.go index 30b1c6d2..9a297091 100644 --- a/internal/repository/pg_organization.go +++ b/internal/repository/pg_organization.go @@ -10,6 +10,7 @@ import ( "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/utils/paging" ) // OrganizationRepository defines the interface for organization data access @@ -32,12 +33,30 @@ type OrganizationRepository interface { GetMemberByID(ctx context.Context, memberID uuid.UUID) (*models.OrganizationMember, error) AddMember(ctx context.Context, member *models.OrganizationMember) error UpdateMember(ctx context.Context, member *models.OrganizationMember) error + + // Custom roles (implemented in pg_organization_roles.go) + ListRoles(ctx context.Context, orgID uuid.UUID) ([]models.OrganizationRole, error) + GetRoleByID(ctx context.Context, orgID, roleID uuid.UUID) (*models.OrganizationRole, error) + CountRoles(ctx context.Context, orgID uuid.UUID) (int, error) + CreateRole(ctx context.Context, role *models.OrganizationRole) error + UpdateRole(ctx context.Context, role *models.OrganizationRole) error + DeleteRole(ctx context.Context, orgID, roleID uuid.UUID) error + + // Multi-role assignment (pg_member_roles.go) + AddMemberWithRoles(ctx context.Context, member *models.OrganizationMember, roleIDs []uuid.UUID) error + HydrateInvitationRoles(ctx context.Context, invitations []models.OrganizationInvitation) error + SetMemberRoles(ctx context.Context, orgID, userID uuid.UUID, roleIDs []uuid.UUID) error + GetMemberRoles(ctx context.Context, orgID, userID uuid.UUID) ([]models.MemberRole, error) + HydrateMemberRoles(ctx context.Context, orgID uuid.UUID, members []models.OrganizationMember) error + SetInvitationRoles(ctx context.Context, invitationID uuid.UUID, roleIDs []uuid.UUID) error + GetInvitationRoles(ctx context.Context, invitationID uuid.UUID) ([]uuid.UUID, error) RemoveMember(ctx context.Context, orgID, userID uuid.UUID) error GetMemberCount(ctx context.Context, orgID uuid.UUID) (int, error) // Invitations CreateInvitation(ctx context.Context, inv *models.OrganizationInvitation) error GetInvitationByToken(ctx context.Context, token string) (*models.OrganizationInvitation, error) + GetInvitationByID(ctx context.Context, id uuid.UUID) (*models.OrganizationInvitation, error) GetInvitationByEmail(ctx context.Context, orgID uuid.UUID, email string) (*models.OrganizationInvitation, error) GetPendingInvitations(ctx context.Context, orgID uuid.UUID) ([]models.OrganizationInvitation, error) GetUserPendingInvitations(ctx context.Context, email string) ([]models.OrganizationInvitation, error) @@ -122,7 +141,8 @@ func (r *organizationRepository) Create(ctx context.Context, org *models.Organiz func (r *organizationRepository) GetByID(ctx context.Context, id uuid.UUID) (*models.Organization, error) { query := ` SELECT id, name, slug, avatar_url, owner_user_id, created_at, updated_at, - deletion_scheduled_at, deletion_scheduled_for + deletion_scheduled_at, deletion_scheduled_for, + presence_show_online, presence_show_activity FROM organizations WHERE id = $1 ` return r.scanOrganization(ctx, query, id) @@ -132,7 +152,8 @@ func (r *organizationRepository) GetByID(ctx context.Context, id uuid.UUID) (*mo func (r *organizationRepository) GetBySlug(ctx context.Context, slug string) (*models.Organization, error) { query := ` SELECT id, name, slug, avatar_url, owner_user_id, created_at, updated_at, - deletion_scheduled_at, deletion_scheduled_for + deletion_scheduled_at, deletion_scheduled_for, + presence_show_online, presence_show_activity FROM organizations WHERE slug = $1 ` return r.scanOrganization(ctx, query, slug) @@ -141,7 +162,7 @@ func (r *organizationRepository) GetBySlug(ctx context.Context, slug string) (*m func (r *organizationRepository) scanOrganization(ctx context.Context, query string, args ...interface{}) (*models.Organization, error) { row := r.db.QueryRow(ctx, query, args...) var org models.Organization - err := row.Scan(&org.ID, &org.Name, &org.Slug, &org.AvatarURL, &org.OwnerUserID, &org.CreatedAt, &org.UpdatedAt, &org.DeletionScheduledAt, &org.DeletionScheduledFor) + err := row.Scan(&org.ID, &org.Name, &org.Slug, &org.AvatarURL, &org.OwnerUserID, &org.CreatedAt, &org.UpdatedAt, &org.DeletionScheduledAt, &org.DeletionScheduledFor, &org.PresenceShowOnline, &org.PresenceShowActivity) if err == pgx.ErrNoRows { return nil, nil } @@ -154,10 +175,12 @@ func (r *organizationRepository) scanOrganization(ctx context.Context, query str // Update updates an organization func (r *organizationRepository) Update(ctx context.Context, org *models.Organization) error { query := ` - UPDATE organizations SET name = $2, slug = $3, updated_at = $4 + UPDATE organizations + SET name = $2, slug = $3, presence_show_online = $4, + presence_show_activity = $5, updated_at = $6 WHERE id = $1 ` - _, err := r.db.Exec(ctx, query, org.ID, org.Name, org.Slug, time.Now()) + _, err := r.db.Exec(ctx, query, org.ID, org.Name, org.Slug, org.PresenceShowOnline, org.PresenceShowActivity, time.Now()) return err } @@ -216,7 +239,8 @@ func (r *organizationRepository) GetUserOrganizations(ctx context.Context, userI func (r *organizationRepository) GetUserDefaultOrganization(ctx context.Context, userID uuid.UUID) (*models.Organization, error) { query := ` SELECT id, name, slug, avatar_url, owner_user_id, created_at, updated_at, - deletion_scheduled_at, deletion_scheduled_for + deletion_scheduled_at, deletion_scheduled_for, + presence_show_online, presence_show_activity FROM organizations WHERE owner_user_id = $1 ORDER BY created_at ASC LIMIT 1 ` @@ -227,7 +251,7 @@ func (r *organizationRepository) GetUserDefaultOrganization(ctx context.Context, func (r *organizationRepository) GetMembers(ctx context.Context, orgID uuid.UUID) ([]models.OrganizationMember, error) { query := ` SELECT - om.id, om.organization_id, om.user_id, om.role, om.permissions, + om.id, om.organization_id, om.user_id, om.role, om.role_id, om.permissions, om.invited_by, om.invited_at, om.accepted_at, u.id, u.first_name, u.last_name, u.email, u.created_at, u.updated_at FROM organization_members om @@ -246,7 +270,7 @@ func (r *organizationRepository) GetMembers(ctx context.Context, orgID uuid.UUID var m models.OrganizationMember var u models.User err := rows.Scan( - &m.ID, &m.OrganizationID, &m.UserID, &m.Role, &m.Permissions, + &m.ID, &m.OrganizationID, &m.UserID, &m.Role, &m.RoleID, &m.Permissions, &m.InvitedBy, &m.InvitedAt, &m.AcceptedAt, &u.ID, &u.FirstName, &u.LastName, &u.Email, &u.CreatedAt, &u.UpdatedAt, ) @@ -264,13 +288,13 @@ func (r *organizationRepository) GetMembers(ctx context.Context, orgID uuid.UUID // GetMember retrieves a specific member of an organization func (r *organizationRepository) GetMember(ctx context.Context, orgID, userID uuid.UUID) (*models.OrganizationMember, error) { query := ` - SELECT id, organization_id, user_id, role, permissions, invited_by, invited_at, accepted_at + SELECT id, organization_id, user_id, role, role_id, permissions, invited_by, invited_at, accepted_at FROM organization_members WHERE organization_id = $1 AND user_id = $2 ` row := r.db.QueryRow(ctx, query, orgID, userID) var m models.OrganizationMember - err := row.Scan(&m.ID, &m.OrganizationID, &m.UserID, &m.Role, &m.Permissions, &m.InvitedBy, &m.InvitedAt, &m.AcceptedAt) + err := row.Scan(&m.ID, &m.OrganizationID, &m.UserID, &m.Role, &m.RoleID, &m.Permissions, &m.InvitedBy, &m.InvitedAt, &m.AcceptedAt) if err == pgx.ErrNoRows { return nil, nil } @@ -283,12 +307,12 @@ func (r *organizationRepository) GetMember(ctx context.Context, orgID, userID uu // GetMemberByID retrieves a member by their membership ID func (r *organizationRepository) GetMemberByID(ctx context.Context, memberID uuid.UUID) (*models.OrganizationMember, error) { query := ` - SELECT id, organization_id, user_id, role, permissions, invited_by, invited_at, accepted_at + SELECT id, organization_id, user_id, role, role_id, permissions, invited_by, invited_at, accepted_at FROM organization_members WHERE id = $1 ` row := r.db.QueryRow(ctx, query, memberID) var m models.OrganizationMember - err := row.Scan(&m.ID, &m.OrganizationID, &m.UserID, &m.Role, &m.Permissions, &m.InvitedBy, &m.InvitedAt, &m.AcceptedAt) + err := row.Scan(&m.ID, &m.OrganizationID, &m.UserID, &m.Role, &m.RoleID, &m.Permissions, &m.InvitedBy, &m.InvitedAt, &m.AcceptedAt) if err == pgx.ErrNoRows { return nil, nil } @@ -301,11 +325,11 @@ func (r *organizationRepository) GetMemberByID(ctx context.Context, memberID uui // AddMember adds a member to an organization func (r *organizationRepository) AddMember(ctx context.Context, member *models.OrganizationMember) error { query := ` - INSERT INTO organization_members (id, organization_id, user_id, role, permissions, invited_by, invited_at, accepted_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + INSERT INTO organization_members (id, organization_id, user_id, role, role_id, permissions, invited_by, invited_at, accepted_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) ` _, err := r.db.Exec(ctx, query, - member.ID, member.OrganizationID, member.UserID, member.Role, member.Permissions, + member.ID, member.OrganizationID, member.UserID, member.Role, member.RoleID, member.Permissions, member.InvitedBy, member.InvitedAt, member.AcceptedAt, ) return err @@ -314,10 +338,10 @@ func (r *organizationRepository) AddMember(ctx context.Context, member *models.O // UpdateMember updates a member's role and permissions func (r *organizationRepository) UpdateMember(ctx context.Context, member *models.OrganizationMember) error { query := ` - UPDATE organization_members SET role = $3, permissions = $4 + UPDATE organization_members SET role = $3, role_id = $4, permissions = $5 WHERE organization_id = $1 AND user_id = $2 ` - _, err := r.db.Exec(ctx, query, member.OrganizationID, member.UserID, member.Role, member.Permissions) + _, err := r.db.Exec(ctx, query, member.OrganizationID, member.UserID, member.Role, member.RoleID, member.Permissions) return err } @@ -337,17 +361,18 @@ func (r *organizationRepository) GetMemberCount(ctx context.Context, orgID uuid. // CreateInvitation creates a new invitation func (r *organizationRepository) CreateInvitation(ctx context.Context, inv *models.OrganizationInvitation) error { query := ` - INSERT INTO organization_invitations (id, organization_id, email, role, permissions, invited_by, token, expires_at, created_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + INSERT INTO organization_invitations (id, organization_id, email, role, role_id, permissions, invited_by, token, expires_at, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) ON CONFLICT (organization_id, email) DO UPDATE SET role = EXCLUDED.role, + role_id = EXCLUDED.role_id, permissions = EXCLUDED.permissions, invited_by = EXCLUDED.invited_by, token = EXCLUDED.token, expires_at = EXCLUDED.expires_at ` _, err := r.db.Exec(ctx, query, - inv.ID, inv.OrganizationID, inv.Email, inv.Role, inv.Permissions, + inv.ID, inv.OrganizationID, inv.Email, inv.Role, inv.RoleID, inv.Permissions, inv.InvitedBy, inv.Token, inv.ExpiresAt, inv.CreatedAt, ) return err @@ -357,7 +382,7 @@ func (r *organizationRepository) CreateInvitation(ctx context.Context, inv *mode func (r *organizationRepository) GetInvitationByToken(ctx context.Context, token string) (*models.OrganizationInvitation, error) { query := ` SELECT - i.id, i.organization_id, i.email, i.role, i.permissions, i.invited_by, i.token, i.expires_at, i.created_at, + i.id, i.organization_id, i.email, i.role, i.role_id, i.permissions, i.invited_by, i.token, i.expires_at, i.created_at, o.id, o.name, o.slug, o.avatar_url, o.owner_user_id, o.created_at, o.updated_at, o.deletion_scheduled_at, o.deletion_scheduled_for FROM organization_invitations i @@ -368,7 +393,37 @@ func (r *organizationRepository) GetInvitationByToken(ctx context.Context, token var inv models.OrganizationInvitation var org models.Organization err := row.Scan( - &inv.ID, &inv.OrganizationID, &inv.Email, &inv.Role, &inv.Permissions, + &inv.ID, &inv.OrganizationID, &inv.Email, &inv.Role, &inv.RoleID, &inv.Permissions, + &inv.InvitedBy, &inv.Token, &inv.ExpiresAt, &inv.CreatedAt, + &org.ID, &org.Name, &org.Slug, &org.AvatarURL, &org.OwnerUserID, &org.CreatedAt, &org.UpdatedAt, + &org.DeletionScheduledAt, &org.DeletionScheduledFor, + ) + if err == pgx.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + inv.Organization = &org + return &inv, nil +} + +// GetInvitationByID retrieves an invitation by its id, with org joined. +func (r *organizationRepository) GetInvitationByID(ctx context.Context, id uuid.UUID) (*models.OrganizationInvitation, error) { + query := ` + SELECT + i.id, i.organization_id, i.email, i.role, i.role_id, i.permissions, i.invited_by, i.token, i.expires_at, i.created_at, + o.id, o.name, o.slug, o.avatar_url, o.owner_user_id, o.created_at, o.updated_at, + o.deletion_scheduled_at, o.deletion_scheduled_for + FROM organization_invitations i + JOIN organizations o ON o.id = i.organization_id + WHERE i.id = $1 + ` + row := r.db.QueryRow(ctx, query, id) + var inv models.OrganizationInvitation + var org models.Organization + err := row.Scan( + &inv.ID, &inv.OrganizationID, &inv.Email, &inv.Role, &inv.RoleID, &inv.Permissions, &inv.InvitedBy, &inv.Token, &inv.ExpiresAt, &inv.CreatedAt, &org.ID, &org.Name, &org.Slug, &org.AvatarURL, &org.OwnerUserID, &org.CreatedAt, &org.UpdatedAt, &org.DeletionScheduledAt, &org.DeletionScheduledFor, @@ -405,7 +460,7 @@ func (r *organizationRepository) GetInvitationByEmail(ctx context.Context, orgID // GetPendingInvitations retrieves all pending invitations for an organization func (r *organizationRepository) GetPendingInvitations(ctx context.Context, orgID uuid.UUID) ([]models.OrganizationInvitation, error) { query := ` - SELECT id, organization_id, email, role, permissions, invited_by, token, expires_at, created_at + SELECT id, organization_id, email, role, role_id, permissions, invited_by, token, expires_at, created_at FROM organization_invitations WHERE organization_id = $1 AND expires_at > NOW() ORDER BY created_at DESC @@ -419,7 +474,7 @@ func (r *organizationRepository) GetPendingInvitations(ctx context.Context, orgI var invitations []models.OrganizationInvitation for rows.Next() { var inv models.OrganizationInvitation - err := rows.Scan(&inv.ID, &inv.OrganizationID, &inv.Email, &inv.Role, &inv.Permissions, &inv.InvitedBy, &inv.Token, &inv.ExpiresAt, &inv.CreatedAt) + err := rows.Scan(&inv.ID, &inv.OrganizationID, &inv.Email, &inv.Role, &inv.RoleID, &inv.Permissions, &inv.InvitedBy, &inv.Token, &inv.ExpiresAt, &inv.CreatedAt) if err != nil { return nil, err } @@ -498,15 +553,24 @@ func (r *organizationRepository) TransferOwnership(ctx context.Context, orgID, n return err } - // Update old owner to admin - _, err = tx.Exec(ctx, `UPDATE organization_members SET role = 'admin', permissions = $3 WHERE organization_id = $1 AND user_id = $2`, + // Re-home the old owner onto the org's Admin role row when one exists + // (roles are data); otherwise fall back to a detached Admin-mask member. + _, err = tx.Exec(ctx, ` + UPDATE organization_members om SET + role = COALESCE(r.name, 'Admin'), + role_id = r.id, + permissions = COALESCE(r.permissions, $3) + FROM (SELECT 1) one + LEFT JOIN organization_roles r ON r.organization_id = $1 AND r.name = 'Admin' + WHERE om.organization_id = $1 AND om.user_id = $2`, orgID, currentOwnerID, models.RolePermissions[models.RoleAdmin]) if err != nil { return err } - // Update new owner to owner - _, err = tx.Exec(ctx, `UPDATE organization_members SET role = 'owner', permissions = $3 WHERE organization_id = $1 AND user_id = $2`, + // Owner is a membership status, not a role: role_id must be NULL so role + // edits can never write through onto the owner row. + _, err = tx.Exec(ctx, `UPDATE organization_members SET role = 'owner', role_id = NULL, permissions = $3 WHERE organization_id = $1 AND user_id = $2`, orgID, newOwnerUserID, models.RolePermissions[models.RoleOwner]) if err != nil { return err @@ -861,7 +925,7 @@ func (r *organizationRepository) SearchOrganizationsForAdmin(ctx context.Context if len(items) > limit { result.Data = items[:limit] last := items[limit-1].ID - result.Pagination.NextCursor = &last + result.Pagination.NextCursor = paging.UUIDString(last) } // Total count for the same filter — drop the trailing LIMIT arg. @@ -1263,7 +1327,7 @@ func (r *organizationRepository) ListLimitRequestsForAdmin(ctx context.Context, if len(items) > limit { result.Data = items[:limit] last := items[limit-1].ID - result.Pagination.NextCursor = &last + result.Pagination.NextCursor = paging.UUIDString(last) } // Total count for the same filter — drop the trailing LIMIT arg. diff --git a/internal/repository/pg_organization_roles.go b/internal/repository/pg_organization_roles.go new file mode 100644 index 00000000..00516c37 --- /dev/null +++ b/internal/repository/pg_organization_roles.go @@ -0,0 +1,183 @@ +package repository + +import ( + "context" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/warmbly/warmbly/internal/models" +) + +// Custom-role storage on the organization repository. Effective member +// permissions stay denormalized on organization_members.permissions: role +// edits write through to assigned members inside one transaction, so every +// permission reader (Go middleware, realtime auth) stays JOIN-free. + +// ListRoles returns the org's custom roles with live member counts. +func (r *organizationRepository) ListRoles(ctx context.Context, orgID uuid.UUID) ([]models.OrganizationRole, error) { + query := ` + SELECT + rl.id, rl.organization_id, rl.name, rl.description, rl.color, rl.permissions, + rl.created_at, rl.updated_at, + (SELECT COUNT(*) FROM organization_members om WHERE om.role_id = rl.id) AS member_count + FROM organization_roles rl + WHERE rl.organization_id = $1 + ORDER BY rl.created_at ASC + ` + rows, err := r.db.Query(ctx, query, orgID) + if err != nil { + return nil, err + } + defer rows.Close() + + var roles []models.OrganizationRole + for rows.Next() { + var role models.OrganizationRole + if err := rows.Scan( + &role.ID, &role.OrganizationID, &role.Name, &role.Description, &role.Color, &role.Permissions, + &role.CreatedAt, &role.UpdatedAt, &role.MemberCount, + ); err != nil { + return nil, err + } + roles = append(roles, role) + } + return roles, nil +} + +// GetRoleByID loads one custom role, org-scoped. nil, nil when unknown. +func (r *organizationRepository) GetRoleByID(ctx context.Context, orgID, roleID uuid.UUID) (*models.OrganizationRole, error) { + query := ` + SELECT + rl.id, rl.organization_id, rl.name, rl.description, rl.color, rl.permissions, + rl.created_at, rl.updated_at, + (SELECT COUNT(*) FROM organization_members om WHERE om.role_id = rl.id) AS member_count + FROM organization_roles rl + WHERE rl.organization_id = $1 AND rl.id = $2 + ` + var role models.OrganizationRole + err := r.db.QueryRow(ctx, query, orgID, roleID).Scan( + &role.ID, &role.OrganizationID, &role.Name, &role.Description, &role.Color, &role.Permissions, + &role.CreatedAt, &role.UpdatedAt, &role.MemberCount, + ) + if err == pgx.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return &role, nil +} + +// CountRoles returns how many custom roles the org has (for the cap check). +func (r *organizationRepository) CountRoles(ctx context.Context, orgID uuid.UUID) (int, error) { + var count int + err := r.db.QueryRow(ctx, `SELECT COUNT(*) FROM organization_roles WHERE organization_id = $1`, orgID).Scan(&count) + return count, err +} + +// CreateRole inserts a custom role. +func (r *organizationRepository) CreateRole(ctx context.Context, role *models.OrganizationRole) error { + query := ` + INSERT INTO organization_roles (id, organization_id, name, description, color, permissions) + VALUES ($1, $2, $3, $4, $5, $6) + ` + _, err := r.db.Exec(ctx, query, role.ID, role.OrganizationID, role.Name, role.Description, role.Color, role.Permissions) + return err +} + +// UpdateRole edits a custom role and writes the new name + permissions +// through to every assigned member in the same transaction, keeping the +// denormalized member snapshots authoritative. +func (r *organizationRepository) UpdateRole(ctx context.Context, role *models.OrganizationRole) error { + tx, err := r.db.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) //nolint:errcheck + + if _, err := tx.Exec(ctx, ` + UPDATE organization_roles + SET name = $3, description = $4, color = $5, permissions = $6, updated_at = NOW() + WHERE organization_id = $1 AND id = $2 + `, role.OrganizationID, role.ID, role.Name, role.Description, role.Color, role.Permissions); err != nil { + return err + } + + // Recompute the effective OR snapshot for every member assigned this + // role (they may hold others), excluding the owner. + if _, err := tx.Exec(ctx, ` + UPDATE organization_members om + SET permissions = COALESCE(( + SELECT bit_or(r.permissions) + FROM organization_member_roles mr + JOIN organization_roles r ON r.id = mr.role_id + WHERE mr.organization_id = om.organization_id AND mr.user_id = om.user_id + ), 0), + role = COALESCE(( + SELECT r2.name FROM organization_member_roles mr2 + JOIN organization_roles r2 ON r2.id = mr2.role_id + WHERE mr2.organization_id = om.organization_id AND mr2.user_id = om.user_id + ORDER BY r2.created_at ASC LIMIT 1 + ), om.role) + WHERE om.role <> 'owner' AND EXISTS ( + SELECT 1 FROM organization_member_roles mx + WHERE mx.organization_id = om.organization_id + AND mx.user_id = om.user_id AND mx.role_id = $1 + ) + `, role.ID); err != nil { + return err + } + + // Pending invitations snapshot the role name for display; keep in sync. + if _, err := tx.Exec(ctx, ` + UPDATE organization_invitations + SET role = $2 + WHERE role_id = $1 + `, role.ID, role.Name); err != nil { + return err + } + + return tx.Commit(ctx) +} + +// DeleteRole removes a role. Members keep their other roles; the join FK +// cascades the assignment rows away and each affected member's effective +// snapshot is recomputed in the same transaction. Roles are freely +// deletable (a member left with no roles simply has no permissions). +func (r *organizationRepository) DeleteRole(ctx context.Context, orgID, roleID uuid.UUID) error { + tx, err := r.db.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) //nolint:errcheck + + // Capture who holds this role before the cascade clears the rows. + rows, err := tx.Query(ctx, + `SELECT user_id FROM organization_member_roles WHERE role_id = $1`, roleID) + if err != nil { + return err + } + var affected []uuid.UUID + for rows.Next() { + var uid uuid.UUID + if err := rows.Scan(&uid); err != nil { + rows.Close() + return err + } + affected = append(affected, uid) + } + rows.Close() + + if _, err := tx.Exec(ctx, + `DELETE FROM organization_roles WHERE organization_id = $1 AND id = $2`, orgID, roleID); err != nil { + return err + } + // FK ON DELETE CASCADE already removed the member/invitation role rows; + // recompute each affected member's snapshot. + for _, uid := range affected { + if err := recomputeMemberPermissions(ctx, tx, orgID, uid); err != nil { + return err + } + } + return tx.Commit(ctx) +} diff --git a/internal/repository/pg_tracked_links.go b/internal/repository/pg_tracked_links.go new file mode 100644 index 00000000..8ca4979c --- /dev/null +++ b/internal/repository/pg_tracked_links.go @@ -0,0 +1,93 @@ +package repository + +import ( + "context" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// TrackedLink is one minted click-tracking ticket: the email carries only the +// opaque ID, this row holds where it actually goes. +type TrackedLink struct { + ID uuid.UUID + TaskID uuid.UUID + CampaignID uuid.UUID + Destination string + CreatedAt time.Time +} + +// TrackedLinkRepository is the server-side click-link store. Only the send +// pipeline writes; the tracking service reads through the backend internal +// API. There is deliberately no update path from any request-facing surface. +type TrackedLinkRepository interface { + CreateBatch(ctx context.Context, links []TrackedLink) error + GetByID(ctx context.Context, id uuid.UUID) (*TrackedLink, error) + Cleanup(ctx context.Context, olderThanDays int) (int64, error) +} + +type trackedLinkRepository struct { + db *pgxpool.Pool +} + +// NewTrackedLinkRepository creates a new tracked link repository +func NewTrackedLinkRepository(db *pgxpool.Pool) TrackedLinkRepository { + return &trackedLinkRepository{db: db} +} + +// CreateBatch inserts all minted links for one outgoing email in a single +// round trip. All-or-nothing: the caller falls back to unwrapped links when +// this fails, so a partially-stored email can never ship dead links. +func (r *trackedLinkRepository) CreateBatch(ctx context.Context, links []TrackedLink) error { + if len(links) == 0 { + return nil + } + + rows := make([][]any, 0, len(links)) + for _, l := range links { + rows = append(rows, []any{l.ID, l.TaskID, l.CampaignID, l.Destination}) + } + + _, err := r.db.CopyFrom(ctx, + pgx.Identifier{"tracked_links"}, + []string{"id", "task_id", "campaign_id", "destination"}, + pgx.CopyFromRows(rows), + ) + return err +} + +// GetByID resolves a ticket to its destination. nil, nil when unknown. +func (r *trackedLinkRepository) GetByID(ctx context.Context, id uuid.UUID) (*TrackedLink, error) { + query := ` + SELECT id, task_id, campaign_id, destination, created_at + FROM tracked_links + WHERE id = $1 + ` + + var l TrackedLink + err := r.db.QueryRow(ctx, query, id).Scan(&l.ID, &l.TaskID, &l.CampaignID, &l.Destination, &l.CreatedAt) + if err == pgx.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return &l, nil +} + +// Cleanup removes links older than the retention horizon (their tickets then +// 404, which is acceptable for year-old emails). +func (r *trackedLinkRepository) Cleanup(ctx context.Context, olderThanDays int) (int64, error) { + query := ` + DELETE FROM tracked_links + WHERE created_at < NOW() - $1 * INTERVAL '1 day' + ` + + tag, err := r.db.Exec(ctx, query, olderThanDays) + if err != nil { + return 0, err + } + return tag.RowsAffected(), nil +} diff --git a/internal/repository/pg_unibox.go b/internal/repository/pg_unibox.go index 4262d5a4..ea0a31f7 100644 --- a/internal/repository/pg_unibox.go +++ b/internal/repository/pg_unibox.go @@ -26,12 +26,12 @@ type UniboxRepository interface { UpdateEntry(ctx context.Context, userID, emailID, id uuid.UUID, e *UpdateUniboxEntry) error GetIncoming(ctx context.Context, userID uuid.UUID, limit int, cursor string) (*models.MailSearchResult, error) GetByID(ctx context.Context, userID, id uuid.UUID) (*models.EmailMessageStoreData, error) - GetByThread(ctx context.Context, userID, emailID uuid.UUID, threadID string, limit int, cursor string) (*models.MailSearchResult, error) + GetByThread(ctx context.Context, orgID, emailID uuid.UUID, threadID string, limit int, cursor string) (*models.MailSearchResult, error) GetBySender(ctx context.Context, userID uuid.UUID, sender string, limit int, cursor string) (*models.MailSearchResult, error) - Search(ctx context.Context, userID uuid.UUID, params *models.MailSearchParams) (*models.MailSearchResult, error) - GetUnseenCount(ctx context.Context, userID uuid.UUID, emailAccountID *uuid.UUID) (int64, error) + Search(ctx context.Context, orgID, userID uuid.UUID, params *models.MailSearchParams) (*models.MailSearchResult, error) + GetUnseenCount(ctx context.Context, orgID uuid.UUID, emailAccountID *uuid.UUID) (int64, error) MarkSeen(ctx context.Context, userID, id uuid.UUID, seen bool) error - MarkSeenBulk(ctx context.Context, userID uuid.UUID, ids []uuid.UUID, seen bool) error + MarkSeenBulk(ctx context.Context, orgID uuid.UUID, ids []uuid.UUID, seen bool) error Delete(ctx context.Context, userID, id uuid.UUID) error // Snooze: per (user, thread). UpsertSnooze adopts the new @@ -44,7 +44,7 @@ type UniboxRepository interface { // Overview powers the scope rail + top metric strip. Single call // so the client doesn't fan out N+M queries for each mailbox/tag. - Overview(ctx context.Context, userID uuid.UUID) (*models.UniboxOverview, error) + Overview(ctx context.Context, orgID uuid.UUID) (*models.UniboxOverview, error) // Conversation labels. SetThreadLabels replaces the full label set // on a thread (idempotent PUT semantics, only the user's own @@ -52,6 +52,12 @@ type UniboxRepository interface { // set for one thread. SetThreadLabels(ctx context.Context, userID uuid.UUID, threadID string, categoryIDs []uuid.UUID) ([]models.MiniCategory, error) ListThreadLabels(ctx context.Context, userID uuid.UUID, threadID string) ([]models.MiniCategory, error) + // AddThreadLabels attaches labels to a thread WITHOUT removing existing ones + // (additive; for automation/step "label email" actions). LatestThreadIDForContact + // finds the user's most recent conversation with an address, so a campaign + // step that knows the contact but not the thread can still label it. + AddThreadLabels(ctx context.Context, userID uuid.UUID, threadID string, categoryIDs []uuid.UUID) error + LatestThreadIDForContact(ctx context.Context, userID uuid.UUID, email string) (string, error) } type uniboxRepository struct { @@ -203,16 +209,19 @@ func (r *uniboxRepository) GetByID(ctx context.Context, userID, id uuid.UUID) (* } // GetByThread returns the messages in a thread. emailID is optional — -// pass uuid.Nil to span every mailbox the user owns (the typical -// unified-inbox case where the caller only knows the thread). -func (r *uniboxRepository) GetByThread(ctx context.Context, userID, emailID uuid.UUID, threadID string, limit int, cursor string) (*models.MailSearchResult, error) { +// pass uuid.Nil to span every mailbox in the organization (the typical +// unified-inbox case where the caller only knows the thread). Scoped by org, +// not user_id, so any member with unibox access sees the whole conversation, +// matching the org-scoped inbox list. +func (r *uniboxRepository) GetByThread(ctx context.Context, orgID, emailID uuid.UUID, threadID string, limit int, cursor string) (*models.MailSearchResult, error) { query := fmt.Sprintf(` SELECT %s FROM unibox_emails - WHERE user_id = $1 AND thread_id = $2 + WHERE email_id IN (SELECT id FROM email_accounts WHERE organization_id = $1) + AND thread_id = $2 `, strings.Join(mailFieldsPreview, ", ")) - args := []any{userID, threadID} + args := []any{orgID, threadID} argPos := 3 if emailID != uuid.Nil { @@ -282,14 +291,16 @@ func (r *uniboxRepository) GetBySender(ctx context.Context, userID uuid.UUID, se // content filter (the default inbox) that's the whole thread. // - thread/representative-level filters (awaiting reply, category) // and keyset pagination run on the collapsed row. -func (r *uniboxRepository) Search(ctx context.Context, userID uuid.UUID, params *models.MailSearchParams) (*models.MailSearchResult, error) { +func (r *uniboxRepository) Search(ctx context.Context, orgID, userID uuid.UUID, params *models.MailSearchParams) (*models.MailSearchResult, error) { previewCols := make([]string, len(mailFieldsPreview)) for i, c := range mailFieldsPreview { previewCols[i] = "ue." + c } - args := []any{userID} - argPos := 2 + // $1 = orgID (scope mail to the workspace's mailboxes); $2 = userID + // (per-user thread labels stay personal). Dynamic filters start at $3. + args := []any{orgID, userID} + argPos := 3 // ── Inner windowed subquery: row-level filters + per-thread aggs ── // Partition by the thread, but treat an empty thread_id (the column @@ -302,7 +313,7 @@ func (r *uniboxRepository) Search(ctx context.Context, userID uuid.UUID, params COUNT(*) OVER (PARTITION BY COALESCE(NULLIF(ue.thread_id, ''), ue.id::text)) AS message_count, bool_or(NOT ue.seen) OVER (PARTITION BY COALESCE(NULLIF(ue.thread_id, ''), ue.id::text)) AS has_unread FROM unibox_emails ue - WHERE ue.user_id = $1`, strings.Join(previewCols, ", ")) + WHERE ue.email_id IN (SELECT id FROM email_accounts WHERE organization_id = $1)`, strings.Join(previewCols, ", ")) // Snooze handling. nil = exclude snoozed (the inbox default), so // threads with an active snooze never appear unless asked for. @@ -375,7 +386,7 @@ func (r *uniboxRepository) Search(ctx context.Context, userID uuid.UUID, params SELECT json_agg(json_build_object('id', c.id, 'title', c.title, 'color', c.color) ORDER BY c.position ASC, c.title ASC) FROM unibox_thread_labels utl JOIN categories c ON c.id = utl.category_id - WHERE utl.user_id = $1 AND utl.thread_id = b.thread_id + WHERE utl.user_id = $2 AND utl.thread_id = b.thread_id ), '[]'::json ) AS labels FROM (%s) b @@ -387,7 +398,7 @@ func (r *uniboxRepository) Search(ctx context.Context, userID uuid.UUID, params query += ` AND EXISTS ( SELECT 1 FROM email_accounts ea - WHERE ea.user_id = $1 + WHERE ea.organization_id = $1 AND ea.email = ANY(b.from_addr) )` } @@ -396,7 +407,7 @@ func (r *uniboxRepository) Search(ctx context.Context, userID uuid.UUID, params query += fmt.Sprintf(` AND EXISTS ( SELECT 1 FROM unibox_thread_labels utl - WHERE utl.user_id = $1 + WHERE utl.user_id = $2 AND utl.thread_id = b.thread_id AND utl.category_id = ANY($%d) )`, argPos) @@ -422,7 +433,7 @@ func (r *uniboxRepository) Search(ctx context.Context, userID uuid.UUID, params return r.queryThreadList(ctx, query, args, params.PageSize) } -func (r *uniboxRepository) GetUnseenCount(ctx context.Context, userID uuid.UUID, emailAccountID *uuid.UUID) (int64, error) { +func (r *uniboxRepository) GetUnseenCount(ctx context.Context, orgID uuid.UUID, emailAccountID *uuid.UUID) (int64, error) { var count int64 // Count unread THREADS (distinct, empty-thread-safe), not messages, @@ -430,16 +441,19 @@ func (r *uniboxRepository) GetUnseenCount(ctx context.Context, userID uuid.UUID, if emailAccountID != nil { err := r.db.QueryRow(ctx, `SELECT COUNT(DISTINCT COALESCE(NULLIF(thread_id, ''), id::text)) - FROM unibox_emails WHERE user_id = $1 AND email_id = $2 AND seen = FALSE`, - userID, *emailAccountID, + FROM unibox_emails + WHERE email_id IN (SELECT id FROM email_accounts WHERE organization_id = $1) + AND email_id = $2 AND seen = FALSE`, + orgID, *emailAccountID, ).Scan(&count) return count, err } err := r.db.QueryRow(ctx, `SELECT COUNT(DISTINCT COALESCE(NULLIF(thread_id, ''), id::text)) - FROM unibox_emails WHERE user_id = $1 AND seen = FALSE`, - userID, + FROM unibox_emails + WHERE email_id IN (SELECT id FROM email_accounts WHERE organization_id = $1) AND seen = FALSE`, + orgID, ).Scan(&count) return count, err } @@ -452,17 +466,18 @@ func (r *uniboxRepository) MarkSeen(ctx context.Context, userID, id uuid.UUID, s return err } -func (r *uniboxRepository) MarkSeenBulk(ctx context.Context, userID uuid.UUID, ids []uuid.UUID, seen bool) error { +func (r *uniboxRepository) MarkSeenBulk(ctx context.Context, orgID uuid.UUID, ids []uuid.UUID, seen bool) error { if len(ids) == 0 { return nil } - if len(ids) == 1 { - return r.MarkSeen(ctx, userID, ids[0], seen) - } - + // Org-scoped so any member with unibox access can clear the shared inbox's + // unread state, not only the mailbox owner. The unread count is org-wide, so + // a user_id filter would leave the badge stuck for non-owner members. ANY($3) + // also covers the single-id case. _, err := r.db.Exec(ctx, - `UPDATE unibox_emails SET seen = $1, updated_at = NOW() WHERE user_id = $2 AND id = ANY($3)`, - seen, userID, ids, + `UPDATE unibox_emails SET seen = $1, updated_at = NOW() + WHERE id = ANY($3) AND email_id IN (SELECT id FROM email_accounts WHERE organization_id = $2)`, + seen, orgID, ids, ) return err } @@ -642,6 +657,63 @@ func (r *uniboxRepository) ListThreadLabels(ctx context.Context, userID uuid.UUI return out, nil } +// AddThreadLabels additively attaches labels to a thread (never removes any), +// so an automation/step action can tag a conversation without clobbering labels +// a teammate set by hand. Only the user's own categories are attached +// (SELECT-guarded), mirroring SetThreadLabels; a bogus id is silently dropped. +func (r *uniboxRepository) AddThreadLabels(ctx context.Context, userID uuid.UUID, threadID string, categoryIDs []uuid.UUID) error { + if threadID == "" || len(categoryIDs) == 0 { + return nil + } + _, err := r.db.Exec(ctx, ` + INSERT INTO unibox_thread_labels (user_id, thread_id, category_id) + SELECT $1, $2, c.id + FROM categories c + WHERE c.user_id = $1 AND c.id = ANY($3) + ON CONFLICT (user_id, thread_id, category_id) DO NOTHING + `, userID, threadID, categoryIDs) + return err +} + +// LatestThreadIDForContact returns the thread id of the most recent conversation +// where the address SENT a message into the user's unibox (an inbound reply), or +// "" when there is none. Matching on from_addr (not to_addr) is deliberate: the +// "label email" action only makes sense once the contact has replied, so a +// contact that never responded resolves to "" and the action is a clean no-op. +// Addresses are raw header forms ("Name " or a bare address); the EXACT +// address is extracted (the text inside angle brackets, else the trimmed value) +// and compared case-insensitively — never a substring contains, so a@b.com does +// not match xa@b.com or a@b.com.evil. +func (r *uniboxRepository) LatestThreadIDForContact(ctx context.Context, userID uuid.UUID, email string) (string, error) { + email = strings.TrimSpace(email) + if email == "" { + return "", nil + } + rows, err := r.db.Query(ctx, ` + SELECT thread_id + FROM unibox_emails + WHERE user_id = $1 AND thread_id <> '' + AND EXISTS ( + SELECT 1 FROM unnest(from_addr) a + WHERE lower(coalesce(substring(a from '<([^>]*)>'), btrim(a))) = lower($2) + ) + ORDER BY internal_date DESC + LIMIT 1 + `, userID, email) + if err != nil { + return "", err + } + defer rows.Close() + if rows.Next() { + var threadID string + if err := rows.Scan(&threadID); err != nil { + return "", err + } + return threadID, nil + } + return "", rows.Err() +} + // ── Snoozes ──────────────────────────────────────────────────────────── func (r *uniboxRepository) UpsertSnooze(ctx context.Context, userID uuid.UUID, threadID string, until time.Time) (*models.UniboxSnooze, error) { @@ -701,7 +773,7 @@ func (r *uniboxRepository) ListSnoozes(ctx context.Context, userID uuid.UUID) ([ // needs. We use CTEs so each metric is a single sequential scan rather // than N+M queries from the client. -func (r *uniboxRepository) Overview(ctx context.Context, userID uuid.UUID) (*models.UniboxOverview, error) { +func (r *uniboxRepository) Overview(ctx context.Context, orgID uuid.UUID) (*models.UniboxOverview, error) { now := time.Now().UTC() todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) weekStart := todayStart.AddDate(0, 0, -6) @@ -731,7 +803,7 @@ func (r *uniboxRepository) Overview(ctx context.Context, userID uuid.UUID) (*mod AND s.snoozed_until > NOW() ) AS is_snoozed FROM unibox_emails e - WHERE e.user_id = $1 + WHERE e.email_id IN (SELECT id FROM email_accounts WHERE organization_id = $1) ), threads AS ( SELECT @@ -749,7 +821,7 @@ func (r *uniboxRepository) Overview(ctx context.Context, userID uuid.UUID) (*mod ORDER BY tkey, internal_date DESC ), user_mailbox_emails AS ( - SELECT email FROM email_accounts WHERE user_id = $1 + SELECT email FROM email_accounts WHERE organization_id = $1 ) SELECT COUNT(*) FILTER (WHERE NOT t.is_snoozed) AS total, @@ -760,7 +832,7 @@ func (r *uniboxRepository) Overview(ctx context.Context, userID uuid.UUID) (*mod (SELECT COUNT(*) FROM latest_per_thread l WHERE EXISTS (SELECT 1 FROM user_mailbox_emails u WHERE u.email = ANY(l.from_addr))) AS awaiting FROM threads t - `, userID, todayStart, weekStart).Scan( + `, orgID, todayStart, weekStart).Scan( &overview.Total, &overview.Unread, &overview.Today, @@ -793,10 +865,10 @@ func (r *uniboxRepository) Overview(ctx context.Context, userID uuid.UUID) (*mod )) AS total FROM email_accounts ea LEFT JOIN unibox_emails ue ON ue.email_id = ea.id AND ue.user_id = ea.user_id - WHERE ea.user_id = $1 + WHERE ea.organization_id = $1 GROUP BY ea.id, ea.email, ea.name ORDER BY ea.email ASC - `, userID) + `, orgID) if err != nil { return nil, err } @@ -836,7 +908,7 @@ func (r *uniboxRepository) Overview(ctx context.Context, userID uuid.UUID) (*mod WHERE t.user_id = $1 GROUP BY t.id, t.title, t.color, t.position ORDER BY t.position ASC, t.title ASC - `, userID) + `, orgID) if err != nil { // Tags are optional; never let an empty tag join take the // whole overview down. @@ -881,7 +953,7 @@ func (r *uniboxRepository) Overview(ctx context.Context, userID uuid.UUID) (*mod WHERE c.user_id = $1 GROUP BY c.id, c.title, c.color, c.position ORDER BY c.position ASC, c.title ASC - `, userID) + `, orgID) if err != nil { return overview, nil } diff --git a/internal/tasks/campaign_reconciler.go b/internal/tasks/campaign_reconciler.go new file mode 100644 index 00000000..4190f544 --- /dev/null +++ b/internal/tasks/campaign_reconciler.go @@ -0,0 +1,100 @@ +package tasks + +import ( + "context" + "errors" + "time" + + "github.com/google/uuid" + "github.com/rs/zerolog/log" + + "github.com/warmbly/warmbly/internal/scheduler" +) + +// campaignReconcileBatch caps how many campaigns a single reconcile pass will +// re-seed. Plenty for steady state; the next tick mops up any overflow. +const campaignReconcileBatch = 500 + +// ReconcileCampaignSchedules re-seeds the wakeup chain for active campaigns that +// have no pending task. A campaign chain is self-perpetuating (each tick +// enqueues the next), so a swallowed enqueue, a worker bounce mid-tick, or a +// crash between send and enqueue leaves the campaign stranded with no successor. +// Unlike warmup, campaigns have no other bootstrap once started, so this sweep +// is the backstop that keeps them from silently stalling. Returns the number of +// chains re-seeded this pass. +func (s *tasksService) ReconcileCampaignSchedules(ctx context.Context, limit int) (int, error) { + ids, err := s.campaignRepo.ListCampaignScheduleCandidates(ctx, limit) + if err != nil { + return 0, err + } + + seeded := 0 + for _, id := range ids { + campaign, xerr := s.campaignRepo.GetByID(ctx, id) + if xerr != nil || campaign == nil || campaign.Status != "active" { + continue + } + + // Compute the next slot the same way a normal tick does. createCampaignTask + // holds a per-campaign advisory lock and no-ops if a pending task raced in, + // so re-seeding is safe even if a real tick enqueues concurrently. + nextTime, _, accountID, cerr := s.scheduler.CalculateNextCampaignTime(ctx, id) + switch { + case cerr == nil, errors.Is(cerr, scheduler.ErrCampaignDeferred): + schedAt := nextTime + if schedAt.IsZero() { + schedAt = time.Now().UTC().Add(1 * time.Minute) + } + if err := s.createCampaignTask(ctx, id, accountID, schedAt); err != nil { + log.Warn().Err(err).Str("campaign_id", id.String()).Msg("campaign reconcile: re-seed failed") + continue + } + seeded++ + case errors.Is(cerr, scheduler.ErrNoEmailAccounts): + // No mailbox to send from — pause rather than spin every pass. + s.autoPauseCampaign(ctx, id, uuid.Nil) + case errors.Is(cerr, scheduler.ErrCampaignCompleted), errors.Is(cerr, scheduler.ErrCampaignEnded): + // Nothing left to send (or past its end date): close it out. + s.campaignRepo.UpdateStatus(ctx, id, "completed") + default: + // Transient error (DB blip): leave it; the next pass retries. + log.Warn().Err(cerr).Str("campaign_id", id.String()).Msg("campaign reconcile: next-time calc failed; will retry") + } + } + return seeded, nil +} + +// StartCampaignReconciler runs ReconcileCampaignSchedules on an interval until +// the context is cancelled. Mirrors StartWarmupReconciler and is started from +// the backend, which owns Cloud Tasks. +func (s *tasksService) StartCampaignReconciler(ctx context.Context, interval time.Duration) { + ticker := time.NewTicker(interval) + defer ticker.Stop() + + // Seed once on boot so chains recover promptly after a restart instead of + // waiting a full interval. + s.reconcileCampaignsOnce(ctx) + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + s.reconcileCampaignsOnce(ctx) + } + } +} + +func (s *tasksService) reconcileCampaignsOnce(ctx context.Context) { + rctx, cancel := context.WithTimeout(ctx, 2*time.Minute) + defer cancel() + + seeded, err := s.ReconcileCampaignSchedules(rctx, campaignReconcileBatch) + if err != nil { + log.Warn().Err(err).Msg("campaign reconcile pass failed") + return + } + if seeded > 0 { + log.Info().Int("seeded", seeded).Msg("campaign reconcile re-seeded chains") + } +} diff --git a/internal/tasks/campaign_task.go b/internal/tasks/campaign_task.go index 9a17046b..c0f95400 100644 --- a/internal/tasks/campaign_task.go +++ b/internal/tasks/campaign_task.go @@ -117,6 +117,7 @@ func (s *tasksService) HandleCampaignTask(task *proto.ProcessTask) *errx.Error { } s.streamingPublisher.PublishTaskProgress(ctx, &pubsub.TaskProgressEvent{ BaseEvent: pubsub.BaseEvent{UserID: campaign.UserID}, + OrgID: campaignOrgID(campaign), CampaignID: campaign.ID.String(), TaskID: taskID.String(), Status: "active", @@ -193,13 +194,20 @@ func (s *tasksService) HandleCampaignTask(task *proto.ProcessTask) *errx.Error { executionStatus = "completed" return nil } - if errors.Is(err, scheduler.ErrCampaignCompleted) { + // Terminal: all emails sent, OR the campaign passed its end date. Both end + // the campaign at the "completed" status (the status enum has no separate + // "ended"); the reason differs in the activity log. + if errors.Is(err, scheduler.ErrCampaignCompleted) || errors.Is(err, scheduler.ErrCampaignEnded) { + reason := "Campaign completed: all emails sent" + if errors.Is(err, scheduler.ErrCampaignEnded) { + reason = "Campaign ended: reached its end date" + } s.campaignRepo.UpdateStatus(ctx, campaign.ID, "completed") if s.campaignLogRepo != nil { s.campaignLogRepo.CreateLog(ctx, &repository.CampaignLogEntry{ CampaignID: campaign.ID, EventType: "completed", - Message: "Campaign completed: all emails sent", + Message: reason, }) } // Broadcast live so the dashboard (and the sidebar campaign counters) @@ -210,15 +218,45 @@ func (s *tasksService) HandleCampaignTask(task *proto.ProcessTask) *errx.Error { EventType: pubsub.EventCampaignCompleted, UserID: campaign.UserID, }, + OrgID: campaignOrgID(campaign), CampaignID: campaign.ID.String(), Name: campaign.Name, Status: "completed", }) } + s.taskRepo.UpdateTaskStatus(ctx, taskID, "completed") + executionStatus = "completed" + return nil } - s.taskRepo.UpdateTaskStatus(ctx, taskID, "completed") - executionStatus = "completed" - return nil + // Benign: the campaign was paused/deleted between ticks. Stop this chain + // cleanly; a resume re-seeds it. + if errors.Is(err, scheduler.ErrCampaignNotActive) { + s.taskRepo.UpdateTaskStatus(ctx, taskID, "cancelled") + executionStatus = "completed" + return nil + } + // Transient / unknown error (a DB blip bubbled up from the scheduler). Do + // NOT silently mark the task completed — that strands the campaign with no + // successor. Record the failure for dashboard review, reset the task to + // pending, and return 5xx so Cloud Tasks retries (with backoff). The + // campaign reconciler is the backstop if retries are ever exhausted. + sentry.CaptureException(err) + s.recordSchedulerFailure(ctx, campaign.ID, "scheduler_error", "Could not compute the next step; retrying", err) + // Pulse the dashboard so the failure appears live for the whole team. A + // CAMPAIGN_UPDATED with empty status invalidates the campaign logs query + // without flipping the campaign's status. + if s.streamingPublisher != nil { + s.streamingPublisher.PublishCampaignEvent(ctx, &pubsub.CampaignEvent{ + BaseEvent: pubsub.BaseEvent{EventType: pubsub.EventCampaignUpdated, UserID: campaign.UserID}, + OrgID: campaignOrgID(campaign), + CampaignID: campaign.ID.String(), + }) + } + if rerr := s.taskRepo.UpdateTaskStatus(ctx, taskID, "pending"); rerr != nil { + sentry.CaptureException(rerr) + } + executionStatus = "failed" + return errx.InternalError() } // STEP 7: Load contact and sequence @@ -425,7 +463,16 @@ func (s *tasksService) HandleCampaignTask(task *proto.ProcessTask) *errx.Error { } if campaign.LinkTracking && bodyHTML != "" { - bodyHTML = WrapLinksForTracking(bodyHTML, taskID, trackingDomain) + wrapped, links := WrapLinksForTracking(bodyHTML, taskID, campaign.ID, trackingDomain) + if len(links) == 0 { + bodyHTML = wrapped + } else if err := s.trackedLinkRepo.CreateBatch(ctx, links); err != nil { + // Tracking is a nicety: ship the original working links rather + // than tickets that would 404 at the tracking service. + log.Warn().Err(err).Str("campaign_id", campaign.ID.String()).Str("task_id", taskID.String()).Msg("Failed to store tracked links; sending untracked") + } else { + bodyHTML = wrapped + } } // STEP 12: Add signature @@ -518,6 +565,7 @@ func (s *tasksService) HandleCampaignTask(task *proto.ProcessTask) *errx.Error { } s.streamingPublisher.PublishTaskProgress(ctx, &pubsub.TaskProgressEvent{ BaseEvent: pubsub.BaseEvent{UserID: campaign.UserID}, + OrgID: campaignOrgID(campaign), CampaignID: campaign.ID.String(), TaskID: taskID.String(), Status: "failed", @@ -622,8 +670,11 @@ func (s *tasksService) HandleCampaignTask(task *proto.ProcessTask) *errx.Error { break } } - s.streamingPublisher.PublishTaskProgress(ctx, &pubsub.TaskProgressEvent{ + // EMAIL_SENT (org-scoped): the whole team sees the send + which + // lead/step fired, live in the campaign view. + s.streamingPublisher.PublishEmailSent(ctx, &pubsub.TaskProgressEvent{ BaseEvent: pubsub.BaseEvent{UserID: campaign.UserID}, + OrgID: campaignOrgID(campaign), CampaignID: campaign.ID.String(), TaskID: taskID.String(), Status: "completed", @@ -701,29 +752,25 @@ func (s *tasksService) executeActionNode(ctx context.Context, campaign *models.C return xerr } return nil + case "label_email": + // Apply unibox labels to the contact's most recent conversation. A no-op + // when the contact has no thread yet (returns "" thread, nil error). + if len(cfg.LabelIDs) == 0 { + return nil + } + owner, perr := uuid.Parse(campaign.UserID) + if perr != nil { + return nil + } + if _, xerr := s.advanced.LabelLatestThreadForContact(ctx, owner, contact.Email, cfg.LabelIDs); xerr != nil { + return xerr + } + return nil case "unsubscribe": if xerr := s.advanced.Unsubscribe(ctx, campaign.ID, contact.ID); xerr != nil { return xerr } return nil - case "notify": - if s.advanced == nil || campaign.OrganizationID == nil { - return nil - } - event := models.WebhookEventCampaignAction - if cfg.NotifyEvent != "" { - event = models.WebhookEventType(cfg.NotifyEvent) - } - data := map[string]any{ - "campaign_id": campaign.ID.String(), - "contact_id": contact.ID.String(), - "contact_email": contact.Email, - } - for k, v := range cfg.NotifyData { - data[k] = v - } - s.advanced.EmitCampaignEvent(ctx, *campaign.OrganizationID, event, data) - return nil case "create_task": if s.advanced == nil || campaign.OrganizationID == nil { return nil @@ -860,6 +907,17 @@ func (s *tasksService) executeActionNode(ctx context.Context, campaign *models.C data[key] = RenderTemplate(kv.Value, *contact) } return s.automationRunner.RunAutomationByID(ctx, *campaign.OrganizationID, *cfg.AutomationID, data) + case "fire_event": + if s.advanced == nil || campaign.OrganizationID == nil { + return nil + } + s.advanced.FireCampaignEvent(ctx, *campaign.OrganizationID, campaign.ID.String(), cfg.EventName, cfg.EventFields, contact) + return nil + case "http_request": + if s.advanced == nil || campaign.OrganizationID == nil { + return nil + } + return s.advanced.RunCampaignHTTPRequest(ctx, *campaign.OrganizationID, cfg, contact) default: return nil } @@ -925,3 +983,37 @@ func (s *tasksService) publishEmailSentEvent( log.Warn().Err(err).Str("campaign_id", campaign.ID.String()).Str("task_id", task.ID.String()).Msg("Failed to publish email sent event") } } + +// campaignOrgID returns the campaign's organization id for org-scoped +// realtime events, or "" for legacy orgless rows. +func campaignOrgID(campaign *Campaign) string { + if campaign == nil || campaign.OrganizationID == nil { + return "" + } + return campaign.OrganizationID.String() +} + +// recordSchedulerFailure writes a campaign-scoped, reviewable failure to the +// activity log so a stalled or retrying step is VISIBLE in the dashboard +// instead of failing silently. metadata.level="error" tints it red in the +// campaign detail (TaskPreview) and the log feed is already realtime-invalidated +// for every teammate. Best-effort and nil-safe — recording a failure must never +// itself break the tick. +func (s *tasksService) recordSchedulerFailure(ctx context.Context, campaignID uuid.UUID, code, message string, cause error) { + if s.campaignLogRepo == nil { + return + } + meta := map[string]interface{}{ + "level": "error", + "code": code, + } + if cause != nil { + meta["error"] = cause.Error() + } + _ = s.campaignLogRepo.CreateLog(ctx, &repository.CampaignLogEntry{ + CampaignID: campaignID, + EventType: "scheduler_failed", + Message: message, + Metadata: meta, + }) +} diff --git a/internal/tasks/service.go b/internal/tasks/service.go index db511e16..ae9754e8 100644 --- a/internal/tasks/service.go +++ b/internal/tasks/service.go @@ -50,6 +50,11 @@ type TasksService interface { // Warmup scheduling lifecycle EnsureWarmupScheduled(ctx context.Context, accountID uuid.UUID) error StartWarmupReconciler(ctx context.Context, interval time.Duration) + + // StartCampaignReconciler re-seeds active campaigns whose self-perpetuating + // task chain died (swallowed enqueue / crash between ticks). Campaigns have + // no other bootstrap once started, so this is the stall backstop. + StartCampaignReconciler(ctx context.Context, interval time.Duration) } // AutomationRunner launches an automation graph by id. It's satisfied @@ -86,6 +91,7 @@ type tasksService struct { contactRepo repository.ContactRepository campaignLogRepo repository.CampaignLogRepository attachmentRepo repository.AttachmentRepository + trackedLinkRepo repository.TrackedLinkRepository // automationRunner launches automations from a campaign "run_automation" step. automationRunner AutomationRunner @@ -124,6 +130,7 @@ func NewService( campaignLogRepo repository.CampaignLogRepository, advanced advanced.Service, attachmentRepo repository.AttachmentRepository, + trackedLinkRepo repository.TrackedLinkRepository, automationRunner AutomationRunner, ) TasksService { return &tasksService{ @@ -148,6 +155,7 @@ func NewService( contactRepo: contactRepo, campaignLogRepo: campaignLogRepo, attachmentRepo: attachmentRepo, + trackedLinkRepo: trackedLinkRepo, automationRunner: automationRunner, warmupSettings: &warmupSettingsCache{}, } diff --git a/internal/tasks/template.go b/internal/tasks/template.go index 9f2099b6..5e158511 100644 --- a/internal/tasks/template.go +++ b/internal/tasks/template.go @@ -3,7 +3,6 @@ package tasks import ( "fmt" "math/rand" - "net/url" "regexp" "strings" "sync" @@ -13,6 +12,7 @@ import ( "github.com/warmbly/warmbly/internal/models" "github.com/warmbly/warmbly/internal/pkg/tmplfuncs" "github.com/warmbly/warmbly/internal/pkg/warmpersona" + "github.com/warmbly/warmbly/internal/repository" ) // Conversation represents a warmup conversation for AI generation @@ -272,15 +272,21 @@ func AddOpenTrackingPixel(htmlBody string, taskID uuid.UUID, trackingDomain stri return htmlBody + pixel } -// WrapLinksForTracking wraps all links in HTML for click tracking -// The tracking URL points to the Rust tracking service endpoint: /t/c/{taskID}?url={original_url} -func WrapLinksForTracking(htmlBody string, taskID uuid.UUID, trackingDomain string) string { +// WrapLinksForTracking rewrites every external link to an opaque +// click-tracking ticket (https:///c/) and returns the minted +// rows. The destination never travels inside the link, so there is nothing +// to forge: the tracking service resolves tickets via the backend internal +// API and 404s anything it does not know. The caller MUST persist the +// returned rows before using the rewritten body (and fall back to the +// original body on failure) so an email can never ship dead tickets. +func WrapLinksForTracking(htmlBody string, taskID, campaignID uuid.UUID, trackingDomain string) (string, []repository.TrackedLink) { if trackingDomain == "" { trackingDomain = "track.warmbly.com" } // Regex to find href attributes linkRegex := regexp.MustCompile(`href="([^"]+)"`) + var links []repository.TrackedLink result := linkRegex.ReplaceAllStringFunc(htmlBody, func(match string) string { // Extract the original URL @@ -300,17 +306,23 @@ func WrapLinksForTracking(htmlBody string, taskID uuid.UUID, trackingDomain stri return match } - // Use /t/c/ path to match Rust tracking service - // URL encode the original URL properly - trackingURL := fmt.Sprintf("https://%s/t/c/%s?url=%s", - trackingDomain, - taskID.String(), - url.QueryEscape(originalURL)) + // Only http(s) destinations are storable redirect targets + if !strings.HasPrefix(originalURL, "http://") && !strings.HasPrefix(originalURL, "https://") { + return match + } - return fmt.Sprintf(`href="%s"`, trackingURL) + id := uuid.New() + links = append(links, repository.TrackedLink{ + ID: id, + TaskID: taskID, + CampaignID: campaignID, + Destination: originalURL, + }) + + return fmt.Sprintf(`href="https://%s/c/%s"`, trackingDomain, id.String()) }) - return result + return result, links } // personaPick chooses from a mailbox's preferred subset of phrasing options so diff --git a/internal/utils/paging/paging.go b/internal/utils/paging/paging.go new file mode 100644 index 00000000..40b92f3f --- /dev/null +++ b/internal/utils/paging/paging.go @@ -0,0 +1,115 @@ +// Package paging encodes keyset pagination cursors as opaque tokens. +// +// The wire value a client sees is a versioned base64url token, NOT the raw +// record id that happens to be the sort key. That keeps clients from coupling +// to the id being the cursor (or to it being a UUID at all), so the keyset can +// evolve without breaking callers. Decoding a malformed or wrong-version token +// is an error, which handlers surface as a 400 rather than silently ignoring. +package paging + +import ( + "encoding/base64" + "errors" + "strconv" + "strings" + + "github.com/google/uuid" + "github.com/warmbly/warmbly/internal/errx" +) + +// offsetPrefix versions the opaque offset-cursor format. Offset-paginated +// endpoints (faceted searches that sort by nullable columns, where a keyset +// cursor would drop NULL rows) expose the SAME opaque next_cursor token as +// keyset endpoints, so every list looks identical to a client. The offset is an +// implementation detail hidden inside the token. +const offsetPrefix = "o1_" + +// EncodeOffset wraps a next-page offset in an opaque cursor token. +func EncodeOffset(offset int) *string { + tok := offsetPrefix + base64.RawURLEncoding.EncodeToString([]byte(strconv.Itoa(offset))) + return &tok +} + +// DecodeOffsetCursor decodes an opaque offset cursor back to its 0-based offset. +// An empty token yields (0, nil) (first page); an invalid token returns a 400. +func DecodeOffsetCursor(token string) (int, *errx.Error) { + if token == "" { + return 0, nil + } + if !strings.HasPrefix(token, offsetPrefix) { + return 0, errx.New(errx.BadRequest, "invalid cursor") + } + raw, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(token, offsetPrefix)) + if err != nil { + return 0, errx.New(errx.BadRequest, "invalid cursor") + } + n, err := strconv.Atoi(string(raw)) + if err != nil || n < 0 { + return 0, errx.New(errx.BadRequest, "invalid cursor") + } + return n, nil +} + +// prefix versions the token format. Bump it if the encoding ever changes so old +// tokens decode to a clear error instead of garbage. +const prefix = "c1_" + +// ErrInvalid is returned for a malformed or wrong-version cursor token. +var ErrInvalid = errors.New("invalid cursor") + +// EncodeUUID wraps a record id in an opaque token. Returns nil for the zero id +// (used to mean "no next page") so the JSON cursor field serializes as null. +func EncodeUUID(id uuid.UUID) *string { + if id == uuid.Nil { + return nil + } + tok := prefix + base64.RawURLEncoding.EncodeToString(id[:]) + return &tok +} + +// UUIDString returns the record id as its plain canonical string for the keyset +// cursor field. Used by first-party admin endpoints, which keep transparent +// UUID cursors (their request structs bind ?cursor as a uuid.UUID directly). +// Public endpoints use EncodeUUID for opaque tokens instead. +func UUIDString(id uuid.UUID) *string { + if id == uuid.Nil { + return nil + } + s := id.String() + return &s +} + +// DecodeUUID reverses EncodeUUID. An empty token yields the zero id with no +// error (no cursor supplied = start from the beginning); any non-empty token +// that is not a valid current-version cursor returns ErrInvalid. +func DecodeUUID(token string) (uuid.UUID, error) { + if token == "" { + return uuid.Nil, nil + } + if !strings.HasPrefix(token, prefix) { + return uuid.Nil, ErrInvalid + } + raw, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(token, prefix)) + if err != nil || len(raw) != 16 { + return uuid.Nil, ErrInvalid + } + var id uuid.UUID + copy(id[:], raw) + return id, nil +} + +// DecodeCursor decodes an opaque cursor token into the canonical UUID string the +// repositories key on. It is a drop-in for validate.Uuid at list endpoints: an +// empty token yields (nil, nil) (start from the beginning); an invalid token +// returns a 400 instead of being silently ignored. +func DecodeCursor(token string) (*string, *errx.Error) { + if token == "" { + return nil, nil + } + id, err := DecodeUUID(token) + if err != nil { + return nil, errx.New(errx.BadRequest, "invalid cursor") + } + s := id.String() + return &s, nil +} diff --git a/realtime/lib/realtime/application.ex b/realtime/lib/realtime/application.ex index 19e6f889..d23a1712 100644 --- a/realtime/lib/realtime/application.ex +++ b/realtime/lib/realtime/application.ex @@ -12,25 +12,33 @@ defmodule Realtime.Application do # while named processes (like PubSub.Supervisor) survive, causing conflicts. {:ok, _} = Application.ensure_all_started(:postgrex) - children = [ - # Database repository for API key lookups - Realtime.Repo, + children = + [ + # Database repository for API key lookups + Realtime.Repo, - # Redis connection pool for rate limiting and distributed state - Realtime.Redis, + # Redis connection pool for rate limiting and distributed state + Realtime.Redis, - # Phoenix PubSub for internal message broadcasting - {Phoenix.PubSub, name: Realtime.PubSub}, + # Phoenix PubSub for internal message broadcasting + {Phoenix.PubSub, name: Realtime.PubSub}, - # Phoenix Endpoint (WebSocket server) - RealtimeWeb.Endpoint, + # Per-org sequencer pool: assigns the resumable sequence + buffers + and + # broadcasts org events in order. Must start before the event bridge. + Realtime.Sequencer, - # Connection tracker - {Realtime.Connections, []}, + # Presence tracker for org-level collaboration (who's online / viewing what) + RealtimeWeb.Presence, - # Google Pub/Sub subscriber supervisor - {Realtime.CloudPubSub.Supervisor, []} - ] + # Phoenix Endpoint (WebSocket server) + RealtimeWeb.Endpoint, + + # Connection tracker + {Realtime.Connections, []}, + + # Google Pub/Sub subscriber supervisor + {Realtime.CloudPubSub.Supervisor, []} + ] ++ event_bridge_children() opts = [strategy: :one_for_one, name: Realtime.Supervisor] @@ -48,6 +56,18 @@ defmodule Realtime.Application do Supervisor.start_link(children, opts) end + # Bridge backend events over Redis whenever Google Pub/Sub is not the active + # transport (local dev and any non-GCP env). In Pub/Sub environments the + # Broadway subscriber handles fan-out, so this stays off and events are never + # delivered twice. + defp event_bridge_children do + if Application.get_env(:realtime, :pubsub_enabled, false) do + [] + else + [Realtime.Redis.EventSubscriber] + end + end + @impl true def config_change(changed, _new, removed) do RealtimeWeb.Endpoint.config_change(changed, removed) diff --git a/realtime/lib/realtime/auth.ex b/realtime/lib/realtime/auth.ex index fc4131a9..f2ad799e 100644 --- a/realtime/lib/realtime/auth.ex +++ b/realtime/lib/realtime/auth.ex @@ -2,25 +2,28 @@ defmodule Realtime.Auth do @moduledoc """ Token verification for WebSocket connections. - Supports both: + Supports: - JWT tokens issued by the Go backend - API keys prefixed with `wmbly_` + - OAuth2 access tokens prefixed with `wmat_` """ require Logger alias Realtime.ApiKey alias Realtime.ErrorReporter + alias Realtime.OAuthToken @doc """ Verifies a token (JWT or API key) and returns the user_id if valid. Detects token type by prefix: + - `wmat_` prefix = OAuth2 access token - `wmbly_` prefix = API key - Otherwise = JWT token Returns: - - {:ok, user_id, :jwt} or {:ok, user_id, :api_key} on success + - {:ok, user_id, :jwt}, {:ok, user_id, :api_key} or {:ok, user_id, :oauth} on success - {:error, reason} on failure """ def verify_token(token, opts \\ []) @@ -29,10 +32,15 @@ defmodule Realtime.Auth do def verify_token("", _opts), do: {:error, :missing_token} def verify_token(token, opts) do - if ApiKey.is_api_key?(token) do - verify_api_key(token, opts) - else - verify_jwt(token) + cond do + OAuthToken.is_oauth_token?(token) -> + verify_oauth_token(token, opts) + + ApiKey.is_api_key?(token) -> + verify_api_key(token, opts) + + true -> + verify_jwt(token) end end @@ -77,6 +85,20 @@ defmodule Realtime.Auth do end end + @doc """ + Verify an OAuth2 access token. + """ + def verify_oauth_token(token, opts \\ []) do + case OAuthToken.validate(token, opts) do + {:ok, user_id} -> + {:ok, user_id, :oauth} + + {:error, reason} -> + Logger.warning("OAuth token verification failed: #{inspect(reason)}") + {:error, reason} + end + end + @doc """ Map error reasons to Discord-style error codes. """ @@ -90,9 +112,12 @@ defmodule Realtime.Auth do def error_code(:invalid_key), do: 4004 def error_code(:key_inactive), do: 4004 def error_code(:key_expired), do: 4004 + def error_code(:token_revoked), do: 4004 def error_code(:database_error), do: 4004 def error_code(:permission_denied), do: 4010 def error_code(:ip_not_allowed), do: 4010 + def error_code(:not_a_member), do: 4010 + def error_code(:forbidden), do: 4010 def error_code(:rate_limited), do: 4007 def error_code(:limit_exceeded), do: 4009 def error_code(_), do: 4004 @@ -110,6 +135,7 @@ defmodule Realtime.Auth do def error_message(:invalid_key), do: "Invalid API key" def error_message(:key_inactive), do: "API key inactive" def error_message(:key_expired), do: "API key expired" + def error_message(:token_revoked), do: "Access token revoked" def error_message(:database_error), do: "Authentication failed" def error_message(:permission_denied), do: "Permission denied" def error_message(:ip_not_allowed), do: "IP address not allowed" @@ -129,20 +155,34 @@ defmodule Realtime.Auth do """ def check_org_membership(user_id, org_id) do query = """ - SELECT om.id, om.role, om.permissions + SELECT om.id, om.role, om.permissions, + o.presence_show_online, o.presence_show_activity FROM organization_members om + JOIN organizations o ON o.id = om.organization_id WHERE om.organization_id = $1 AND om.user_id = $2 """ - case Realtime.Repo.query(query, [org_id, user_id]) do - {:ok, %{rows: [[id, role, permissions] | _]}} -> + with {:ok, org_bin} <- dump_uuid(org_id), + {:ok, user_bin} <- dump_uuid(user_id) do + run_org_membership(query, org_bin, user_bin, org_id, user_id) + else + _ -> {:error, :not_a_member} + end + end + + defp run_org_membership(query, org_bin, user_bin, org_id, user_id) do + case Realtime.Repo.query(query, [org_bin, user_bin]) do + {:ok, %{rows: [[id, role, permissions, show_online, show_activity] | _]}} -> {:ok, %{ id: id, role: role, permissions: permissions, organization_id: org_id, - user_id: user_id + user_id: user_id, + # Org-wide presence privacy. Default to visible if somehow null. + presence_show_online: show_online != false, + presence_show_activity: show_activity != false }} {:ok, %{rows: []}} -> @@ -153,6 +193,22 @@ defmodule Realtime.Auth do end end + @doc """ + Fetch a user's display profile (name + avatar) for presence metadata. + Best-effort: returns nil fields when the user can't be loaded, so a + DB hiccup degrades presence labels rather than blocking the join. + """ + def get_user_profile(user_id) do + query = "SELECT first_name, last_name, avatar_url FROM users WHERE id = $1" + + with {:ok, uuid} <- Ecto.UUID.dump(user_id), + {:ok, %{rows: [[first, last, avatar] | _]}} <- Realtime.Repo.query(query, [uuid]) do + %{name: String.trim("#{first} #{last}"), avatar: avatar} + else + _ -> %{name: nil, avatar: nil} + end + end + @doc """ Check if a user has access to a campaign via organization membership. @@ -169,7 +225,7 @@ defmodule Realtime.Auth do WHERE c.id = $1 """ - case Realtime.Repo.query(org_query, [campaign_id]) do + case dump_and_query(org_query, [campaign_id]) do {:ok, %{rows: [[org_id] | _]}} when not is_nil(org_id) -> # Check if user is a member of the organization check_org_membership(user_id, org_id) @@ -196,7 +252,7 @@ defmodule Realtime.Auth do WHERE c.id = $1 AND c.user_id = $2 """ - case Realtime.Repo.query(query, [campaign_id, user_id]) do + case dump_and_query(query, [campaign_id, user_id]) do {:ok, %{rows: [_ | _]}} -> # Full permissions for direct owner {:ok, %{permissions: 65535}} @@ -219,7 +275,7 @@ defmodule Realtime.Auth do WHERE ea.id = $1 """ - case Realtime.Repo.query(org_query, [email_account_id]) do + case dump_and_query(org_query, [email_account_id]) do {:ok, %{rows: [[org_id] | _]}} when not is_nil(org_id) -> check_org_membership(user_id, org_id) @@ -245,7 +301,7 @@ defmodule Realtime.Auth do WHERE ea.id = $1 AND ea.user_id = $2 """ - case Realtime.Repo.query(query, [email_account_id, user_id]) do + case dump_and_query(query, [email_account_id, user_id]) do {:ok, %{rows: [_ | _]}} -> {:ok, %{permissions: 65535}} @@ -257,6 +313,29 @@ defmodule Realtime.Auth do end end + # Postgrex encodes uuid params as 16-byte binaries; accept both the raw + # binary (from a prior query's row) and the canonical string form. + defp dump_uuid(<<_::128>> = bin), do: {:ok, bin} + defp dump_uuid(value) when is_binary(value), do: Ecto.UUID.dump(value) + defp dump_uuid(_), do: :error + + # Run a query whose params are all uuids, dumping each first. An + # undumpable value behaves like an empty result, not a crash. + defp dump_and_query(query, params) do + dumped = + Enum.reduce_while(params, {:ok, []}, fn value, {:ok, acc} -> + case dump_uuid(value) do + {:ok, bin} -> {:cont, {:ok, [bin | acc]}} + _ -> {:halt, :error} + end + end) + + case dumped do + {:ok, bins} -> Realtime.Repo.query(query, Enum.reverse(bins)) + :error -> {:ok, %{rows: []}} + end + end + @doc """ Check if member has a specific permission. Permission is a bitmask value. diff --git a/realtime/lib/realtime/connections.ex b/realtime/lib/realtime/connections.ex index 216cd7c4..142f9caf 100644 --- a/realtime/lib/realtime/connections.ex +++ b/realtime/lib/realtime/connections.ex @@ -22,11 +22,20 @@ defmodule Realtime.Connections do @table :realtime_connections @ip_table :realtime_ip_connections - # Default limits (can be overridden via config) + # Default limits (can be overridden via config). The cap is PER USER (not per + # org), so 10 covers many tabs and devices for one person. Dead connections + # are reaped (process-monitor plus the periodic sweep below), so the live + # count reflects real presence rather than leftover phantoms, and a normal + # user never trips the limit with stale sockets. @default_max_per_user 10 @default_max_per_ip 50 @default_max_global 100_000 + # How often the tracker sweeps for connections whose owning process has died + # (e.g. a socket the transport closed after a missed heartbeat) and reconciles + # the counts, so a stale connection never holds a slot. + @sweep_interval 30_000 + # Redis key TTL for distributed counts (10 minutes) @redis_ttl 600 @@ -146,8 +155,11 @@ defmodule Realtime.Connections do "Connections tracker started (limits: user=#{max_per_user()}, ip=#{max_per_ip()}, global=#{max_global()})" ) - # State: %{monitor_ref => {user_id, ip}} so DOWN messages can find - # the (user, ip) pair to decrement. + schedule_sweep() + + # State: %{monitor_ref => {user_id, ip, pid}} so DOWN messages and the + # periodic sweep can find the (user, ip) pair to decrement and verify the + # owning process is still alive. {:ok, %{monitors: %{}}} end @@ -156,7 +168,7 @@ defmodule Realtime.Connections do case do_track(user_id, ip, custom_max) do :ok -> ref = Process.monitor(pid) - monitors = Map.put(state.monitors, ref, {user_id, ip}) + monitors = Map.put(state.monitors, ref, {user_id, ip, pid}) {:reply, :ok, %{state | monitors: monitors}} error -> @@ -176,16 +188,40 @@ defmodule Realtime.Connections do {nil, _} -> {:noreply, state} - {{user_id, ip}, monitors} -> + {{user_id, ip, _pid}, monitors} -> do_untrack(user_id, ip) {:noreply, %{state | monitors: monitors}} end end + # Periodic reconciliation: drop any tracked connection whose owning process is + # gone (a socket the transport closed after a missed heartbeat, or a :DOWN we + # somehow missed) so the live count always reflects real presence. + def handle_info(:sweep, state) do + {dead, alive} = + Enum.split_with(state.monitors, fn {_ref, {_uid, _ip, pid}} -> not Process.alive?(pid) end) + + Enum.each(dead, fn {ref, {uid, ip, _pid}} -> + Process.demonitor(ref, [:flush]) + do_untrack(uid, ip) + end) + + if dead != [] do + Logger.debug("Connections sweep reaped #{length(dead)} stale connection(s)") + end + + schedule_sweep() + {:noreply, %{state | monitors: Map.new(alive)}} + end + def handle_info(_msg, state) do {:noreply, state} end + defp schedule_sweep do + Process.send_after(self(), :sweep, @sweep_interval) + end + # Private functions defp do_track(user_id, ip, custom_max) do diff --git a/realtime/lib/realtime/event_broadcaster.ex b/realtime/lib/realtime/event_broadcaster.ex new file mode 100644 index 00000000..740b9c48 --- /dev/null +++ b/realtime/lib/realtime/event_broadcaster.ex @@ -0,0 +1,55 @@ +defmodule Realtime.EventBroadcaster do + @moduledoc """ + Fans a backend event out to the matching Phoenix PubSub topics: the actor's + user channel, the org channel, and any entity channels (campaign / account / + bulk). Routing is purely by event body fields, so the source transport (Google + Pub/Sub via Broadway, or Redis pub/sub in dev/non-GCP envs) does not matter. + """ + + require Logger + + @doc """ + Broadcast a decoded event map. Unknown shapes are ignored. + """ + def broadcast(event) when is_map(event) do + user_id = event["user_id"] + event_type = event["event_type"] + + if present?(user_id) do + Phoenix.PubSub.broadcast(Realtime.PubSub, "user:#{user_id}", {:pubsub_event, event}) + end + + org_id = event["org_id"] || event["organization_id"] + + if present?(org_id) do + # Route org events through the sequencer so each org's events are assigned a + # monotonic seq, buffered for replay, and broadcast IN ORDER (even when + # ingested concurrently) — the invariant resume relies on. + Realtime.Sequencer.publish(org_id, event) + end + + broadcast_to_entity_channels(event) + Logger.debug("Broadcast #{event_type}") + :ok + end + + def broadcast(_), do: :ok + + defp broadcast_to_entity_channels(event) do + if campaign_id = event["campaign_id"] do + Phoenix.PubSub.broadcast(Realtime.PubSub, "campaign:#{campaign_id}", {:pubsub_event, event}) + end + + if account_id = event["email_account_id"] do + Phoenix.PubSub.broadcast(Realtime.PubSub, "account:#{account_id}", {:pubsub_event, event}) + end + + if operation_id = event["operation_id"] do + Phoenix.PubSub.broadcast(Realtime.PubSub, "bulk:#{operation_id}", {:pubsub_event, event}) + end + + :ok + end + + defp present?(value), do: is_binary(value) and value != "" +end diff --git a/realtime/lib/realtime/event_log.ex b/realtime/lib/realtime/event_log.ex new file mode 100644 index 00000000..f7cd325b --- /dev/null +++ b/realtime/lib/realtime/event_log.ex @@ -0,0 +1,154 @@ +defmodule Realtime.EventLog do + @moduledoc """ + Per-organization event log backing resumable delivery on the org channel. + + Every org event is assigned a monotonic per-org sequence number and appended to + a capped Redis stream BEFORE it is broadcast. A client that reconnects can pass + the last sequence it saw and have the gap replayed, instead of polling REST to + resync. If the client was gone long enough that its position fell out of the + buffer, replay fails and the client must do a full resync. + + Redis-backed and fail-open: if Redis is unavailable, events are still delivered + live (just without a sequence number, so they are simply not resumable). The + sequence is GLOBAL per org — every event gets one regardless of which member + may see it — so replay re-applies the same permission + intent filter as live + delivery, and a resuming client only ever receives the events it is allowed to. + """ + + alias Realtime.Redis + + require Logger + + # Keep ~this many recent events per org, and let the keys expire if an org goes + # quiet, so Redis memory stays bounded. A client gone longer than the TTL (or + # further back than maxlen events) can't resume and does a full resync instead. + @maxlen 2_000 + @ttl_ms 3_600_000 + + # Atomic: bump the per-org sequence, append the event to the capped stream under + # that sequence as the entry id, refresh both TTLs, return the new sequence. + @append_script """ + local seq = redis.call('INCR', KEYS[1]) + redis.call('XADD', KEYS[2], 'MAXLEN', '~', tonumber(ARGV[2]), seq .. '-0', 'd', ARGV[1]) + redis.call('PEXPIRE', KEYS[1], tonumber(ARGV[3])) + redis.call('PEXPIRE', KEYS[2], tonumber(ARGV[3])) + return seq + """ + + @doc """ + Assign a sequence number to an org event and buffer it for replay, returning the + event with a `"seq"` field. On Redis failure returns the event unchanged (no + seq) so live delivery still happens — that one event just isn't resumable. + """ + def stamp(org_id, event) when is_binary(org_id) and is_map(event) do + json = Jason.encode!(event) + + case Redis.command([ + "EVAL", + @append_script, + 2, + seq_key(org_id), + buf_key(org_id), + json, + @maxlen, + @ttl_ms + ]) do + {:ok, seq} when is_integer(seq) -> Map.put(event, "seq", seq) + _ -> event + end + end + + def stamp(_org_id, event), do: event + + @doc "Current (latest) sequence for an org, or 0 if none / Redis unavailable." + def current_seq(org_id) do + case Redis.command(["GET", seq_key(org_id)]) do + {:ok, v} when is_binary(v) -> + case Integer.parse(v) do + {n, _} -> n + :error -> 0 + end + + _ -> + 0 + end + end + + @doc """ + Buffered events after `last_seq`, each decoded and re-stamped with its `"seq"`. + + * `{:ok, events}` — the gap is fully covered (possibly empty if caught up) + * `{:gap, current_seq}` — `last_seq` fell out of the buffer (evicted or the + counter reset); the caller must do a full resync, not trust a partial replay + """ + def replay(org_id, last_seq) when is_integer(last_seq) and last_seq >= 0 do + current = current_seq(org_id) + + cond do + # Counter went backwards (Redis flushed / key expired then re-created) — we + # can't prove the client didn't miss anything, so force a full resync. + current < last_seq -> + {:gap, current} + + current == last_seq -> + {:ok, []} + + true -> + case min_seq(org_id) do + # Sequence advanced but nothing is buffered (all evicted) -> gap. + nil -> {:gap, current} + # The oldest buffered event is newer than last_seq + 1 -> a hole was + # trimmed away -> gap. + min when min > last_seq + 1 -> {:gap, current} + _ -> {:ok, read_after(org_id, last_seq)} + end + end + end + + def replay(org_id, _invalid), do: {:gap, current_seq(org_id)} + + # Private -------------------------------------------------------------------- + + defp min_seq(org_id) do + case Redis.command(["XRANGE", buf_key(org_id), "-", "+", "COUNT", "1"]) do + {:ok, [[id, _fields] | _]} -> parse_seq(id) + _ -> nil + end + end + + defp read_after(org_id, last_seq) do + start = "(" <> Integer.to_string(last_seq) <> "-0" + + case Redis.command(["XRANGE", buf_key(org_id), start, "+"]) do + {:ok, entries} when is_list(entries) -> + entries + |> Enum.map(&decode_entry/1) + |> Enum.reject(&is_nil/1) + + _ -> + [] + end + end + + defp decode_entry([id, fields]) do + with json when is_binary(json) <- field_value(fields, "d"), + {:ok, event} when is_map(event) <- Jason.decode(json) do + Map.put(event, "seq", parse_seq(id)) + else + _ -> nil + end + end + + defp decode_entry(_), do: nil + + defp field_value([k, v | _], k), do: v + defp field_value([_, _ | rest], k), do: field_value(rest, k) + defp field_value(_, _), do: nil + + defp parse_seq(id) when is_binary(id) do + id |> String.split("-", parts: 2) |> List.first() |> String.to_integer() + end + + defp seq_key(org_id), do: "rt:seq:#{org_id}" + defp buf_key(org_id), do: "rt:buf:#{org_id}" +end diff --git a/realtime/lib/realtime/oauth_token.ex b/realtime/lib/realtime/oauth_token.ex new file mode 100644 index 00000000..180b6ab8 --- /dev/null +++ b/realtime/lib/realtime/oauth_token.ex @@ -0,0 +1,135 @@ +defmodule Realtime.OAuthToken do + @moduledoc """ + OAuth2 access token validation and authentication. + + Validates access tokens prefixed with `wmat_` by: + 1. Hashing with SHA-256 (same scheme as `Realtime.ApiKey`) + 2. Looking up in the `oauth_access_grants` table + 3. Checking the token is neither revoked nor expired + 4. Checking the `REALTIME_SUBSCRIBE` scope (bit 11) is granted + + Tokens carry their permissions in the `scopes` bigint, which uses the same + api_permission bitmask as API keys. + """ + + require Logger + + alias Realtime.ApiKey + alias Realtime.ErrorReporter + alias Realtime.Repo + + import Ecto.Query + + # OAuth2 access token prefix + @prefix "wmat_" + + # Permission bit for realtime subscription (bit 11 = value 2048) + @perm_realtime_subscribe 11 + + @doc """ + Check if a token is an OAuth2 access token (starts with `wmat_` prefix). + """ + def is_oauth_token?(nil), do: false + def is_oauth_token?(""), do: false + + def is_oauth_token?(token) when is_binary(token) do + String.starts_with?(token, @prefix) + end + + @doc """ + Validate an OAuth2 access token and return the user_id if valid. + + Returns: + - {:ok, user_id} if valid + - {:error, reason} if invalid + + Checks: + - Token exists + - Token is not revoked + - Token is not expired + - Token has realtime subscription scope (bit 11) + """ + def validate(token, _opts \\ []) do + with {:ok, grant} <- lookup_token(token), + :ok <- check_revoked(grant), + :ok <- check_expiration(grant), + :ok <- check_scope(grant) do + {:ok, grant.user_id} + end + end + + # Reuse the API key hashing so both token types hash identically. + defp hash_token(token), do: ApiKey.hash_key(token) + + defp lookup_token(token) do + token_hash = hash_token(token) + + query = + from(g in "oauth_access_grants", + where: g.access_token_hash == ^token_hash, + select: %{ + user_id: g.user_id, + scopes: g.scopes, + access_expires_at: g.access_expires_at, + revoked_at: g.revoked_at + } + ) + + case Repo.one(query) do + nil -> + {:error, :invalid_key} + + grant -> + {:ok, grant} + end + rescue + e -> + Logger.error("OAuth token query failed: #{inspect(e)}") + ErrorReporter.capture_exception(e) + {:error, :database_error} + end + + defp check_revoked(%{revoked_at: nil}), do: :ok + + defp check_revoked(%{revoked_at: _revoked_at}) do + {:error, :token_revoked} + end + + defp check_expiration(%{access_expires_at: nil}), do: :ok + + defp check_expiration(%{access_expires_at: expires_at}) when is_struct(expires_at, DateTime) do + if DateTime.compare(DateTime.utc_now(), expires_at) == :lt do + :ok + else + {:error, :key_expired} + end + end + + defp check_expiration(%{access_expires_at: expires_at}) + when is_struct(expires_at, NaiveDateTime) do + if NaiveDateTime.compare(NaiveDateTime.utc_now(), expires_at) == :lt do + :ok + else + {:error, :key_expired} + end + end + + defp check_expiration(_), do: :ok + + defp check_scope(%{scopes: scopes}) when is_integer(scopes) do + if has_permission?(scopes, @perm_realtime_subscribe) do + :ok + else + {:error, :permission_denied} + end + end + + defp check_scope(_) do + # No scopes, deny by default + {:error, :permission_denied} + end + + defp has_permission?(scopes, bit) do + Bitwise.band(scopes, Bitwise.bsl(1, bit)) != 0 + end +end diff --git a/realtime/lib/realtime/pubsub/subscriber.ex b/realtime/lib/realtime/pubsub/subscriber.ex index 0b0ffcaf..f4c3e03c 100644 --- a/realtime/lib/realtime/pubsub/subscriber.ex +++ b/realtime/lib/realtime/pubsub/subscriber.ex @@ -10,6 +10,7 @@ defmodule Realtime.CloudPubSub.Subscriber do require Logger alias Realtime.ErrorReporter + alias Realtime.EventBroadcaster def start_link(opts) do subscription = Keyword.fetch!(opts, :subscription) @@ -75,46 +76,5 @@ defmodule Realtime.CloudPubSub.Subscriber do messages end - defp broadcast_event(event) do - user_id = event["user_id"] - event_type = event["event_type"] - - # Broadcast to user channel - if user_id do - topic = "user:#{user_id}" - Phoenix.PubSub.broadcast(Realtime.PubSub, topic, {:pubsub_event, event}) - Logger.debug("Broadcast #{event_type} to #{topic}") - end - - org_id = event["org_id"] || event["organization_id"] - - if org_id do - topic = "org:#{org_id}" - Phoenix.PubSub.broadcast(Realtime.PubSub, topic, {:pubsub_event, event}) - Logger.debug("Broadcast #{event_type} to #{topic}") - end - - # Broadcast to entity-specific channels - broadcast_to_entity_channels(event) - end - - defp broadcast_to_entity_channels(event) do - # Campaign events - if campaign_id = event["campaign_id"] do - topic = "campaign:#{campaign_id}" - Phoenix.PubSub.broadcast(Realtime.PubSub, topic, {:pubsub_event, event}) - end - - # Account events - if account_id = event["email_account_id"] do - topic = "account:#{account_id}" - Phoenix.PubSub.broadcast(Realtime.PubSub, topic, {:pubsub_event, event}) - end - - # Bulk operation events - if operation_id = event["operation_id"] do - topic = "bulk:#{operation_id}" - Phoenix.PubSub.broadcast(Realtime.PubSub, topic, {:pubsub_event, event}) - end - end + defp broadcast_event(event), do: EventBroadcaster.broadcast(event) end diff --git a/realtime/lib/realtime/redis/event_subscriber.ex b/realtime/lib/realtime/redis/event_subscriber.ex new file mode 100644 index 00000000..e46cb3a8 --- /dev/null +++ b/realtime/lib/realtime/redis/event_subscriber.ex @@ -0,0 +1,70 @@ +defmodule Realtime.Redis.EventSubscriber do + @moduledoc """ + Subscribes to the Redis pub/sub channel the Go backend and consumer publish + realtime events on, and fans each event out to Phoenix topics via + `Realtime.EventBroadcaster`. + + This is the transport bridge for local dev and any non-GCP environment. In + Google Pub/Sub environments the Broadway subscriber does the same job and this + process is not started, so events are never delivered twice. Redix.PubSub + reconnects and re-subscribes automatically, so a Redis blip self-heals. + """ + + use GenServer + + require Logger + + alias Realtime.EventBroadcaster + + @channel "realtime:events" + + def start_link(opts), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__) + + @impl true + def init(_opts) do + redis_url = Application.get_env(:realtime, :redis_url, "redis://localhost:6379/0") + + case Redix.PubSub.start_link(redis_url) do + {:ok, conn} -> + {:ok, _ref} = Redix.PubSub.subscribe(conn, @channel, self()) + Logger.info("Realtime Redis event bridge subscribing to '#{@channel}'") + {:ok, %{conn: conn}} + + {:error, reason} -> + # Fail open: realtime is a nicety, not a hard dependency. Retry shortly. + Logger.warning("Redis event bridge connect failed: #{inspect(reason)}; retrying") + Process.send_after(self(), :retry_connect, 2_000) + {:ok, %{conn: nil}} + end + end + + @impl true + def handle_info(:retry_connect, %{conn: nil}) do + {:ok, state} = init([]) + {:noreply, state} + end + + def handle_info({:redix_pubsub, _conn, _ref, :subscribed, %{channel: channel}}, state) do + Logger.debug("Redis event bridge subscribed to #{channel}") + {:noreply, state} + end + + def handle_info({:redix_pubsub, _conn, _ref, :message, %{payload: payload}}, state) do + case Jason.decode(payload) do + {:ok, event} -> + EventBroadcaster.broadcast(event) + + {:error, reason} -> + Logger.error("Redis event decode error: #{inspect(reason)}") + end + + {:noreply, state} + end + + def handle_info({:redix_pubsub, _conn, _ref, :disconnected, _meta}, state) do + Logger.warning("Redis event bridge disconnected; Redix will reconnect") + {:noreply, state} + end + + def handle_info(_msg, state), do: {:noreply, state} +end diff --git a/realtime/lib/realtime/sequencer.ex b/realtime/lib/realtime/sequencer.ex new file mode 100644 index 00000000..d9db0f24 --- /dev/null +++ b/realtime/lib/realtime/sequencer.ex @@ -0,0 +1,59 @@ +defmodule Realtime.Sequencer do + @moduledoc """ + Serializes per-organization event sequencing + broadcast so delivery order + matches sequence order. + + Without this, two same-org events ingested concurrently (e.g. from different + Pub/Sub pipelines) could be assigned seq 5 and 6 but broadcast 6-before-5; a + client that disconnected in between would resume from 6 and silently miss 5. + + A small pool of workers, partitioned by org id, keeps strict per-org ordering + while letting different orgs run in parallel. Each worker handles one event at + a time: assign the sequence + buffer it (`Realtime.EventLog`), then broadcast to + the org PubSub topic. Publishing is async (cast), so ingest never blocks. + """ + + use Supervisor + + @pool_size 8 + + def start_link(_opts), do: Supervisor.start_link(__MODULE__, :ok, name: __MODULE__) + + @impl true + def init(:ok) do + children = + for i <- 0..(@pool_size - 1) do + Supervisor.child_spec({Realtime.Sequencer.Worker, i}, id: {Realtime.Sequencer.Worker, i}) + end + + Supervisor.init(children, strategy: :one_for_one) + end + + @doc """ + Sequence, buffer, and broadcast an org event, preserving per-org order. Async. + """ + def publish(org_id, event) when is_binary(org_id) and is_map(event) do + worker = Realtime.Sequencer.Worker.name(:erlang.phash2(org_id, @pool_size)) + GenServer.cast(worker, {:publish, org_id, event}) + end +end + +defmodule Realtime.Sequencer.Worker do + @moduledoc false + + use GenServer + + def name(index), do: :"realtime_sequencer_#{index}" + + def start_link(index), do: GenServer.start_link(__MODULE__, index, name: name(index)) + + @impl true + def init(_index), do: {:ok, %{}} + + @impl true + def handle_cast({:publish, org_id, event}, state) do + event = Realtime.EventLog.stamp(org_id, event) + Phoenix.PubSub.broadcast(Realtime.PubSub, "org:#{org_id}", {:pubsub_event, event}) + {:noreply, state} + end +end diff --git a/realtime/lib/realtime_web/channels/org_channel.ex b/realtime/lib/realtime_web/channels/org_channel.ex index b7d481b4..57c0f467 100644 --- a/realtime/lib/realtime_web/channels/org_channel.ex +++ b/realtime/lib/realtime_web/channels/org_channel.ex @@ -1,15 +1,20 @@ defmodule RealtimeWeb.OrgChannel do @moduledoc """ - Channel for organization-specific events. + Channel for organization-specific events and team presence. - Users can join their organization's channel to receive events like: - - member_joined: New member joined the organization - - member_left: Member left or was removed - - member_role_changed: Member's role/permissions changed - - settings_changed: Organization settings updated - - subscription_changed: Subscription status changed + Users join their organization's channel to receive org-scoped dashboard + events (campaign sends, inbox arrivals, audit entries, member changes, ...) + filtered by their member permissions. - Authorization is handled by checking organization membership. + Presence: every JWT member is tracked in `RealtimeWeb.Presence` with + display metadata and a live activity descriptor. Clients push + `presence:update` (rate-limited like any client event) with: + + %{"page" => "/app/unibox", "resource" => "thread:", "action" => "replying"} + + so teammates see who is online, who is viewing the same record, and who is + already replying to an email. API-key (developer) sockets receive events but + are never tracked as presences. """ use Phoenix.Channel @@ -19,9 +24,12 @@ defmodule RealtimeWeb.OrgChannel do alias Realtime.Auth alias Realtime.Connections alias Realtime.RateLimiter + alias RealtimeWeb.Presence + + @presence_actions ~w(viewing editing replying idle) @impl true - def join("org:" <> org_id, _params, socket) do + def join("org:" <> org_id, params, socket) do user_id = socket.assigns.user_id case Auth.check_org_membership(user_id, org_id) do @@ -33,24 +41,121 @@ defmodule RealtimeWeb.OrgChannel do |> assign(:org_id, org_id) |> assign(:member, member) |> assign(:permissions, Map.get(member, :permissions, 0)) + # Org-wide presence privacy, read once at join. Re-read on rejoin, so a + # settings change applies live once clients reconnect to the channel. + |> assign(:presence_show_online, Map.get(member, :presence_show_online, true)) + |> assign(:presence_show_activity, Map.get(member, :presence_show_activity, true)) + # Optional event-family intents: a client may join with + # {"intents": ["AUDIT", "CAMPAIGN"]} to receive only matching event + # types. Absent or empty means the full org stream (back-compatible). + |> assign(:intents, parse_intents(params)) + # Resume token from a reconnecting client: the last sequence it saw. + |> assign(:resume_from, parse_resume(params)) send(self(), :after_join) - {:ok, %{org_id: org_id, role: member.role}, socket} + + # The join reply doubles as a HELLO: advertise the heartbeat cadence the + # server expects (so library authors do not hardcode it) and the current + # stream sequence. Every event carries a monotonic `seq`; track the + # highest you have seen and rejoin with {"resume": {"last_seq": seq}} to + # replay what you missed across a disconnect. Heartbeats are + # client-initiated on the "phoenix" topic; send one within + # server_timeout_ms or the socket is closed. + {:ok, + %{ + org_id: org_id, + role: member.role, + heartbeat_interval_ms: 25_000, + server_timeout_ms: 60_000, + seq: Realtime.EventLog.current_seq(org_id), + resume_supported: true + }, socket} {:error, :not_a_member} -> - {:error, %{reason: "not_a_member"}} + join_error(:not_a_member) {:error, reason} -> Logger.warning("Failed to join org channel: #{inspect(reason)}") - {:error, %{reason: to_string(reason)}} + join_error(reason) end end + # Structured join rejection the client can branch on: a numeric `code` + # (Auth.error_code/1) plus a human-readable `reason` (Auth.error_message/1), + # mirroring the socket-level auth error shape. + defp join_error(reason) do + {:error, %{code: Auth.error_code(reason), reason: Auth.error_message(reason)}} + end + @impl true def handle_info(:after_join, socket) do # Subscribe to the organization's Pub/Sub topic org_id = socket.assigns.org_id Phoenix.PubSub.subscribe(Realtime.PubSub, "org:#{org_id}") + + # Track presence for human members only; developer API-key sockets are + # event consumers, not teammates. When the org has turned off "show who's + # online", we track no one — so nobody appears online for anybody — but + # still push the (empty) roster so the client's join-complete logic runs. + if Map.get(socket.assigns, :auth_type) == :jwt do + if socket.assigns[:presence_show_online] do + profile = Auth.get_user_profile(socket.assigns.user_id) + + {:ok, _} = + Presence.track(socket, socket.assigns.user_id, %{ + online_at: System.system_time(:second), + name: profile.name, + avatar: profile.avatar, + page: nil, + resource: nil, + action: nil + }) + end + + push(socket, "presence_state", Presence.list(socket)) + end + + # If the client reconnected with a resume token, replay the events it missed + # (re-applying the same permission + intent filter as live delivery), or tell + # it the buffer no longer covers its position so it should do a full resync. + maybe_replay(socket) + + {:noreply, socket} + end + + # Org-wide presence privacy changed. Re-gate THIS socket live instead of + # waiting for a reconnect: drop the current presence, then re-add it only if + # "show online" is now on (re-added with nil activity, which also enforces + # "activity off" until the next — stripped — client push). Phoenix broadcasts + # the resulting presence diff, so every teammate's UI updates immediately. + # Not forwarded to web clients (the audit event refreshes the settings UI). + @impl true + def handle_info({:pubsub_event, %{"event_type" => "PRESENCE_POLICY_UPDATED"} = event}, socket) do + show_online = event["presence_show_online"] != false + show_activity = event["presence_show_activity"] != false + + socket = + socket + |> assign(:presence_show_online, show_online) + |> assign(:presence_show_activity, show_activity) + + if Map.get(socket.assigns, :auth_type) == :jwt do + Presence.untrack(socket, socket.assigns.user_id) + + if show_online do + profile = Auth.get_user_profile(socket.assigns.user_id) + + Presence.track(socket, socket.assigns.user_id, %{ + online_at: System.system_time(:second), + name: profile.name, + avatar: profile.avatar, + page: nil, + resource: nil, + action: nil + }) + end + end + {:noreply, socket} end @@ -63,8 +168,9 @@ defmodule RealtimeWeb.OrgChannel do case RateLimiter.check(user_id, :ws_message, ws_message_limit) do {:ok, _remaining} -> - # Check if user has permission to see this event - if can_see_event?(socket, event) do + # Forward only if the member may see it AND it matches the client's + # declared intents (if any). + if can_see_event?(socket, event) and intents_allow?(socket, event) do push(socket, event["event_type"], event) end @@ -80,6 +186,21 @@ defmodule RealtimeWeb.OrgChannel do {:noreply, socket} end + # Swallow the duplicate %Broadcast{} our manual PubSub subscription delivers + # to the channel process (the fastlane copy is what reaches the client). + @impl true + def handle_info(%Phoenix.Socket.Broadcast{}, socket), do: {:noreply, socket} + + # Presence diffs arrive as channel out-events. Phoenix routes them to + # handle_out/3, so we must define it (its absence crashed the channel and + # dropped the socket). Push presence_state/presence_diff straight to the + # client — they are low-volume and not permission-sensitive. + @impl true + def handle_out(event, payload, socket) do + push(socket, event, payload) + {:noreply, socket} + end + @impl true def handle_in("ping", _payload, socket) do {:reply, {:ok, %{pong: System.system_time(:millisecond)}}, socket} @@ -116,38 +237,230 @@ defmodule RealtimeWeb.OrgChannel do # Private functions - # Check if user has permission to see a specific event type - defp can_see_event?(socket, event) do - event_type = Map.get(event, "event_type", "") - permissions = socket.assigns.permissions + # Normalize the optional intents list into upcased family tokens, or nil for + # "everything". Each token is substring-matched against the event type, so + # "CAMPAIGN" matches CAMPAIGN_* and "AUDIT" matches AUDIT_CREATED. + defp parse_intents(params) when is_map(params) do + case params["intents"] do + list when is_list(list) -> + tokens = + list + |> Enum.filter(&is_binary/1) + |> Enum.map(fn s -> s |> String.upcase() |> String.replace(~r/[.:\s-]+/, "_") end) + |> Enum.reject(&(&1 == "")) - case event_type do - # Billing events require billing permission - "subscription_changed" -> - Auth.has_permission?(%{permissions: permissions}, Auth.permission(:manage_billing)) + if tokens == [], do: nil, else: tokens - # Member events require team management permission - "member_joined" -> - Auth.has_permission?(%{permissions: permissions}, Auth.permission(:manage_team)) - - "member_left" -> - Auth.has_permission?(%{permissions: permissions}, Auth.permission(:manage_team)) - - "member_role_changed" -> - Auth.has_permission?(%{permissions: permissions}, Auth.permission(:manage_team)) - - # Settings changes require settings permission - "settings_changed" -> - Auth.has_permission?(%{permissions: permissions}, Auth.permission(:manage_settings)) - - # Default: allow all other events _ -> + nil + end + end + + defp parse_intents(_), do: nil + + # Resume support ------------------------------------------------------------- + + # A reconnecting client may join with {"resume": {"last_seq": }} to replay + # the events it missed. Returns the sequence (>= 0), :invalid for a malformed + # token, or nil for a fresh (non-resuming) join. + defp parse_resume(params) when is_map(params) do + case params["resume"] do + %{"last_seq" => v} -> normalize_seq(v) + _ -> nil + end + end + + defp parse_resume(_), do: nil + + defp normalize_seq(v) when is_integer(v) and v >= 0, do: v + + defp normalize_seq(v) when is_binary(v) do + case Integer.parse(v) do + {n, ""} when n >= 0 -> n + _ -> :invalid + end + end + + defp normalize_seq(_), do: :invalid + + # Replay the gap for a resuming client, or signal that a full resync is needed. + # Runs after the PubSub subscribe, so a live event landing during replay may be + # delivered twice (once here, once live) — resume is at-least-once, so clients + # dedupe by `seq`. The replay is bounded by the buffer size. + defp maybe_replay(socket) do + org_id = socket.assigns.org_id + + case socket.assigns[:resume_from] do + nil -> + :ok + + :invalid -> + push(socket, "resume_failed", %{ + reason: "invalid_resume", + current_seq: Realtime.EventLog.current_seq(org_id) + }) + + last_seq -> + case Realtime.EventLog.replay(org_id, last_seq) do + {:ok, events} -> + replayed = + Enum.reduce(events, 0, fn ev, n -> + if can_see_event?(socket, ev) and intents_allow?(socket, ev) do + push(socket, ev["event_type"], ev) + n + 1 + else + n + end + end) + + push(socket, "resumed", %{ + from: last_seq, + current_seq: Realtime.EventLog.current_seq(org_id), + replayed: replayed + }) + + {:gap, current} -> + push(socket, "resume_failed", %{reason: "buffer_evicted", current_seq: current}) + end + end + + :ok + end + + # When a client declared intents, forward only events whose normalized type + # contains one of the requested tokens. No intents = forward everything. + defp intents_allow?(socket, event) do + case socket.assigns[:intents] do + nil -> + true + + tokens -> + type = + event + |> Map.get("event_type", "") + |> to_string() + |> String.upcase() + |> String.replace(~r/[.:\s-]+/, "_") + + Enum.any?(tokens, fn t -> String.contains?(type, t) end) + end + end + + # Gate org-broadcast events on member permissions. Event types are + # normalized (upcased, separators collapsed to "_") so both the legacy + # lowercase names and the Go publisher's UPPER_SNAKE names match. + defp can_see_event?(socket, event) do + event_type = + event + |> Map.get("event_type", "") + |> to_string() + |> String.upcase() + |> String.replace(~r/[.:\s-]+/, "_") + + permissions = socket.assigns.permissions + has = fn perm -> Auth.has_permission?(%{permissions: permissions}, Auth.permission(perm)) end + + cond do + # Billing + String.contains?(event_type, "SUBSCRIPTION") or String.contains?(event_type, "BILLING") -> + has.(:manage_billing) + + # Team / member events + String.contains?(event_type, "MEMBER") or String.contains?(event_type, "INVITATION") -> + has.(:manage_team) + + # Org settings changes + String.contains?(event_type, "SETTINGS") -> + has.(:manage_settings) + + # Unibox rows carry subject + preview snippets + String.contains?(event_type, "INBOX") or + event_type in ["EMAIL_RECEIVED", "EMAIL_UPDATED", "EMAIL_DELETED"] -> + has.(:access_unibox) + + # Campaign activity: lifecycle, task progress, send/open/click/reply pulses + String.contains?(event_type, "CAMPAIGN") or String.contains?(event_type, "TASK_PROGRESS") or + event_type in [ + "EMAIL_SENT", + "EMAIL_OPENED", + "EMAIL_CLICKED", + "EMAIL_REPLIED", + "EMAIL_BOUNCED" + ] -> + has.(:view_campaigns) + + # Contact changes + String.contains?(event_type, "CONTACT") -> + has.(:view_contacts) + + # Mailbox account + warmup health transitions + String.contains?(event_type, "ACCOUNT") or String.contains?(event_type, "WARMUP") -> + has.(:manage_emails) + + # Developer "fire event" custom events: the org's own automation/campaign + # signals, with no per-app context on this socket. Allow to any subscriber + # on the org channel (the same join already verified org membership). An + # "intents" of "CUSTOM" filters these in: the substring match covers + # "CUSTOM_EVENT" since "CUSTOM" is a prefix of the normalized type. + event_type == "CUSTOM_EVENT" -> + true + + # Default: allow (audit refresh signals, meetings, automations, ...). + # The corresponding list endpoints enforce their own permissions; these + # events only tell the dashboard to refetch. + true -> true end end + # presence:update — merge the client's sanitized activity descriptor into its + # presence meta. Only tracked (JWT) members can update presence. Gated by the + # org privacy policy: if "show who's online" is off the member isn't tracked + # (nothing to update); if "show activity" is off we keep them online but strip + # the viewing/editing/page detail so teammates never see what they're doing. + defp handle_client_event("presence:update", payload, socket) do + if Map.get(socket.assigns, :auth_type) == :jwt and socket.assigns[:presence_show_online] do + patch = + if socket.assigns[:presence_show_activity] do + sanitize_presence(payload) + else + %{page: nil, resource: nil, action: nil, updated_at: System.system_time(:second)} + end + + Presence.update(socket, socket.assigns.user_id, fn meta -> Map.merge(meta, patch) end) + end + + {:noreply, socket} + end + defp handle_client_event(_event, _payload, socket) do # Default handler for unknown events {:noreply, socket} end + + defp sanitize_presence(payload) when is_map(payload) do + action = + case payload["action"] do + a when a in @presence_actions -> a + _ -> nil + end + + %{ + page: presence_string(payload["page"]), + resource: presence_string(payload["resource"]), + action: action, + updated_at: System.system_time(:second) + } + end + + defp sanitize_presence(_), do: %{page: nil, resource: nil, action: nil} + + defp presence_string(value) when is_binary(value) do + case String.trim(value) do + "" -> nil + trimmed -> String.slice(trimmed, 0, 160) + end + end + + defp presence_string(_), do: nil end diff --git a/realtime/lib/realtime_web/channels/user_socket.ex b/realtime/lib/realtime_web/channels/user_socket.ex index 5d863e8f..6a31c47d 100644 --- a/realtime/lib/realtime_web/channels/user_socket.ex +++ b/realtime/lib/realtime_web/channels/user_socket.ex @@ -42,21 +42,57 @@ defmodule RealtimeWeb.UserSocket do {:ok, socket} else - {:error, reason} -> - code = Auth.error_code(reason) - message = Auth.error_message(reason) - Logger.warning("Socket connection rejected (#{code}): #{message}") - :error - + # The join rate limiter returns a 3-tuple carrying the cooldown; match it + # before the generic 2-tuple clause and surface a Retry-After style hint. {:error, :rate_limited, retry_after_ms} -> - Logger.warning("Socket connection rate limited, retry after #{retry_after_ms}ms") - :error + reject(:rate_limited, retry_after_ms: retry_after_ms) + + {:error, reason} -> + reject(reason) end end def connect(_params, _socket, _connect_info) do - Logger.warning("Socket connection rejected: missing token") - :error + reject(:missing_token) + end + + # Build the structured rejection returned from connect/2. + # + # Phoenix.Socket.connect/2 runs before the WebSocket upgrade completes, so we + # cannot send a real WS close frame here (e.g. a 4007 close); the close code is + # advisory. Returning {:error, term} hands `term` to the transport's + # :error_handler (Phoenix.Transports.WebSocket), which can render it; the + # default handler still replies HTTP 403, so the 403 response carries the + # reason for transports/handlers that surface it. + defp reject(reason, extra \\ []) do + code = Auth.error_code(reason) + message = Auth.error_message(reason) + + payload = + %{code: code, reason: message} + |> maybe_put_retry_after(extra[:retry_after_ms]) + + log_rejection(code, message, payload) + + {:error, payload} + end + + defp maybe_put_retry_after(payload, nil), do: payload + + defp maybe_put_retry_after(payload, retry_after_ms) when is_integer(retry_after_ms) do + Map.put(payload, :retry_after_ms, retry_after_ms) + end + + defp maybe_put_retry_after(payload, _), do: payload + + defp log_rejection(code, message, %{retry_after_ms: retry_after_ms}) do + Logger.warning( + "Socket connection rejected (#{code}): #{message}, retry after #{retry_after_ms}ms" + ) + end + + defp log_rejection(code, message, _payload) do + Logger.warning("Socket connection rejected (#{code}): #{message}") end @impl true diff --git a/realtime/lib/realtime_web/presence.ex b/realtime/lib/realtime_web/presence.ex new file mode 100644 index 00000000..5a7c9b9b --- /dev/null +++ b/realtime/lib/realtime_web/presence.ex @@ -0,0 +1,17 @@ +defmodule RealtimeWeb.Presence do + @moduledoc """ + Phoenix.Presence for org-level collaboration. + + Tracks which members are online per `org:` topic, with display metadata + (name, avatar) and a lightweight activity descriptor (page, resource, + action) that clients update as they navigate — e.g. `resource: + "thread:", action: "replying"` powers the "Mate is replying" indicator + in the unibox and the live-collaborator stack in the automation builder. + + API-key (developer) sockets are never tracked: machines are not teammates. + """ + + use Phoenix.Presence, + otp_app: :realtime, + pubsub_server: Realtime.PubSub +end diff --git a/realtime/mix.exs b/realtime/mix.exs index 28bf5c11..d1d5e0f0 100644 --- a/realtime/mix.exs +++ b/realtime/mix.exs @@ -6,6 +6,7 @@ defmodule Realtime.MixProject do app: :realtime, version: "0.1.0", elixir: "~> 1.18", + listeners: [Phoenix.CodeReloader], start_permanent: Mix.env() == :prod, deps: deps() ] diff --git a/site/src/pages/developers.astro b/site/src/pages/developers.astro index df46542e..f0ef3ace 100644 --- a/site/src/pages/developers.astro +++ b/site/src/pages/developers.astro @@ -134,7 +134,7 @@ const faq: [string, string][] = [ ['Can a single key be scoped to one campaign or mailbox?', 'Yes. Each key carries a list of scopes and an optional resource filter, for example mailboxes:write limited to mbx_01HQX. Cross-resource calls return 403 with a scope_required error code.'], ['Do you publish OpenAPI?', - 'Yes, at docs.warmbly.com/openapi.json. The same spec generates our TypeScript and Python SDKs, so the surface in the docs is the surface every client targets.'], + 'Yes, at docs.warmbly.com/openapi.json. It is OpenAPI 3.1, so you can import it into Postman or Insomnia, generate a typed client in your language, or build straight against it. There are no official SDKs yet, so plain HTTP is the path today.'], ]; ---

- First-class SDKs for TypeScript, Python, and Go. Plain HTTP if you want to call it from anywhere else. Same request shape, same idempotency semantics, same error codes. + A published OpenAPI 3.1 spec you can generate a typed client from, and plain HTTP from anywhere else. Same request shape, same idempotency semantics, same error codes.

@@ -301,71 +301,51 @@ const faq: [string, string][] = [ aria-selected={i === 0 ? 'true' : 'false'} >{c.label} ))} - +
-
// add-mailbox.ts
-import Warmbly from "@warmbly/sdk";
-
-const wb = new Warmbly({ apiKey: process.env.WARMBLY_KEY! });
-
-const mailbox = await wb.mailboxes.create(
-  {
-    address:  "sara@acme.com",
-    provider: "google",
-    warmup:   { pool: "premium" },
-  },
-  { idempotencyKey: "sara-bootstrap-2026-05" },
+        
// list-campaigns.ts — plain fetch, no SDK required
+const res = await fetch(
+  "https://api.warmbly.com/v1/campaigns?limit=20",
+  { headers: { Authorization: `Bearer ${process.env.WARMBLY_KEY}` } },
 );
 
-console.log(mailbox.id, mailbox.oauth_url);
+const { data, pagination } = await res.json(); +console.log(data.length, pagination.next_cursor);
-
# add_mailbox.py
-from warmbly import Warmbly
+        
# list_campaigns.py — plain requests, no SDK required
+import os, requests
 
-wb = Warmbly(api_key=os.environ["WARMBLY_KEY"])
-
-mailbox = wb.mailboxes.create(
-    address="sara@acme.com",
-    provider="google",
-    warmup={"pool": "premium"},
-    idempotency_key="sara-bootstrap-2026-05",
+res = requests.get(
+    "https://api.warmbly.com/v1/campaigns",
+    headers={"Authorization": f"Bearer {os.environ['WARMBLY_KEY']}"},
+    params={"limit": 20},
 )
 
-print(mailbox.id, mailbox.oauth_url)
+body = res.json() +print(len(body["data"]), body["pagination"]["next_cursor"])
-
// main.go
-package main
+        
// main.go — net/http, no SDK required
+req, _ := http.NewRequest("GET",
+    "https://api.warmbly.com/v1/campaigns?limit=20", nil)
+req.Header.Set("Authorization", "Bearer "+os.Getenv("WARMBLY_KEY"))
 
-import "github.com/warmbly/warmbly-go"
-
-client := warmbly.New(os.Getenv("WARMBLY_KEY"))
-
-mailbox, err := client.Mailboxes.Create(ctx, &warmbly.MailboxCreate{
-    Address:  "sara@acme.com",
-    Provider: "google",
-    Warmup:   &warmbly.Warmup{Pool: "premium"},
-}, warmbly.WithIdempotencyKey("sara-bootstrap-2026-05"))
-
-if err != nil { log.Fatal(err) }
+res, err := http.DefaultClient.Do(req) +if err != nil { log.Fatal(err) } +defer res.Body.Close() // decode { data, pagination }
# terminal
-$ curl -X POST https://api.warmbly.com/v1/mailboxes \
-    -H "Authorization: Bearer $WARMBLY_KEY" \
-    -H "Idempotency-Key: sara-bootstrap-2026-05" \
-    -H "Content-Type: application/json" \
-    -d '{
-      "address":  "sara@acme.com",
-      "provider": "google",
-      "warmup":   { "pool": "premium" }
-    }'
+$ curl https://api.warmbly.com/v1/campaigns?limit=20 \ + -H "Authorization: Bearer $WARMBLY_KEY" + +# -> { "data": [ ... ], "pagination": { "next_cursor": "c1_…", "has_more": true } }
@@ -670,7 +650,7 @@ mailbox, err := client.Mailboxes.

- Full reference and SDK guides at docs.warmbly.com. + Full API reference and the OpenAPI spec at docs.warmbly.com.

diff --git a/tracking/Cargo.lock b/tracking/Cargo.lock index 01b4e47b..7e0f16c2 100644 --- a/tracking/Cargo.lock +++ b/tracking/Cargo.lock @@ -2935,7 +2935,6 @@ dependencies = [ "tower-http 0.5.2", "tracing", "tracing-subscriber", - "urlencoding", "uuid", ] diff --git a/tracking/Cargo.toml b/tracking/Cargo.toml index 647b5eb6..20be96e5 100644 --- a/tracking/Cargo.toml +++ b/tracking/Cargo.toml @@ -28,7 +28,6 @@ serde_json = "1" chrono = { version = "0.4", features = ["serde"] } sha2 = "0.10" base64 = "0.21" -urlencoding = "2" moka = { version = "0.12", features = ["future"] } # Logging diff --git a/tracking/src/abuse.rs b/tracking/src/abuse.rs new file mode 100644 index 00000000..603a06c7 --- /dev/null +++ b/tracking/src/abuse.rs @@ -0,0 +1,108 @@ +//! Anti-abuse layer for the tracking endpoints. +//! +//! Two independent controls, applied before an event reaches Kafka: +//! - per-source rate limiting (fixed 60s window, bounded cache) +//! - prefetch / scanner filtering (the response is still served so real +//! clients never break; only the analytics event is suppressed) +//! +//! Open-redirect protection lives in the ticket design itself (`links.rs`): +//! destinations never travel inside the URL, so there is nothing to forge. + +use axum::http::HeaderMap; +use moka::future::Cache; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +/// Fixed-window per-source request counter. Window resets via entry TTL, the +/// cache is hard-capped so a botnet rotating sources cannot exhaust memory. +pub struct RateLimiter { + buckets: Cache>, + limit: u32, +} + +impl RateLimiter { + pub fn new(limit_per_min: u32) -> Self { + Self { + buckets: Cache::builder() + .max_capacity(50_000) + .time_to_live(Duration::from_secs(60)) + .build(), + limit: limit_per_min, + } + } + + /// Returns true while the source is within its per-minute budget. + pub async fn allow(&self, source: &str) -> bool { + let counter = self + .buckets + .get_with(source.to_string(), async { Arc::new(AtomicU32::new(0)) }) + .await; + counter.fetch_add(1, Ordering::Relaxed) < self.limit + } +} + +/// Browser/link-warming prefetches and previews: the fetch is speculative, +/// not a human open/click, so it must not count. +pub fn is_prefetch(headers: &HeaderMap) -> bool { + for name in ["sec-purpose", "purpose", "x-purpose", "x-moz"] { + if let Some(value) = headers.get(name).and_then(|h| h.to_str().ok()) { + let value = value.to_ascii_lowercase(); + if value.contains("prefetch") + || value.contains("preview") + || value.contains("prerender") + { + return true; + } + } + } + false +} + +/// UA markers for crawlers, CLI clients, link-expanding chat apps, uptime +/// monitors, and email security gateways that follow every link in a message. +/// Gmail's image proxy is deliberately NOT listed: it is the only open signal +/// Gmail exposes, and filtering it would zero out opens for Gmail recipients. +const SCANNER_UA_MARKERS: &[&str] = &[ + "bot", + "spider", + "crawl", + "curl/", + "wget/", + "python-requests", + "python/", + "go-http-client", + "okhttp", + "java/", + "headless", + "phantomjs", + "validator", + "pingdom", + "uptime", + "statuscake", + "site24x7", + "bingpreview", + "skypeuripreview", + "whatsapp", + "telegram", + // email security gateways / link rewriters + "urldefense", + "safelinks", + "barracuda", + "mimecast", + "proofpoint", + "forcepoint", + "symantec", + "trendmicro", + "sophos", + "zscaler", +]; + +pub fn is_scanner(user_agent: Option<&str>) -> bool { + let Some(ua) = user_agent else { + // No UA at all is never a real mail client or browser. + return true; + }; + let ua = ua.to_ascii_lowercase(); + SCANNER_UA_MARKERS.iter().any(|marker| ua.contains(marker)) +} diff --git a/tracking/src/config.rs b/tracking/src/config.rs index e11566d4..97f870b3 100644 --- a/tracking/src/config.rs +++ b/tracking/src/config.rs @@ -31,6 +31,15 @@ pub struct Config { pub schema_registry_url: String, pub schema_registry_key: Option, pub schema_registry_secret: Option, + /// Backend base URL for resolving click tickets (required), e.g. + /// http://backend:8080 — the service calls + /// GET {url}/api/v1/internal/tracked-links/:id at click time. + pub backend_internal_url: String, + /// Shared bearer token for the backend internal API (required; same + /// INTERNAL_API_TOKEN the workers use). + pub internal_api_token: String, + /// Per-source request budget for both tracking endpoints (default 300/min). + pub rate_limit_per_min: u32, } impl Config { @@ -119,6 +128,23 @@ impl Config { info!("Schema Registry authentication enabled"); } + // Click-ticket resolver wiring (required): backend internal API base + // URL + the shared internal bearer token. + let backend_internal_url = + Self::get_required("BACKEND_INTERNAL_URL", "backend/internal_url", ¶ms).await?; + let internal_api_token = + Self::get_secret_optional("INTERNAL_API_TOKEN", "backend/internal_api_token", &secrets) + .await + .filter(|s| !s.is_empty()) + .ok_or_else(|| ConfigError::Missing("INTERNAL_API_TOKEN".to_string()))?; + info!("Click-ticket resolver: {}", backend_internal_url); + + let rate_limit_per_min: u32 = env::var("TRACKING_RATE_LIMIT_PER_MIN") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(300); + info!("Per-source rate limit: {}/min", rate_limit_per_min); + Ok(Self { env: env_name, host, @@ -130,6 +156,9 @@ impl Config { schema_registry_url, schema_registry_key, schema_registry_secret, + backend_internal_url, + internal_api_token, + rate_limit_per_min, }) } @@ -179,6 +208,13 @@ impl Config { info!("Schema Registry authentication enabled"); } + let backend_internal_url = params.get("backend/internal_url").await?; + let internal_api_token = secrets + .get_optional("backend/internal_api_token") + .await + .filter(|s| !s.is_empty()) + .ok_or_else(|| ConfigError::Missing("backend/internal_api_token".to_string()))?; + Ok(Self { env: env.to_string(), host, @@ -190,6 +226,9 @@ impl Config { schema_registry_url, schema_registry_key, schema_registry_secret, + backend_internal_url, + internal_api_token, + rate_limit_per_min: 300, }) } diff --git a/tracking/src/handlers.rs b/tracking/src/handlers.rs index e5f25a42..aa8ed919 100644 --- a/tracking/src/handlers.rs +++ b/tracking/src/handlers.rs @@ -1,16 +1,18 @@ use axum::{ - extract::{Path, Query, State}, + extract::{Path, State}, http::{header, HeaderMap, StatusCode}, response::{IntoResponse, Redirect, Response}, }; use chrono::Utc; use moka::future::Cache; use sha2::{Digest, Sha256}; -use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; +use crate::abuse::{is_prefetch, is_scanner, RateLimiter}; +use crate::config::Config; use crate::kafka::{KafkaProducer, TrackingEvent}; +use crate::links::{LinkResolver, Resolution}; // 1x1 transparent GIF (43 bytes) const TRANSPARENT_GIF: &[u8] = &[ @@ -29,10 +31,14 @@ pub struct AppState { /// Cache to deduplicate tracking events /// Each event type + task + IP is cached for 1 hour pub dedupe_cache: Arc, + /// Per-source request budget (anti-flood) + pub rate_limiter: Arc, + /// Click-ticket resolver (backend internal API + layered caches) + pub links: Arc, } impl AppState { - pub fn new(kafka: KafkaProducer) -> Self { + pub fn new(kafka: KafkaProducer, config: &Config) -> Self { // Create cache with: // - Max 100k entries // - TTL of 1 hour per entry @@ -46,6 +52,11 @@ impl AppState { Self { kafka, dedupe_cache: Arc::new(dedupe_cache), + rate_limiter: Arc::new(RateLimiter::new(config.rate_limit_per_min)), + links: Arc::new(LinkResolver::new( + config.backend_internal_url.clone(), + config.internal_api_token.clone(), + )), } } @@ -94,12 +105,13 @@ pub async fn track_open( return pixel_response(); } - // Extract IP hash for deduplication + // Extract IP hash for deduplication + rate limiting let ip_hash = extract_ip_hash(&headers); - // Check for duplicate (same task + IP within 1 hour) - if state.is_duplicate("OPEN", &task_id, &ip_hash).await { - // Still return pixel but don't publish event + // Anti-flood: over-budget sources still get the pixel (real mail clients + // must never see a broken image), but nothing is published. + let source = ip_hash.clone().unwrap_or_else(|| "unknown".to_string()); + if !state.rate_limiter.allow(&source).await { return pixel_response(); } @@ -109,6 +121,17 @@ pub async fn track_open( .and_then(|h| h.to_str().ok()) .map(|s| s.to_string()); + // Speculative fetches and scanners are served but never counted. + if is_prefetch(&headers) || is_scanner(user_agent.as_deref()) { + return pixel_response(); + } + + // Check for duplicate (same task + IP within 1 hour) + if state.is_duplicate("OPEN", &task_id, &ip_hash).await { + // Still return pixel but don't publish event + return pixel_response(); + } + // Publish event asynchronously (fire and forget) let kafka = state.kafka.clone(); tokio::spawn(async move { @@ -128,71 +151,65 @@ pub async fn track_open( } /// Click tracking redirect handler -/// GET /t/c/{task_id}?url={original_url} +/// GET /c/{link_id} +/// +/// The email carries only this opaque ticket; the destination lives +/// server-side, so there is nothing to forge and no open-redirect surface. +/// Unknown tickets 404. pub async fn track_click( State(state): State, - Path(task_id): Path, - Query(params): Query>, + Path(link_id): Path, headers: HeaderMap, ) -> Response { - // Get original URL from query params first (we need to redirect regardless) - let original_url = match params.get("url") { - Some(url) => { - // Decode URL - urlencoding::decode(url) - .map(|s| s.into_owned()) - .unwrap_or_else(|_| url.clone()) - } - None => { - return (StatusCode::BAD_REQUEST, "Missing url parameter").into_response(); - } - }; - - // Basic URL validation - if !original_url.starts_with("http://") && !original_url.starts_with("https://") { - return (StatusCode::BAD_REQUEST, "Invalid URL").into_response(); + // Garbage dies before any lookup or counter work + if uuid::Uuid::parse_str(&link_id).is_err() { + return (StatusCode::NOT_FOUND, "Unknown link").into_response(); } - // Validate task_id is a valid UUID format - if uuid::Uuid::parse_str(&task_id).is_err() { - // Still redirect but don't track - return Redirect::temporary(&original_url).into_response(); - } - - // Extract IP hash for deduplication + // Anti-flood: cap total request rate per source let ip_hash = extract_ip_hash(&headers); - - // Create a unique key for this specific link click (task + URL + IP) - let url_hash = { - let mut hasher = Sha256::new(); - hasher.update(original_url.as_bytes()); - let result = hasher.finalize(); - format!("{:x}", result)[..8].to_string() - }; - - let dedupe_key = format!("{}:{}", task_id, url_hash); - - // Check for duplicate (same task + URL + IP within 1 hour) - if state.is_duplicate("CLICK", &dedupe_key, &ip_hash).await { - // Still redirect but don't publish event - return Redirect::temporary(&original_url).into_response(); + let source = ip_hash.clone().unwrap_or_else(|| "unknown".to_string()); + if !state.rate_limiter.allow(&source).await { + return (StatusCode::TOO_MANY_REQUESTS, "Slow down").into_response(); } + let link = match state.links.resolve(&link_id, &source).await { + Resolution::Found(link) => link, + Resolution::NotFound => { + return (StatusCode::NOT_FOUND, "Unknown link").into_response(); + } + Resolution::Unavailable => { + // Fail closed: never redirect a ticket we could not verify. + return (StatusCode::SERVICE_UNAVAILABLE, "Try again shortly").into_response(); + } + }; + // Extract metadata from request let user_agent = headers .get(header::USER_AGENT) .and_then(|h| h.to_str().ok()) .map(|s| s.to_string()); + // Security gateways and link previewers follow every URL in a message; + // serve them the destination but never count a click. + if is_prefetch(&headers) || is_scanner(user_agent.as_deref()) { + return Redirect::temporary(&link.destination).into_response(); + } + + // Dedupe repeat clicks of the same ticket from the same source + if state.is_duplicate("CLICK", &link_id, &ip_hash).await { + return Redirect::temporary(&link.destination).into_response(); + } + // Publish event asynchronously (fire and forget) let kafka = state.kafka.clone(); - let original_url_clone = original_url.clone(); + let destination = link.destination.clone(); tokio::spawn(async move { kafka .publish(TrackingEvent { event_type: "EMAIL_CLICKED".to_string(), - task_id, - original_url: Some(original_url_clone), + task_id: link.task_id, + original_url: Some(destination), timestamp: Utc::now().to_rfc3339(), user_agent, ip_hash, @@ -200,8 +217,7 @@ pub async fn track_click( .await; }); - // Redirect to original URL - Redirect::temporary(&original_url).into_response() + Redirect::temporary(&link.destination).into_response() } /// Return the transparent pixel response diff --git a/tracking/src/links.rs b/tracking/src/links.rs new file mode 100644 index 00000000..39734e79 --- /dev/null +++ b/tracking/src/links.rs @@ -0,0 +1,194 @@ +//! Click-ticket resolver. +//! +//! Emails carry only an opaque link id (`/c/`); this module resolves it +//! to the stored destination via the backend internal API. The layered +//! defenses keep a ticket-spray attack away from the backend: +//! +//! 1. positive cache: resolved tickets are served from memory +//! 2. negative cache: recently-confirmed-unknown ids are 404d from memory +//! 3. per-source miss budget: real clickers essentially never miss, so a +//! source accumulating misses is probing and gets cut off without lookups +//! 4. circuit breaker: when the backend errors/times out, lookups stop for a +//! cooldown and misses fail closed instead of piling on + +use moka::future::Cache; +use serde::Deserialize; +use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tracing::warn; + +#[derive(Clone, Debug)] +pub struct ResolvedLink { + pub destination: String, + pub task_id: String, +} + +#[derive(Deserialize)] +struct LinkResponse { + destination: String, + task_id: String, +} + +pub enum Resolution { + /// Ticket is known; redirect + count. + Found(ResolvedLink), + /// Ticket is confirmed unknown (or this source exhausted its miss budget). + NotFound, + /// Backend unavailable / breaker open; fail closed without counting a miss. + Unavailable, +} + +/// Consecutive backend failures before the breaker opens. +const BREAKER_TRIP: u32 = 5; +/// How long the breaker stays open once tripped. +const BREAKER_COOLDOWN: Duration = Duration::from_secs(15); +/// Unknown-ticket lookups allowed per source per minute. Legitimate clicks +/// resolve, so anything past a handful of misses is a probe. +const MISS_BUDGET_PER_MIN: u32 = 12; + +pub struct LinkResolver { + http: reqwest::Client, + backend_url: String, + internal_token: String, + found: Cache, + not_found: Cache, + miss_budget: Cache>, + breaker_failures: AtomicU32, + breaker_open_until_ms: AtomicU64, + started: Instant, +} + +impl LinkResolver { + pub fn new(backend_url: String, internal_token: String) -> Self { + Self { + http: reqwest::Client::builder() + .timeout(Duration::from_secs(3)) + .build() + .expect("reqwest client"), + backend_url: backend_url.trim_end_matches('/').to_string(), + internal_token, + // Tickets are immutable once minted; a long TTL is safe and keeps + // repeat clicks (forwarded emails, retries) off the backend. + found: Cache::builder() + .max_capacity(200_000) + .time_to_live(Duration::from_secs(24 * 3600)) + .build(), + // Short negative TTL: protects against repeat probes of one id + // without permanently 404ing a ticket minted milliseconds later. + not_found: Cache::builder() + .max_capacity(100_000) + .time_to_live(Duration::from_secs(60)) + .build(), + miss_budget: Cache::builder() + .max_capacity(50_000) + .time_to_live(Duration::from_secs(60)) + .build(), + breaker_failures: AtomicU32::new(0), + breaker_open_until_ms: AtomicU64::new(0), + started: Instant::now(), + } + } + + pub async fn resolve(&self, link_id: &str, source: &str) -> Resolution { + if let Some(link) = self.found.get(link_id).await { + return Resolution::Found(link); + } + + if self.not_found.contains_key(link_id) { + self.count_miss(source).await; + return Resolution::NotFound; + } + + // Probing sources get cut off before any backend traffic. + if !self.miss_allowed(source).await { + return Resolution::NotFound; + } + + if self.breaker_is_open() { + return Resolution::Unavailable; + } + + let url = format!( + "{}/api/v1/internal/tracked-links/{}", + self.backend_url, link_id + ); + let response = self + .http + .get(&url) + .bearer_auth(&self.internal_token) + .send() + .await; + + match response { + Ok(resp) if resp.status().is_success() => match resp.json::().await { + Ok(body) => { + self.breaker_failures.store(0, Ordering::Relaxed); + let link = ResolvedLink { + destination: body.destination, + task_id: body.task_id, + }; + self.found.insert(link_id.to_string(), link.clone()).await; + Resolution::Found(link) + } + Err(e) => { + warn!("tracked-link decode failed: {}", e); + self.record_failure(); + Resolution::Unavailable + } + }, + Ok(resp) if resp.status() == reqwest::StatusCode::NOT_FOUND => { + self.breaker_failures.store(0, Ordering::Relaxed); + self.not_found.insert(link_id.to_string(), ()).await; + self.count_miss(source).await; + Resolution::NotFound + } + Ok(resp) => { + warn!("tracked-link lookup unexpected status: {}", resp.status()); + self.record_failure(); + Resolution::Unavailable + } + Err(e) => { + warn!("tracked-link lookup failed: {}", e); + self.record_failure(); + Resolution::Unavailable + } + } + } + + async fn miss_allowed(&self, source: &str) -> bool { + let counter = self + .miss_budget + .get_with(source.to_string(), async { Arc::new(AtomicU32::new(0)) }) + .await; + counter.load(Ordering::Relaxed) < MISS_BUDGET_PER_MIN + } + + async fn count_miss(&self, source: &str) { + let counter = self + .miss_budget + .get_with(source.to_string(), async { Arc::new(AtomicU32::new(0)) }) + .await; + counter.fetch_add(1, Ordering::Relaxed); + } + + fn now_ms(&self) -> u64 { + self.started.elapsed().as_millis() as u64 + } + + fn breaker_is_open(&self) -> bool { + self.now_ms() < self.breaker_open_until_ms.load(Ordering::Relaxed) + } + + fn record_failure(&self) { + let failures = self.breaker_failures.fetch_add(1, Ordering::Relaxed) + 1; + if failures >= BREAKER_TRIP { + self.breaker_open_until_ms.store( + self.now_ms() + BREAKER_COOLDOWN.as_millis() as u64, + Ordering::Relaxed, + ); + self.breaker_failures.store(0, Ordering::Relaxed); + warn!("tracked-link breaker open for {:?}", BREAKER_COOLDOWN); + } + } +} diff --git a/tracking/src/main.rs b/tracking/src/main.rs index fbfb8a05..7545e877 100644 --- a/tracking/src/main.rs +++ b/tracking/src/main.rs @@ -1,7 +1,9 @@ +mod abuse; mod aws; mod config; mod handlers; mod kafka; +mod links; mod observability; use axum::{routing::get, Router}; @@ -49,13 +51,13 @@ async fn main() { } }; - let state = AppState::new(kafka); + let state = AppState::new(kafka, &config); // Build router let app = Router::new() .route("/health", get(health)) .route("/t/o/:task_id", get(track_open)) - .route("/t/c/:task_id", get(track_click)) + .route("/c/:link_id", get(track_click)) .layer( CorsLayer::new() .allow_origin(Any) diff --git a/web/src/app/app/admin/audit/page.tsx b/web/src/app/app/admin/audit/page.tsx index 9df2392e..905bf057 100644 --- a/web/src/app/app/admin/audit/page.tsx +++ b/web/src/app/app/admin/audit/page.tsx @@ -3,6 +3,7 @@ import { useQuery } from "@tanstack/react-query"; import { searchAdminAuditLogs } from "@/lib/api/client/app/admin/audit"; import type { AdminAuditLog, AdminAuditLogSearch } from "@/lib/api/models/app/admin/Audit"; import { SelectMenu, type SelectOption } from "@/components/ui/select-menu"; +import { DateTimePicker } from "@/components/ui/DateTimePicker"; const LIMIT_OPTIONS: SelectOption[] = [25, 50, 100].map((n) => ({ value: String(n), @@ -176,19 +177,15 @@ export default function AdminAuditPage() { /> - applyFilter({ start_date: e.target.value || undefined })} - className={inp} + onChange={(v) => applyFilter({ start_date: v || undefined })} /> - applyFilter({ end_date: e.target.value || undefined })} - className={inp} + onChange={(v) => applyFilter({ end_date: v || undefined })} /> diff --git a/web/src/app/app/admin/layout.tsx b/web/src/app/app/admin/layout.tsx index 7476ada0..059d64e0 100644 --- a/web/src/app/app/admin/layout.tsx +++ b/web/src/app/app/admin/layout.tsx @@ -12,9 +12,6 @@ export default function AdminLayout({ children }: { children: React.ReactNode }) "Workers": "/workers", "Credentials": "/credentials", "Audit": "/audit", - "Roles": "/roles", - "Users": "/users", - "Plans": "/plans", } return ( diff --git a/web/src/app/app/analytics/page.tsx b/web/src/app/app/analytics/page.tsx index 4c90fd63..53a18b8b 100644 --- a/web/src/app/app/analytics/page.tsx +++ b/web/src/app/app/analytics/page.tsx @@ -1,3 +1,5 @@ +import { NoAccess } from "@/components/layout/NoAccess"; +import { usePermission } from "@/hooks/usePermission"; import { useMemo, useState } from "react"; import { Link } from "react-router-dom"; import { @@ -49,6 +51,7 @@ function num(v: number | undefined): string { } export default function AnalyticsPage() { + const canView = usePermission("VIEW_ANALYTICS"); const [range, setRange] = useState("7d"); const [metric, setMetric] = useState("sent"); const dash = useDashboard(range); @@ -62,7 +65,7 @@ export default function AnalyticsPage() { const breakdown = [ { label: "Sent", value: os?.total_emails_sent, icon: SendIcon, dot: "bg-slate-400" }, - { label: "Opens", value: os?.total_opens, icon: MailCheckIcon, dot: "bg-emerald-500" }, + { label: "Opens", value: os?.total_opens, icon: MailCheckIcon, dot: "bg-emerald-500", note: os?.machine_opens ? `${num(os.machine_opens)} auto` : undefined }, { label: "Clicks", value: os?.total_clicks, icon: MousePointerClickIcon, dot: "bg-violet-500" }, { label: "Replies", value: os?.total_replies, icon: ReplyIcon, dot: "bg-amber-500" }, { label: "Bounces", value: os?.total_bounces, icon: TriangleAlertIcon, dot: "bg-rose-500" }, @@ -80,6 +83,8 @@ export default function AnalyticsPage() { daily: (d?.daily_trend ?? []).map((p) => ({ label: p.date, value: p.sent })), }; + if (!canView) return ; + return ( @@ -103,14 +108,14 @@ export default function AnalyticsPage() {
-
+
{METRICS.map((m) => ( - )} - - ); + return ; } void AlertCircleIcon; diff --git a/web/src/app/app/campaigns/[id]/layout.tsx b/web/src/app/app/campaigns/[id]/layout.tsx index cfa82b88..a7035e10 100644 --- a/web/src/app/app/campaigns/[id]/layout.tsx +++ b/web/src/app/app/campaigns/[id]/layout.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import PermissionButton from "@/components/ui/PermissionButton"; import { Link, Outlet, useLocation, useParams } from "react-router-dom"; import { motion } from "framer-motion"; import { @@ -17,11 +18,13 @@ import useStopCampaign from "@/lib/api/hooks/app/campaigns/useStopCampaign"; import { CampaignContext } from "@/hooks/context/campaign"; import { useConfirm } from "@/hooks/context/confirm"; import LaunchCampaignDialog from "@/components/app/campaigns/LaunchCampaignDialog"; +import ResourceViewers from "@/components/app/presence/ResourceViewers"; +import { usePresenceResource } from "@/hooks/PresenceProvider"; const TABS = [ { label: "Overview", path: "", Icon: BarChart3Icon }, { label: "Leads", path: "/leads", Icon: UsersIcon }, - { label: "Steps", path: "/sequences", Icon: ListChecksIcon }, + { label: "Steps", path: "/steps", Icon: ListChecksIcon }, { label: "Schedule", path: "/schedule", Icon: CalendarIcon }, { label: "Settings", path: "/preferences", Icon: Settings2Icon }, ] as const; @@ -42,6 +45,10 @@ export default function CampaignLayout() { const stopCampaign = useStopCampaign(); const [launchOpen, setLaunchOpen] = useState(false); + // Collaboration: claim this campaign while it's open so teammates see + // who's already in here (header pill + the org-wide presence stack). + usePresenceResource(id ? `campaign:${id}` : null); + if (campaignData.isLoading) { return (
@@ -106,13 +113,15 @@ export default function CampaignLayout() { > {status} +

{campaign.id}

{canToggle && (
- +
)}
diff --git a/web/src/app/app/campaigns/[id]/page.tsx b/web/src/app/app/campaigns/[id]/page.tsx index 2c06efe6..13a98cfe 100644 --- a/web/src/app/app/campaigns/[id]/page.tsx +++ b/web/src/app/app/campaigns/[id]/page.tsx @@ -43,7 +43,7 @@ export default function CampaignOverview() { const [metric, setMetric] = useState("sent"); const summary = analytics.data?.summary; - const sequences = analytics.data?.sequences ?? []; + const sequences = analytics.data?.steps ?? []; const dailyStats = daily.data ?? []; const series: ChartPoint[] = useMemo( @@ -81,7 +81,7 @@ export default function CampaignOverview() { const breakdown = [ { label: "Sent", value: summary?.emails_sent, icon: SendIcon, dot: "bg-slate-400" }, - { label: "Opens", value: summary?.unique_opens, icon: MailCheckIcon, dot: "bg-emerald-500" }, + { label: "Opens", value: summary?.unique_opens, icon: MailCheckIcon, dot: "bg-emerald-500", note: summary?.machine_opens ? `${summary.machine_opens} auto` : undefined }, { label: "Clicks", value: summary?.unique_clicks, icon: MousePointerClickIcon, dot: "bg-violet-500" }, { label: "Replies", value: summary?.replies, icon: ReplyIcon, dot: "bg-amber-500" }, { label: "Bounces", value: summary?.bounces, icon: TriangleAlertIcon, dot: "bg-rose-500" }, @@ -214,7 +214,7 @@ export default function CampaignOverview() { Bounces
{sequences.map((s) => ( -
+
{s.position} @@ -250,6 +250,14 @@ export default function CampaignOverview() {
{q.label} + {q.note && ( + + {q.note} + + )} {loading ? "—" : } @@ -270,6 +278,14 @@ export default function CampaignOverview() {
{q.label} + {q.note && ( + + {q.note} + + )} {loading ? "—" : } diff --git a/web/src/app/app/campaigns/[id]/preferences/page.tsx b/web/src/app/app/campaigns/[id]/preferences/page.tsx index 7b7628cd..b41be708 100644 --- a/web/src/app/app/campaigns/[id]/preferences/page.tsx +++ b/web/src/app/app/campaigns/[id]/preferences/page.tsx @@ -1,3 +1,4 @@ +import PermissionButton from "@/components/ui/PermissionButton"; import React from "react"; import { AnimatePresence, motion } from "framer-motion"; import { Loading } from "@/components/loader"; @@ -416,13 +417,14 @@ export default function CampaignPreferences() { > Reset - + )} diff --git a/web/src/app/app/campaigns/[id]/schedule/page.tsx b/web/src/app/app/campaigns/[id]/schedule/page.tsx index 3d342fbd..7e3b22d3 100644 --- a/web/src/app/app/campaigns/[id]/schedule/page.tsx +++ b/web/src/app/app/campaigns/[id]/schedule/page.tsx @@ -1,3 +1,4 @@ +import PermissionButton from "@/components/ui/PermissionButton"; import React from "react"; import { ArrowRightIcon, CalendarClockIcon, CalendarRangeIcon, GlobeIcon } from "lucide-react"; import { differenceInCalendarDays, format } from "date-fns"; @@ -276,12 +277,13 @@ export default function CampaignSchedule() { > Reset - +
); diff --git a/web/src/app/app/campaigns/[id]/sequences/page.tsx b/web/src/app/app/campaigns/[id]/steps/page.tsx similarity index 84% rename from web/src/app/app/campaigns/[id]/sequences/page.tsx rename to web/src/app/app/campaigns/[id]/steps/page.tsx index 5cff5222..6cb69edf 100644 --- a/web/src/app/app/campaigns/[id]/sequences/page.tsx +++ b/web/src/app/app/campaigns/[id]/steps/page.tsx @@ -3,25 +3,26 @@ import { LayersIcon, Loader2Icon, PlusIcon } from "lucide-react"; import toast from "react-hot-toast"; import { useCampaign } from "@/hooks/context/campaign"; import CampaignFlow from "@/components/app/campaigns/sequences/CampaignFlow"; +import PermissionButton from "@/components/ui/PermissionButton"; import useSequences from "@/lib/api/hooks/app/campaigns/sequences/useSequences"; import useCreateSequence from "@/lib/api/hooks/app/campaigns/sequences/useCreateSequence"; import type { AppError } from "@/lib/api/client/normalizeError"; import buildError from "@/lib/helper/buildError"; -export default function CampaignSequences() { +export default function CampaignSteps() { const campaign = useCampaign(); if (!campaign) { - throw new Error("CampaignSequences cannot be rendered without a campaign"); + throw new Error("CampaignSteps cannot be rendered without a campaign"); } return ( - }> - + }> + ); } -function SequencesBuilder({ campaignId }: { campaignId: string }) { +function StepsBuilder({ campaignId }: { campaignId: string }) { const { data: sequences } = useSequences(campaignId); const createSequence = useCreateSequence(campaignId); const [creating, setCreating] = React.useState(false); @@ -52,7 +53,8 @@ function SequencesBuilder({ campaignId }: { campaignId: string }) { replies. The first email sends immediately; later steps wait and thread as follow-ups.

- +
); } @@ -72,6 +74,6 @@ function SequencesBuilder({ campaignId }: { campaignId: string }) { return ; } -function SequencesSkeleton() { +function StepsSkeleton() { return
; } diff --git a/web/src/app/app/campaigns/page.tsx b/web/src/app/app/campaigns/page.tsx index 14544f50..220a1694 100644 --- a/web/src/app/app/campaigns/page.tsx +++ b/web/src/app/app/campaigns/page.tsx @@ -1,3 +1,5 @@ +import { NoAccess } from "@/components/layout/NoAccess"; +import { usePermission } from "@/hooks/usePermission"; import { useUserProfile } from "@/hooks/context/user"; import useCampaigns from "@/lib/api/hooks/app/campaigns/useCampaigns"; import useStartCampaign from "@/lib/api/hooks/app/campaigns/useStartCampaign"; @@ -252,6 +254,7 @@ function CampaignFolderMenu({ campaign, folders }: { campaign: Campaign; folders export default function CampaignsPage() { const p = useUserProfile(); const confirm = useConfirm(); + const canView = usePermission("VIEW_CAMPAIGNS"); const startCampaign = useStartCampaign(); const stopCampaign = useStopCampaign(); const [folder, setFolder] = useState(""); @@ -310,6 +313,8 @@ export default function CampaignsPage() { return stats; }, [campaigns]); + if (!canView) return ; + return ( ) + const canView = usePermission("VIEW_CONTACTS"); + if (!canView) return ; + return ; } diff --git a/web/src/app/app/crm/deals/page.tsx b/web/src/app/app/crm/deals/page.tsx index 8c88d2f6..cc6a3355 100644 --- a/web/src/app/app/crm/deals/page.tsx +++ b/web/src/app/app/crm/deals/page.tsx @@ -33,6 +33,7 @@ import { TopbarAction, } from "@/components/layout/Page"; import { Label, TextInput } from "@/components/ui/field"; +import { DatePicker } from "@/components/ui/DatePicker"; import { PopoverMenu, PopoverMenuTrigger, @@ -744,7 +745,7 @@ function DealDialog({
- +
diff --git a/web/src/app/app/crm/tasks/page.tsx b/web/src/app/app/crm/tasks/page.tsx index 96145555..fad55bdb 100644 --- a/web/src/app/app/crm/tasks/page.tsx +++ b/web/src/app/app/crm/tasks/page.tsx @@ -52,6 +52,7 @@ import { TopbarAction, } from "@/components/layout/Page"; import { Label, SearchInput, TextInput } from "@/components/ui/field"; +import { DatePicker } from "@/components/ui/DatePicker"; import DueInDays from "@/components/app/crm/DueInDays"; import { dueInDaysToISO, isoToDueInDays } from "@/lib/helper/dueDate"; import { @@ -1170,18 +1171,18 @@ function FilterPopover({
- setDate("due_after", v)} - type="date" - className="w-full" + placeholder="From" + className="flex-1" /> - setDate("due_before", v)} - type="date" - className="w-full" + placeholder="To" + className="flex-1" />
diff --git a/web/src/app/app/deliverability/page.tsx b/web/src/app/app/deliverability/page.tsx index cb79427f..6ca8ff3e 100644 --- a/web/src/app/app/deliverability/page.tsx +++ b/web/src/app/app/deliverability/page.tsx @@ -170,7 +170,7 @@ export default function DeliverabilityPage() {
- + All mailboxes diff --git a/web/src/app/app/emails/page.tsx b/web/src/app/app/emails/page.tsx index 84cc77b6..c4d0b340 100644 --- a/web/src/app/app/emails/page.tsx +++ b/web/src/app/app/emails/page.tsx @@ -3,6 +3,8 @@ import React, { useEffect, useMemo, useRef } from "react"; import toast from "react-hot-toast"; import { useQueryClient } from "@tanstack/react-query"; import useEmails from "@/lib/api/hooks/app/emails/useEmails"; +import { NoAccess } from "@/components/layout/NoAccess"; +import { usePermission } from "@/hooks/usePermission"; import useWarmupLifecycle from "@/lib/api/hooks/app/emails/useWarmupLifecycle"; import useAccountStatuses from "@/lib/api/hooks/app/analytics/useAccountStatuses"; import useFeatureStatus from "@/lib/api/hooks/app/subscription/useFeatureStatus"; @@ -72,6 +74,7 @@ function healthTone(status?: AccountStatus): { dot: string; text: string; label: export default function AddressesPage() { const p = useUserProfile(); const confirm = useConfirm(); + const canView = usePermission("MANAGE_EMAILS"); const [query, setQuery] = React.useState(""); const [tag, setTag] = React.useState(""); @@ -181,6 +184,10 @@ export default function AddressesPage() { : false; } + if (!canView) { + return ; + } + return ( = { - hubspot: { bg: "bg-orange-50", ring: "ring-orange-200", text: "text-orange-600" }, - salesforce: { bg: "bg-sky-50", ring: "ring-sky-200", text: "text-sky-600" }, - pipedrive: { bg: "bg-slate-100", ring: "ring-slate-300", text: "text-slate-700" }, - close: { bg: "bg-indigo-50", ring: "ring-indigo-200", text: "text-indigo-600" }, - zapier: { bg: "bg-orange-50", ring: "ring-orange-200", text: "text-orange-600" }, - make: { bg: "bg-violet-50", ring: "ring-violet-200", text: "text-violet-600" }, - n8n: { bg: "bg-rose-50", ring: "ring-rose-200", text: "text-rose-600" }, - slack: { bg: "bg-fuchsia-50", ring: "ring-fuchsia-200", text: "text-fuchsia-600" }, - discord: { bg: "bg-indigo-50", ring: "ring-indigo-200", text: "text-indigo-600" }, - calendly: { bg: "bg-sky-50", ring: "ring-sky-200", text: "text-sky-600" }, - cal_com: { bg: "bg-slate-100", ring: "ring-slate-300", text: "text-slate-800" }, +import { RAW_BRAND_LOGOS } from "./brandLogos"; + +const BRAND_ICON: Record = { + discord: { + hex: "#5865F2", + path: "M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z", + }, + hubspot: { + hex: "#FF7A59", + path: "M18.164 7.93V5.084a2.198 2.198 0 001.267-1.978v-.067A2.2 2.2 0 0017.238.845h-.067a2.2 2.2 0 00-2.193 2.193v.067a2.196 2.196 0 001.252 1.973l.013.006v2.852a6.22 6.22 0 00-2.969 1.31l.012-.01-7.828-6.095A2.497 2.497 0 104.3 4.656l-.012.006 7.697 5.991a6.176 6.176 0 00-1.038 3.446c0 1.343.425 2.588 1.147 3.607l-.013-.02-2.342 2.343a1.968 1.968 0 00-.58-.095h-.002a2.033 2.033 0 102.033 2.033 1.978 1.978 0 00-.1-.595l.005.014 2.317-2.317a6.247 6.247 0 104.782-11.134l-.036-.005zm-.964 9.378a3.206 3.206 0 113.215-3.207v.002a3.206 3.206 0 01-3.207 3.207z", + }, + salesforce: { + hex: "#00A1E0", + path: "M10.006 5.415a4.195 4.195 0 013.045-1.306c1.56 0 2.954.9 3.69 2.205.63-.3 1.35-.45 2.1-.45 2.85 0 5.159 2.34 5.159 5.22s-2.31 5.22-5.176 5.22c-.345 0-.69-.044-1.02-.104a3.75 3.75 0 01-3.3 1.95c-.6 0-1.155-.15-1.65-.375A4.314 4.314 0 018.88 20.4a4.302 4.302 0 01-4.05-2.82c-.27.062-.54.076-.825.076-2.204 0-4.005-1.8-4.005-4.05 0-1.5.811-2.805 2.01-3.51-.255-.57-.39-1.2-.39-1.846 0-2.58 2.1-4.65 4.65-4.65 1.53 0 2.85.705 3.72 1.8", + }, + zapier: { + hex: "#FF4F00", + path: "M4.157 0A4.151 4.151 0 0 0 0 4.161v15.678A4.151 4.151 0 0 0 4.157 24h15.682A4.152 4.152 0 0 0 24 19.839V4.161A4.152 4.152 0 0 0 19.839 0H4.157Zm10.61 8.761h.03a.577.577 0 0 1 .23.038.585.585 0 0 1 .201.124.63.63 0 0 1 .162.431.612.612 0 0 1-.162.435.58.58 0 0 1-.201.128.58.58 0 0 1-.23.042.529.529 0 0 1-.235-.042.585.585 0 0 1-.332-.328.559.559 0 0 1-.038-.235.613.613 0 0 1 .17-.431.59.59 0 0 1 .405-.162Zm2.853 1.572c.03.004.061.004.095.004.325-.011.646.064.937.219.238.144.431.355.552.609.128.279.189.582.185.888v.193a2 2 0 0 1 0 .219h-2.498c.003.227.075.45.204.642a.78.78 0 0 0 .646.265.714.714 0 0 0 .484-.136.642.642 0 0 0 .23-.318l.915.257a1.398 1.398 0 0 1-.28.537c-.14.159-.321.284-.521.355a2.234 2.234 0 0 1-.836.136 1.923 1.923 0 0 1-1.001-.245 1.618 1.618 0 0 1-.665-.703 2.221 2.221 0 0 1-.227-1.036 1.95 1.95 0 0 1 .48-1.398 1.9 1.9 0 0 1 1.3-.488Zm-9.607.023c.162.004.325.026.48.079.207.065.4.174.563.314.26.302.393.692.366 1.088v2.276H8.53l-.109-.711h-.065c-.064.163-.155.31-.272.439a1.122 1.122 0 0 1-.374.264 1.023 1.023 0 0 1-.453.083 1.334 1.334 0 0 1-.866-.264.965.965 0 0 1-.329-.801.993.993 0 0 1 .076-.431 1.02 1.02 0 0 1 .242-.363 1.478 1.478 0 0 1 1.043-.303h.952v-.181a.696.696 0 0 0-.136-.454.553.553 0 0 0-.438-.154.695.695 0 0 0-.378.086.48.48 0 0 0-.193.254l-.99-.144a1.26 1.26 0 0 1 .257-.563c.14-.174.321-.302.533-.378.261-.091.54-.136.82-.129.053-.003.106-.007.163-.007Zm4.384.007c.174 0 .347.038.506.114.182.083.34.211.458.374.257.423.377.911.351 1.406a2.53 2.53 0 0 1-.355 1.448 1.148 1.148 0 0 1-1.009.517c-.204 0-.401-.045-.582-.136a1.052 1.052 0 0 1-.48-.457 1.298 1.298 0 0 1-.114-.234h-.045l.004 1.784h-1.059v-4.713h.904l.117.805h.057c.068-.208.177-.401.328-.56a1.129 1.129 0 0 1 .843-.344h.076v-.004Zm7.559.084h.903l.113.805h.053a1.37 1.37 0 0 1 .235-.484.813.813 0 0 1 .313-.242.82.82 0 0 1 .39-.076h.234v1.051h-.401a.662.662 0 0 0-.313.008.623.623 0 0 0-.272.155.663.663 0 0 0-.174.26.683.683 0 0 0-.027.314v1.875h-1.054v-3.666Zm-17.515.003h3.262v.896L3.73 13.104l.034.113h1.973l.042.9H2.4v-.9l1.931-1.754-.045-.117H2.441v-.896Zm11.815 0h1.055v3.659h-1.055V10.45Zm3.443.684.019.016a.69.69 0 0 0-.351.045.756.756 0 0 0-.287.204c-.11.155-.174.336-.189.522h1.545c-.034-.526-.257-.787-.74-.787h.003Zm-5.718.163c-.026 0-.057 0-.083.004a.78.78 0 0 0-.31.053.746.746 0 0 0-.257.189 1.016 1.016 0 0 0-.204.695v.064c-.015.257.057.507.204.711a.634.634 0 0 0 .253.196.638.638 0 0 0 .314.061.644.644 0 0 0 .578-.265c.14-.223.204-.48.189-.74a1.216 1.216 0 0 0-.181-.711.677.677 0 0 0-.503-.257Zm-4.509 1.266a.464.464 0 0 0-.268.102.373.373 0 0 0-.114.276c0 .053.008.106.027.155a.375.375 0 0 0 .087.132.576.576 0 0 0 .397.11v.004a.863.863 0 0 0 .563-.182.573.573 0 0 0 .211-.457v-.14h-.903Z", + }, + n8n: { + hex: "#EA4B71", + path: "M21.4737 5.6842c-1.1772 0-2.1663.8051-2.4468 1.8947h-2.8955c-1.235 0-2.289.893-2.492 2.111l-.1038.623a1.263 1.263 0 0 1-1.246 1.0555H11.289c-.2805-1.0896-1.2696-1.8947-2.4468-1.8947s-2.1663.8051-2.4467 1.8947H4.973c-.2805-1.0896-1.2696-1.8947-2.4468-1.8947C1.1311 9.4737 0 10.6047 0 12s1.131 2.5263 2.5263 2.5263c1.1772 0 2.1663-.8051 2.4468-1.8947h1.4223c.2804 1.0896 1.2696 1.8947 2.4467 1.8947 1.1772 0 2.1663-.8051 2.4468-1.8947h1.0008a1.263 1.263 0 0 1 1.2459 1.0555l.1038.623c.203 1.218 1.257 2.111 2.492 2.111h.3692c.2804 1.0895 1.2696 1.8947 2.4468 1.8947 1.3952 0 2.5263-1.131 2.5263-2.5263s-1.131-2.5263-2.5263-2.5263c-1.1772 0-2.1664.805-2.4468 1.8947h-.3692a1.263 1.263 0 0 1-1.246-1.0555l-.1037-.623A2.52 2.52 0 0 0 13.9607 12a2.52 2.52 0 0 0 .821-1.4794l.1038-.623a1.263 1.263 0 0 1 1.2459-1.0555h2.8955c.2805 1.0896 1.2696 1.8947 2.4468 1.8947 1.3952 0 2.5263-1.131 2.5263-2.5263s-1.131-2.5263-2.5263-2.5263m0 1.2632a1.263 1.263 0 0 1 1.2631 1.2631 1.263 1.263 0 0 1-1.2631 1.2632 1.263 1.263 0 0 1-1.2632-1.2632 1.263 1.263 0 0 1 1.2632-1.2631M2.5263 10.7368A1.263 1.263 0 0 1 3.7895 12a1.263 1.263 0 0 1-1.2632 1.2632A1.263 1.263 0 0 1 1.2632 12a1.263 1.263 0 0 1 1.2631-1.2632m6.3158 0A1.263 1.263 0 0 1 10.1053 12a1.263 1.263 0 0 1-1.2632 1.2632A1.263 1.263 0 0 1 7.579 12a1.263 1.263 0 0 1 1.2632-1.2632m10.1053 3.7895a1.263 1.263 0 0 1 1.2631 1.2632 1.263 1.263 0 0 1-1.2631 1.2631 1.263 1.263 0 0 1-1.2632-1.2631 1.263 1.263 0 0 1 1.2632-1.2632", + }, + calendly: { + hex: "#006BFF", + path: "M19.655 14.262c.281 0 .557.023.828.064 0 .005-.005.01-.005.014-.105.267-.234.534-.381.786l-1.219 2.106c-1.112 1.936-3.177 3.127-5.411 3.127h-2.432c-2.23 0-4.294-1.191-5.412-3.127l-1.218-2.106a6.251 6.251 0 0 1 0-6.252l1.218-2.106C6.736 4.832 8.8 3.641 11.035 3.641h2.432c2.23 0 4.294 1.191 5.411 3.127l1.219 2.106c.147.252.271.519.381.786 0 .004.005.009.005.014-.267.041-.543.064-.828.064-1.816 0-2.501-.607-3.291-1.306-.764-.676-1.711-1.517-3.44-1.517h-1.029c-1.251 0-2.387.455-3.2 1.278-.796.805-1.233 1.904-1.233 3.099v1.411c0 1.196.437 2.295 1.233 3.099.813.823 1.949 1.278 3.2 1.278h1.034c1.729 0 2.676-.841 3.439-1.517.791-.703 1.471-1.306 3.287-1.301Zm.005-3.237c.399 0 .794-.036 1.179-.11-.002-.004-.002-.01-.002-.014-.073-.414-.193-.823-.349-1.218.731-.12 1.407-.396 1.986-.819 0-.004-.005-.013-.005-.018-.331-1.085-.832-2.101-1.489-3.03-.649-.915-1.435-1.719-2.331-2.395-1.867-1.398-4.088-2.138-6.428-2.138-1.448 0-2.855.28-4.175.841-1.273.543-2.423 1.315-3.407 2.299S2.878 6.552 2.341 7.83c-.557 1.324-.842 2.726-.842 4.175 0 1.448.281 2.855.842 4.174.542 1.274 1.314 2.423 2.298 3.407s2.129 1.761 3.407 2.299c1.324.556 2.727.841 4.175.841 2.34 0 4.561-.74 6.428-2.137a10.815 10.815 0 0 0 2.331-2.396c.652-.929 1.158-1.949 1.489-3.03 0-.004.005-.014.005-.018-.579-.423-1.255-.699-1.986-.819.161-.395.276-.804.349-1.218.005-.009.005-.014.005-.023.869.166 1.692.506 2.404 1.035.685.505.552 1.075.446 1.416C22.184 20.437 17.619 24 12.221 24c-6.625 0-12-5.375-12-12s5.37-12 12-12c5.398 0 9.963 3.563 11.471 8.464.106.341.239.915-.446 1.421-.717.529-1.535.873-2.404 1.034.128.716.128 1.45 0 2.166-.387-.074-.782-.11-1.182-.11-4.184 0-3.968 2.823-6.736 2.823h-1.029c-1.899 0-3.15-1.357-3.15-3.095v-1.411c0-1.738 1.251-3.094 3.15-3.094h1.034c2.768 0 2.552 2.823 6.731 2.827Z", + }, + make: { + hex: "#6D00CC", + path: "M13.38 3.498c-.27 0-.511.19-.566.465L9.85 18.986a.578.578 0 0 0 .453.678l4.095.826a.58.58 0 0 0 .682-.455l2.963-15.021a.578.578 0 0 0-.453-.678l-4.096-.826a.589.589 0 0 0-.113-.012zm-5.876.098a.576.576 0 0 0-.516.318L.062 17.697a.575.575 0 0 0 .256.774l3.733 1.877a.578.578 0 0 0 .775-.258l6.926-13.781a.577.577 0 0 0-.256-.776L7.762 3.658a.571.571 0 0 0-.258-.062zm11.74.115a.576.576 0 0 0-.576.576v15.426c0 .318.258.578.576.578h4.178a.58.58 0 0 0 .578-.578V4.287a.578.578 0 0 0-.578-.576Z", + }, + cal_com: { + hex: "#292929", + path: "M2.408 14.488C1.035 14.488 0 13.4 0 12.058c0-1.346.982-2.443 2.408-2.443.758 0 1.282.233 1.691.765l-.66.55a1.343 1.343 0 0 0-1.03-.442c-.93 0-1.44.711-1.44 1.57 0 .86.559 1.557 1.44 1.557.413 0 .765-.147 1.043-.443l.651.573c-.391.51-.929.743-1.695.743zM6.948 10.913h.89v3.49h-.89v-.51c-.185.362-.493.604-1.083.604-.943 0-1.695-.82-1.695-1.826 0-1.007.752-1.825 1.695-1.825.585 0 .898.241 1.083.604zm.026 1.758c0-.546-.374-.998-.964-.998-.568 0-.938.457-.938.998 0 .528.37.998.938.998.586 0 .964-.456.964-.998zM8.467 9.503h.89v4.895h-.89zM9.752 13.937a.53.53 0 0 1 .542-.528c.313 0 .533.242.533.528a.527.527 0 0 1-.533.537.534.534 0 0 1-.542-.537zM14.23 13.839c-.33.403-.832.658-1.426.658a1.806 1.806 0 0 1-1.84-1.826c0-1.007.778-1.825 1.84-1.825.572 0 1.07.241 1.4.622l-.687.577c-.172-.215-.396-.376-.713-.376-.568 0-.938.456-.938.998 0 .541.37.997.938.997.343 0 .58-.179.757-.42zM14.305 12.671c0-1.007.78-1.825 1.84-1.825 1.061 0 1.84.818 1.84 1.825 0 1.007-.779 1.826-1.84 1.826-1.06-.005-1.84-.82-1.84-1.826zm2.778 0c0-.546-.37-.998-.938-.998-.568-.004-.937.452-.937.998 0 .542.37.998.937.998.568 0 .938-.456.938-.998zM24 12.269v2.13h-.89v-1.911c0-.604-.281-.864-.704-.864-.396 0-.678.197-.678.864v1.91h-.89v-1.91c0-.604-.285-.864-.704-.864-.396 0-.744.197-.744.864v1.91h-.89v-3.49h.89v.484c.185-.376.52-.564 1.035-.564.489 0 .898.241 1.123.649.224-.417.554-.65 1.153-.65.731.005 1.299.56 1.299 1.442z", + }, }; +// Neutral tinted-initial fallback for anything without a mark or brand color. +const BRAND_TINT: Record = {}; + export default function ProviderGlyph({ provider, name, @@ -27,16 +54,71 @@ export default function ProviderGlyph({ name: string; size?: 7 | 9 | 10; }) { - const brand = BRAND[provider] ?? { bg: "bg-sky-50", ring: "ring-sky-100", text: "text-sky-700" }; - const dim = size === 7 ? "w-7 h-7 text-[12px]" : size === 10 ? "w-10 h-10 text-[15px]" : "w-9 h-9 text-[13px]"; + const tileDim = size === 7 ? "w-7 h-7" : size === 10 ? "w-10 h-10" : "w-9 h-9"; + const iconDim = size === 7 ? "w-4 h-4" : size === 10 ? "w-6 h-6" : "w-5 h-5"; + + const raw = RAW_BRAND_LOGOS[provider]; + if (raw) { + // Full-bleed marks bring their own background and fill the tile (clipped to + // the rounded corners); the rest sit on a white tile at icon size. + if (raw.fullBleed) { + return ( +
+ +
+ ); + } + return ( +
+ +
+ ); + } + + const icon = BRAND_ICON[provider]; + if (icon) { + return ( +
+ + + +
+ ); + } + + const textDim = size === 7 ? "text-[12px]" : size === 10 ? "text-[15px]" : "text-[13px]"; + const tint = BRAND_TINT[provider] ?? { bg: "bg-sky-50", ring: "ring-sky-100", text: "text-sky-700" }; return (
{name.charAt(0)} diff --git a/web/src/app/app/integrations/_components/brandLogos.ts b/web/src/app/app/integrations/_components/brandLogos.ts new file mode 100644 index 00000000..0b095b07 --- /dev/null +++ b/web/src/app/app/integrations/_components/brandLogos.ts @@ -0,0 +1,65 @@ +// Multi-color official brand marks that can't be a single path. The raw inner +// SVG markup is rendered as-is inside a tile. Sourced from the +// brands' published logos (Slack: the four-color hash; Close: the faceted orb). + +// fullBleed marks paint their own (non-white) background and fill the whole tile, +// so the glyph wrapper skips the white tile and clips them to the rounded corners. +export type RawBrandLogo = { viewBox: string; inner: string; fullBleed?: boolean }; + +const SLACK: RawBrandLogo = { + viewBox: "0 0 256 256", + inner: [ + '', + '', + '', + '', + ].join(""), +}; + +const CLOSE: RawBrandLogo = { + viewBox: "0 0 256 256", + inner: [ + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + ].join(""), +}; + +// Pipedrive's real monogram (the official "P" mark) in white on a Green Haze +// (#08A742) tile, matching its app icon. Full-bleed: the green fills the tile. +const PIPEDRIVE: RawBrandLogo = { + viewBox: "0 0 304 304", + fullBleed: true, + inner: [ + '', + '', + ].join(""), +}; + +export const RAW_BRAND_LOGOS: Record = { + slack: SLACK, + close: CLOSE, + pipedrive: PIPEDRIVE, +}; diff --git a/web/src/app/app/layout.tsx b/web/src/app/app/layout.tsx index ef6b6e4e..7b0b7fa4 100644 --- a/web/src/app/app/layout.tsx +++ b/web/src/app/app/layout.tsx @@ -13,6 +13,7 @@ import TagsModal from "@/components/app/modals/TagsModal"; import FoldersModal from "@/components/app/modals/FoldersModal"; import AddEmailModal from "@/components/app/modals/AddEmailModal"; import PasskeyEnrollPrompt from "@/components/app/modals/PasskeyEnrollPrompt"; +import PermissionDeniedModal from "@/components/app/modals/PermissionDeniedModal"; export default function RootAppLayout() { const token = getToken(); @@ -43,6 +44,7 @@ export default function RootAppLayout() { + diff --git a/web/src/app/app/not-found.tsx b/web/src/app/app/not-found.tsx index 00af2263..9c8a263a 100644 --- a/web/src/app/app/not-found.tsx +++ b/web/src/app/app/not-found.tsx @@ -16,7 +16,7 @@ import { cn } from "@/lib/utils"; const dests = [ { title: "Accounts", url: "/app/emails", icon: MailIcon, hint: "mailboxes & senders" }, - { title: "Campaigns", url: "/app/campaigns", icon: MegaphoneIcon, hint: "sequences & sends" }, + { title: "Campaigns", url: "/app/campaigns", icon: MegaphoneIcon, hint: "steps & sends" }, { title: "Contacts", url: "/app/contacts", icon: UsersIcon, hint: "people & lists" }, { title: "Analytics", url: "/app/analytics", icon: BarChart3Icon, hint: "opens, clicks, replies" }, ]; diff --git a/web/src/app/app/settings/_components/RoleMultiSelect.tsx b/web/src/app/app/settings/_components/RoleMultiSelect.tsx new file mode 100644 index 00000000..358bdfbd --- /dev/null +++ b/web/src/app/app/settings/_components/RoleMultiSelect.tsx @@ -0,0 +1,114 @@ +// Multi-role picker: checkbox dropdown + colored chips for the selected +// roles. A member can hold several roles; effective access is the union. + +import React from "react"; +import { CheckIcon, ChevronDownIcon, Loader2Icon } from "lucide-react"; +import type OrganizationRole from "@/lib/api/models/app/organizations/OrganizationRole"; +import type { MemberRole } from "@/lib/api/models/app/organizations/OrganizationMember"; +import { + PopoverMenu, + PopoverMenuContent, + PopoverMenuTrigger, +} from "@/components/ui/popover-menu"; +import { roleColor } from "./RoleSelect"; + +export function RoleChips({ roles }: { roles: MemberRole[] }) { + if (roles.length === 0) { + return No role; + } + return ( + + {roles.map((r) => ( + + + {r.name} + + ))} + + ); +} + +export default function RoleMultiSelect({ + roles, + value, + onChange, + pending = false, + align = "start", +}: { + roles: OrganizationRole[]; + /** Selected role ids. */ + value: string[]; + /** Fires with the next full set; never empty (at least one role). */ + onChange: (roleIds: string[]) => void; + pending?: boolean; + align?: "start" | "end"; +}) { + const [open, setOpen] = React.useState(false); + const selected = roles.filter((r) => value.includes(r.id)); + const summary = + selected.length === 0 + ? "Select roles" + : selected.length === 1 + ? selected[0].name + : `${selected[0].name} +${selected.length - 1}`; + + const toggle = (id: string) => { + const next = value.includes(id) ? value.filter((v) => v !== id) : [...value, id]; + if (next.length === 0) return; // keep at least one + onChange(next); + }; + + return ( + + + + + + {roles.map((r) => { + const on = value.includes(r.id); + return ( + + ); + })} + {roles.length === 0 && ( +
+ No roles yet. Create one under Settings → Roles & access. +
+ )} +
+
+ ); +} diff --git a/web/src/app/app/settings/_components/RoleSelect.tsx b/web/src/app/app/settings/_components/RoleSelect.tsx new file mode 100644 index 00000000..c5810089 --- /dev/null +++ b/web/src/app/app/settings/_components/RoleSelect.tsx @@ -0,0 +1,92 @@ +// Shared workspace-role dropdown: colored dot, name, description, check on +// the active row. Roles are data (seeded Admin/Manager/Viewer + anything the +// workspace created); Owner is a membership status and never appears here. + +import React from "react"; +import { CheckIcon, ChevronDownIcon, Loader2Icon } from "lucide-react"; +import type OrganizationRole from "@/lib/api/models/app/organizations/OrganizationRole"; +import { + PopoverMenu, + PopoverMenuContent, + PopoverMenuTrigger, +} from "@/components/ui/popover-menu"; + +const FALLBACK_COLOR = "#64748b"; + +export function roleColor(role?: Pick | null) { + return role?.color || FALLBACK_COLOR; +} + +export default function RoleSelect({ + roles, + value, + fallbackLabel, + onChange, + pending = false, + align = "start", +}: { + roles: OrganizationRole[]; + /** Currently selected role id (member.role_id / draft selection). */ + value?: string | null; + /** Shown when value matches no role (e.g. the role was deleted). */ + fallbackLabel?: string; + onChange: (role: OrganizationRole) => void; + pending?: boolean; + align?: "start" | "end"; +}) { + const [open, setOpen] = React.useState(false); + const current = roles.find((r) => r.id === value); + const label = current?.name ?? fallbackLabel ?? "Select role"; + const color = current ? roleColor(current) : FALLBACK_COLOR; + + return ( + + + + + + {roles.map((r) => { + const selected = r.id === value; + return ( + + ); + })} + {roles.length === 0 && ( +
+ No roles yet. Create one under Settings → Roles & access. +
+ )} +
+
+ ); +} diff --git a/web/src/app/app/settings/_components/SaveStatus.tsx b/web/src/app/app/settings/_components/SaveStatus.tsx new file mode 100644 index 00000000..e7727f41 --- /dev/null +++ b/web/src/app/app/settings/_components/SaveStatus.tsx @@ -0,0 +1,53 @@ +import { AnimatePresence, motion } from "framer-motion"; +import { CheckIcon, Loader2Icon, AlertCircleIcon } from "lucide-react"; +import type { AutosaveStatus } from "@/hooks/useAutosave"; + +// Header indicator for the auto-save settings tabs: Saving… / Saved / retry. +// Renders nothing when idle so the header stays quiet between edits. +export default function SaveStatus({ status, onRetry }: { status: AutosaveStatus; onRetry?: () => void }) { + return ( + + {status === "saving" && ( + + + Saving… + + )} + {status === "saved" && ( + + + Saved + + )} + {status === "error" && ( + + + Couldn't save + {onRetry && ( + + )} + + )} + + ); +} + +function Pill({ children, className }: { children: React.ReactNode; className?: string }) { + return ( + + {children} + + ); +} diff --git a/web/src/app/app/settings/_components/SectionShell.tsx b/web/src/app/app/settings/_components/SectionShell.tsx index 8cd8b09f..0e86cb72 100644 --- a/web/src/app/app/settings/_components/SectionShell.tsx +++ b/web/src/app/app/settings/_components/SectionShell.tsx @@ -146,19 +146,22 @@ export function Row({ export function Toggle({ on, onChange, + disabled, }: { on: boolean; onChange: (next: boolean) => void; + disabled?: boolean; }) { return ( + + +
+ + + )} +
); } + +function SectionLink({ section }: { section: SectionDef }) { + return ( + + `group relative shrink-0 md:w-full flex items-center gap-2.5 px-2.5 h-8 rounded-md text-[12.5px] whitespace-nowrap text-left transition-colors ${ + isActive ? "text-slate-900 font-medium" : "text-slate-600 hover:text-slate-900 hover:bg-slate-200/40" + }` + } + > + {({ isActive }) => ( + <> + {isActive && ( + + )} + + {section.label} + + )} + + ); +} diff --git a/web/src/app/app/settings/members/page.tsx b/web/src/app/app/settings/members/page.tsx index fdcfbae3..d43e75fb 100644 --- a/web/src/app/app/settings/members/page.tsx +++ b/web/src/app/app/settings/members/page.tsx @@ -9,7 +9,6 @@ import React from "react"; import { CheckIcon, - ChevronDownIcon, CopyIcon, Loader2Icon, MailIcon, @@ -20,11 +19,6 @@ import { } from "lucide-react"; import toast from "react-hot-toast"; import { Label } from "@/components/ui/field"; -import { - PopoverMenu, - PopoverMenuContent, - PopoverMenuTrigger, -} from "@/components/ui/popover-menu"; import { useConfirm } from "@/hooks/context/confirm"; import useFeatureAccess from "@/hooks/useFeatureAccess"; import useMembers from "@/lib/api/hooks/app/organizations/useMembers"; @@ -33,10 +27,13 @@ import useInviteMember from "@/lib/api/hooks/app/organizations/useInviteMember"; import useRemoveMember from "@/lib/api/hooks/app/organizations/useRemoveMember"; import useCancelInvitation from "@/lib/api/hooks/app/organizations/useCancelInvitation"; import useUpdateMemberRole from "@/lib/api/hooks/app/organizations/useUpdateMemberRole"; +import useRoles from "@/lib/api/hooks/app/organizations/useRoles"; +import type OrganizationRole from "@/lib/api/models/app/organizations/OrganizationRole"; import { useAppStore } from "@/stores"; import type { AppError } from "@/lib/api/client/normalizeError"; import buildError from "@/lib/helper/buildError"; -import { ROLE_CATALOG, getRoleDef } from "@/lib/permissions"; +import getInvitationLink from "@/lib/api/client/app/organizations/getInvitationLink"; +import RoleMultiSelect, { RoleChips } from "../_components/RoleMultiSelect"; import { RolePill, Section, @@ -59,6 +56,7 @@ export default function MembersSettingsPage() { const removeMember = useRemoveMember(); const cancelInvite = useCancelInvitation(); const updateRole = useUpdateMemberRole(); + const customRoles = useRoles(); const currentUserId = useAppStore((s) => s.user?.id); const currentOrg = useAppStore((s) => s.currentOrganization); @@ -91,32 +89,29 @@ export default function MembersSettingsPage() { } }); } - function copyInviteLink(invitationId: string) { - const url = `${window.location.origin}/select-org?invitation=${invitationId}`; - navigator.clipboard.writeText(url).then( - () => toast.success("Invite link copied"), - () => toast.error("Couldn't copy"), - ); + async function copyInviteLink(invitationId: string) { + try { + const { token } = await getInvitationLink(invitationId); + const url = `${window.location.origin}/invite?token=${encodeURIComponent(token)}`; + await navigator.clipboard.writeText(url); + toast.success("Invite link copied"); + } catch (e) { + toast.error(buildError(e as AppError)); + } } - function changeRole(memberId: string, nextRole: string, email: string) { - const def = getRoleDef(nextRole); - confirm?.show(`Change ${email}'s role to ${def.label}?`, async () => { - try { - await toast.promise( - updateRole.mutateAsync({ - id: memberId, - data: { role: nextRole }, - }), - { - loading: "Saving…", - success: `Role updated to ${def.label}`, - error: (e: AppError) => buildError(e), - }, - ); - } catch { - /* surfaced */ - } - }); + async function changeRoles(memberId: string, roleIds: string[]) { + try { + await toast.promise( + updateRole.mutateAsync({ id: memberId, data: { role_ids: roleIds } }), + { + loading: "Saving…", + success: "Roles updated", + error: (e: AppError) => buildError(e), + }, + ); + } catch { + /* surfaced */ + } } return ( @@ -124,19 +119,20 @@ export default function MembersSettingsPage() { title="Members" description={`Everyone with access to ${currentOrg?.name ?? "this workspace"}.`} > - {access.isOwner && ( + {access.canManage && (
{ + customRoles={customRoles.data ?? []} + onSubmit={async (emails, roleIds) => { let ok = 0; let failed = 0; for (const e of emails) { try { - await invite.mutateAsync({ email: e, role }); + await invite.mutateAsync({ email: e, role_ids: roleIds }); ok++; } catch { failed++; @@ -203,10 +199,11 @@ export default function MembersSettingsPage() {
- {access.isOwner && !isOwner ? ( - changeRole(m.user_id, next, email)} + {access.canManage && !isOwner ? ( + r.id)} + onChange={(ids) => changeRoles(m.user_id, ids)} pending={updateRole.isPending} /> ) : isOwner ? ( @@ -215,7 +212,7 @@ export default function MembersSettingsPage() { Owner ) : ( - + )} @@ -224,7 +221,7 @@ export default function MembersSettingsPage() { : "—"} - {access.isOwner && !isOwner && !isSelf && ( + {access.canManage && !isOwner && !isSelf && (
- + {(inv.roles?.length ?? 0) > 0 ? : r.id === inv.role_id)?.color} />} {new Date(inv.expires_at).toLocaleDateString("en-US", { month: "short", day: "numeric" })} - {access.isOwner && ( + {access.canManage && (
- - - {assignable.map((r) => { - const selected = r.id === value; - return ( - - ); - })} - - - ); -} /** * Multi-email invite flow. Email chips + role selector + send button, @@ -417,13 +337,19 @@ function InlineRolePicker({ function InviteFlow({ onSubmit, pending, + customRoles, }: { - onSubmit: (emails: string[], role: string) => Promise; + onSubmit: (emails: string[], roleIds: string[]) => Promise; pending: boolean; + customRoles: OrganizationRole[]; }) { const [chips, setChips] = React.useState<{ email: string; valid: boolean }[]>([]); const [draft, setDraft] = React.useState(""); - const [role, setRole] = React.useState("manager"); + const [roleIds, setRoleIds] = React.useState([]); + // Default to the seeded Viewer (least privilege), else the first role. + const defaultRole = customRoles.find((r) => r.name === "Viewer") ?? customRoles[0]; + const effectiveRoleIds = roleIds.length > 0 ? roleIds : defaultRole ? [defaultRole.id] : []; + const selectedRoles = customRoles.filter((r) => effectiveRoleIds.includes(r.id)); const SEPARATOR_RE = /[\s,;]+/; function commitDrafts(value: string) { @@ -481,14 +407,26 @@ function InviteFlow({ icon: "⚠️", }); } - await onSubmit(valid, role); + if (effectiveRoleIds.length === 0) { + toast.error("Create a role first (Settings → Roles & access)"); + return; + } + await onSubmit(valid, effectiveRoleIds); setChips([]); setDraft(""); } const totalCount = chips.length + (draft.trim() ? draft.trim().split(SEPARATOR_RE).filter(Boolean).length : 0); - const assignable = ROLE_CATALOG.filter((r) => r.assignable && r.id !== "member"); - const activeRole = getRoleDef(role); + const activeLabel = + selectedRoles.length === 0 + ? "No roles yet" + : selectedRoles.map((r) => r.name).join(", "); + const activeDescription = + selectedRoles.length === 0 + ? "Create a role under Settings → Roles & access before inviting members." + : selectedRoles.length === 1 + ? (selectedRoles[0].description || "This role's permissions apply to the invitee.") + : "The invitee gets the combined permissions of every selected role."; return (
@@ -551,23 +489,12 @@ function InviteFlow({
- -
- {assignable.map((r) => ( - - ))} -
+ +
@@ -591,10 +518,10 @@ function InviteFlow({
- {activeRole.label} + {activeLabel}

- {activeRole.description} + {activeDescription}

diff --git a/web/src/app/app/settings/notifications/page.tsx b/web/src/app/app/settings/notifications/page.tsx index b1c1203f..722c4671 100644 --- a/web/src/app/app/settings/notifications/page.tsx +++ b/web/src/app/app/settings/notifications/page.tsx @@ -1,14 +1,13 @@ import React from "react"; -import toast from "react-hot-toast"; -import { TopbarAction } from "@/components/layout/Page"; -import type { AppError } from "@/lib/api/client/normalizeError"; -import buildError from "@/lib/helper/buildError"; import { useNotificationPreferences, useUpdateNotificationPreferences, } from "@/lib/api/hooks/app/notifications/useNotifications"; import type { NotificationCategoryKey, NotificationPreferences } from "@/lib/api/models/app/notifications/Notification"; import { Row, Section, SectionShell, Toggle } from "../_components/SectionShell"; +import SaveStatus from "../_components/SaveStatus"; +import { useAutosave } from "@/hooks/useAutosave"; +import { useRegisterUnsaved } from "@/hooks/context/unsaved"; const INBOUND: { key: NotificationCategoryKey; label: string; hint: string }[] = [ { key: "inbound_reply", label: "Reply received", hint: "A recipient replied to a cold email." }, @@ -21,29 +20,58 @@ const HEALTH: { key: NotificationCategoryKey; label: string; hint: string }[] = { key: "health_worker_downtime", label: "Worker downtime", hint: "A sender worker stops responding." }, ]; +const SECURITY: { key: NotificationCategoryKey; label: string; hint: string }[] = [ + { key: "security_new_signin", label: "New sign-in", hint: "Your account was accessed from a device you haven't used before." }, +]; + export default function NotificationsSettingsPage() { const { data, isLoading } = useNotificationPreferences(); const update = useUpdateNotificationPreferences(); const [draft, setDraft] = React.useState(null); - React.useEffect(() => { - if (data) setDraft(data); - }, [data]); + // Auto-save: toggles persist instantly. markSaved on data load moves the + // baseline to the server value so the initial null→data hydration (and any + // refetch) is never mistaken for a user edit. + const autosave = useAutosave({ + value: draft, + enabled: !!draft, + save: async (v) => { + if (v) await update.mutateAsync(v); + }, + }); + useRegisterUnsaved(autosave, () => setDraft(autosave.savedValue)); - const dirty = !!draft && !!data && JSON.stringify(draft) !== JSON.stringify(data); + React.useEffect(() => { + if (data) { + setDraft(data); + autosave.markSaved(data); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [data]); const setEnabled = (key: NotificationCategoryKey, on: boolean) => setDraft((d) => (d ? { ...d, [key]: { ...d[key], enabled: on } } : d)); - const save = async () => { - if (!draft || !dirty || update.isPending) return; - try { - await update.mutateAsync(draft); - toast.success("Notification preferences saved"); - } catch (err) { - toast.error(buildError(err as AppError)); - } - }; + const CATEGORY_KEYS: NotificationCategoryKey[] = [ + "inbound_reply", + "inbound_out_of_office", + "health_bounce", + "health_complaint", + "health_worker_downtime", + "security_new_signin", + ]; + // Channels present globally: "on" when every category carries the channel. + const channelOn = (ch: "email" | "slack") => + !!draft && CATEGORY_KEYS.every((k) => draft[k].channels[ch]); + const setChannel = (ch: "email" | "slack", on: boolean) => + setDraft((d) => { + if (!d) return d; + const next = { ...d }; + for (const k of CATEGORY_KEYS) { + next[k] = { ...d[k], channels: { ...d[k].channels, [ch]: on } }; + } + return next; + }); const rows = (items: { key: NotificationCategoryKey; label: string; hint: string }[]) => items.map((c) => ( @@ -55,19 +83,8 @@ export default function NotificationsSettingsPage() { return ( - data && setDraft(data)}> - Discard - - - {update.isPending ? "Saving…" : "Save"} - - - ) : null - } + description="Which events notify you, and where they are delivered. Defaults reflect the recommendation." + actions={} > {isLoading || !draft ? (
Loading…
@@ -82,15 +99,18 @@ export default function NotificationsSettingsPage() {
{rows(HEALTH)}
-
+
+ {rows(SECURITY)} +
+
On - Coming soon + setChannel("email", v)} /> - - Coming soon + + setChannel("slack", v)} />
diff --git a/web/src/app/app/settings/oauth-apps/page.tsx b/web/src/app/app/settings/oauth-apps/page.tsx new file mode 100644 index 00000000..612dce09 --- /dev/null +++ b/web/src/app/app/settings/oauth-apps/page.tsx @@ -0,0 +1,607 @@ +// OAuth apps — a Settings section. Developers register third-party OAuth clients +// here (client id + one-time secret, redirect URIs, requested scopes) and review +// the apps the workspace's members have authorized. Every app is issued a client +// secret; PKCE is an optional extra layer the developer can add. The flow itself +// (consent + token exchange) lives on the standalone /oauth/authorize page + API. + +import React from "react"; +import { createPortal } from "react-dom"; +import toast from "react-hot-toast"; +import { AnimatePresence, motion } from "framer-motion"; +import { + ArrowLeftIcon, + ArrowRightIcon, + CheckCircle2Icon, + CheckIcon, + CopyIcon, + ImageIcon, + Loader2Icon, + PlusIcon, + RefreshCwIcon, + Trash2Icon, + XIcon, +} from "lucide-react"; + +import { NoAccess } from "@/components/layout/NoAccess"; +import { usePermission } from "@/hooks/usePermission"; +import { EmptyBlock } from "@/components/layout/Page"; +import { Label, TextInput } from "@/components/ui/field"; +import { cn } from "@/lib/utils"; +import { useConfirm } from "@/hooks/context/confirm"; +import useAPIPermissions from "@/lib/api/hooks/app/api-keys/useAPIPermissions"; +import type APIPermission from "@/lib/api/models/app/apikeys/APIPermission"; +import { + useOAuthApps, + useCreateOAuthApp, + useDeleteOAuthApp, + useRotateOAuthAppSecret, + useUploadOAuthAppLogo, +} from "@/lib/api/hooks/app/oauth/useOAuthApps"; +import { useAuthorizedApps, useRevokeAuthorizedApp } from "@/lib/api/hooks/app/oauth/useAuthorizedApps"; +import type { OAuthApplication, OAuthApplicationWithSecret } from "@/lib/api/models/app/oauth/OAuthApp"; +import { SectionShell } from "../_components/SectionShell"; + +function CopyButton({ value, label }: { value: string; label?: string }) { + const [copied, setCopied] = React.useState(false); + return ( + + ); +} + +// ScopePicker is a checkbox grid over the API permissions, toggling bits in the +// scope bitmask. Reuses the same permission catalogue as API keys. +function ScopePicker({ value, onChange }: { value: number; onChange: (v: number) => void }) { + const perms = useAPIPermissions(); + const grouped = React.useMemo(() => { + const g: Record = {}; + for (const p of perms.data?.permissions ?? []) { + (g[p.category] ??= []).push(p); + } + return g; + }, [perms.data]); + const toggle = (bit: number) => onChange(value & bit ? value & ~bit : value | bit); + return ( +
+ {Object.entries(grouped).map(([cat, list]) => ( +
+
{cat}
+
+ {list.map((p) => { + const on = (value & p.value) === p.value; + return ( + + ); + })} +
+
+ ))} +
+ ); +} + +const TILE_COLORS = ["bg-sky-600", "bg-indigo-600", "bg-emerald-600", "bg-rose-600", "bg-amber-600", "bg-fuchsia-600"]; + +// AppLogo renders an app's uploaded logo, or a colored letter tile as a fallback. +function AppLogo({ name, url, size = "md" }: { name: string; url?: string | null; size?: "sm" | "md" | "lg" }) { + const dim = + size === "lg" + ? "w-16 h-16 text-[22px] rounded-2xl" + : size === "sm" + ? "w-8 h-8 text-[12px] rounded-md" + : "w-9 h-9 text-[13px] rounded-lg"; + if (url) return {name}; + const letter = (name.trim()[0] ?? "?").toUpperCase(); + const color = TILE_COLORS[letter.charCodeAt(0) % TILE_COLORS.length]; + return
{letter}
; +} + +const WIZARD_STEPS = ["Basics", "Branding", "Redirects", "Scopes"] as const; + +// Stepper — the horizontal progress indicator at the top of the register wizard. +function Stepper({ step }: { step: number }) { + return ( +
+
+ {WIZARD_STEPS.map((label, i) => { + const done = i < step; + const active = i === step; + return ( + +
+ + {done ? : i + 1} + + +
+ {i < WIZARD_STEPS.length - 1 && ( + + )} +
+ ); + })} +
+
+ ); +} + +// RegisterModal — a multi-step onboarding wizard for creating an OAuth app: +// Basics -> Branding (logo) -> Redirects -> Scopes -> reveal credentials. The +// modal animates in, steps slide, and a stepper shows progress. +function RegisterModal({ onClose }: { onClose: () => void }) { + const create = useCreateOAuthApp(); + const uploadLogo = useUploadOAuthAppLogo(); + const fileRef = React.useRef(null); + + const [step, setStep] = React.useState(0); + const [name, setName] = React.useState(""); + const [description, setDescription] = React.useState(""); + const [website, setWebsite] = React.useState(""); + const [logoUrl, setLogoUrl] = React.useState(""); + const [redirects, setRedirects] = React.useState(""); + const [scopes, setScopes] = React.useState(0); + const [created, setCreated] = React.useState(null); + + const redirectList = redirects.split("\n").map((s) => s.trim()).filter(Boolean); + + const stepValid = (i: number): boolean => { + if (i === 0) return name.trim().length > 0; + if (i === 2) return redirectList.length > 0; + if (i === 3) return scopes !== 0; + return true; // branding (logo) is optional + }; + + const goNext = () => { + if (!stepValid(step)) { + toast.error(step === 0 ? "Give the app a name" : step === 2 ? "Add at least one redirect URI" : "Select at least one scope"); + return; + } + setStep((s) => Math.min(s + 1, WIZARD_STEPS.length - 1)); + }; + const goBack = () => setStep((s) => Math.max(s - 1, 0)); + + const onPickLogo = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + e.target.value = ""; + if (!file) return; + try { + const { logo_url } = await uploadLogo.mutateAsync(file); + setLogoUrl(logo_url); + } catch (err) { + toast.error((err as { message?: string })?.message ?? "Could not upload the logo"); + } + }; + + const submit = async () => { + if (!stepValid(0) || !stepValid(2) || !stepValid(3)) { + toast.error("Fill in the required fields"); + return; + } + try { + const app = await create.mutateAsync({ + name: name.trim(), + description: description.trim(), + website_url: website.trim(), + logo_url: logoUrl || undefined, + redirect_uris: redirectList, + scopes, + }); + setCreated(app); + toast.success("App registered"); + } catch (e) { + toast.error((e as { message?: string })?.message ?? "Could not register the app"); + } + }; + + // Rendered through a portal to document.body so the modal mounts as a clean + // top-level subtree (outside the Settings content's AnimatePresence) and its + // entrance animation always plays. + return createPortal( +
+ + +
+ + {created ? "App created" : "Register an OAuth app"} + + +
+ + {created ? ( + + ) : ( + <> + +
+ + + {step === 0 && ( + <> +
+ + +
+
+ + +
+
+ + +
+ + )} + {step === 1 && ( + <> +

+ Add a logo so people recognize your app on the consent screen when they connect it. +

+
+ +
+ +
+ + {logoUrl && ( + + )} +
+

PNG or JPG, up to 2MB. Optional.

+
+
+ + )} + {step === 2 && ( +
+ +