mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-09 00:03:10 +00:00
Merge pull request #48 from warmbly/feature/realtime-tracking
Realtime collaboration + presence privacy, the steps/automations flow overhaul, and the label-email action
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:<id>`, `org_id`/`organization_id` -> `org:<id>`, 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:<id>`, `automation:<id>`, `campaign:<id>`, `contact:<id>` — follow this naming for new surfaces
|
||||
- show other viewers with `<ResourceViewers resource={...} />` (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://<domain>/c/<uuid>`. 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`
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -51,7 +51,7 @@ interface AuthRequestConfig extends AxiosRequestConfig {
|
||||
let refreshPromise: Promise<AdminToken> | null = null;
|
||||
|
||||
async function refreshTokens(refreshToken: string): Promise<AdminToken> {
|
||||
const res = await axios.post<AdminToken>(`${API_URL}/auth/refresh`, {
|
||||
const res = await axios.post<AdminToken>(`${API_URL}/v1/auth/refresh`, {
|
||||
refresh_token: refreshToken,
|
||||
});
|
||||
return res.data;
|
||||
|
||||
@@ -15,7 +15,7 @@ import type {
|
||||
export function login(input: LoginRequest): Promise<LoginStartResponse> {
|
||||
return Request<LoginStartResponse>({
|
||||
method: "POST",
|
||||
url: "/auth/login",
|
||||
url: "/v1/auth/login",
|
||||
data: input,
|
||||
timeout: 15_000,
|
||||
});
|
||||
@@ -25,7 +25,7 @@ export function login(input: LoginRequest): Promise<LoginStartResponse> {
|
||||
export function loginConfirm(input: LoginConfirmRequest): Promise<LoginResponse> {
|
||||
return Request<LoginResponse>({
|
||||
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<LoginResponse>
|
||||
export function getMe(): Promise<AdminProfile> {
|
||||
return Request<AdminProfile>({
|
||||
method: "GET",
|
||||
url: "/auth/me",
|
||||
url: "/v1/auth/me",
|
||||
authorization: true,
|
||||
});
|
||||
}
|
||||
@@ -42,7 +42,7 @@ export function getMe(): Promise<AdminProfile> {
|
||||
export function logout(): Promise<void> {
|
||||
return Request<void>({
|
||||
method: "POST",
|
||||
url: "/auth/logout",
|
||||
url: "/v1/auth/logout",
|
||||
authorization: true,
|
||||
});
|
||||
}
|
||||
|
||||
+60
-10
@@ -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 + "<topic>-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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+55
-3
@@ -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
|
||||
|
||||
@@ -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 `<topic>-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.
|
||||
|
||||
@@ -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/<id> 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 "<topic>-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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
@@ -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
|
||||
|
||||
<Callout type="warn" title="Keep Your Keys Secret">
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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": "<p>Hi Jane, ...</p>",
|
||||
"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.
|
||||
@@ -6,8 +6,12 @@
|
||||
"pages": [
|
||||
"index",
|
||||
"authentication",
|
||||
"oauth",
|
||||
"permissions",
|
||||
"endpoints",
|
||||
"openapi",
|
||||
"reference",
|
||||
"realtime",
|
||||
"error-codes"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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=<random-csrf-token>
|
||||
&code_challenge=<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=<your-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=<the-original-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
|
||||
|
||||
<Cards>
|
||||
<Card title="Permissions" href="/api/permissions/">
|
||||
The scopes an OAuth app can request, shared with API keys.
|
||||
</Card>
|
||||
<Card title="Authentication" href="/api/authentication/">
|
||||
Using bearer tokens (API keys and OAuth) on the API.
|
||||
</Card>
|
||||
</Cards>
|
||||
@@ -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`.
|
||||
@@ -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 |
|
||||
|
||||
@@ -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=<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:<user_id>` | Events for the key's owning user | Always joinable by yourself |
|
||||
| `org:<org_id>` | Organization-wide events | Requires membership; events are filtered by your member permissions |
|
||||
| `campaign:<campaign_id>` | One campaign's activity | Requires `view_campaigns` |
|
||||
| `account:<account_id>` | One mailbox's sync and warmup events | Requires `manage_emails` |
|
||||
| `bulk:<operation_id>` | 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:<org_id>` 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:<org_id>` 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:<org_id>` 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:<org_id>` 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:<id>", "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.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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": "<p>Hi {{first_name}}...</p>",
|
||||
"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": "<p>Hi {{first_name}}...</p>",
|
||||
"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`.
|
||||
@@ -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:<key>`). Empty uses the default columns. |
|
||||
| `filename` | string | No | Filename without extension. Sanitized server-side; empty falls back to `contacts-<YYYY-MM-DD>`. |
|
||||
|
||||
```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:<key>`. |
|
||||
| `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 <addr@example.com>`) 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": "<abc@mail.acme.com>",
|
||||
"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.
|
||||
@@ -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"
|
||||
}
|
||||
```
|
||||
@@ -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": "<p>Hi {{.FirstName}},</p>",
|
||||
"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": "<p>Hi {{.FirstName}},</p>",
|
||||
"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": "<p>Hi {{.FirstName}},</p>",
|
||||
"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": "<p>Hi Dana,</p>",
|
||||
"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."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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": "<p>Hi Jane, ...</p>",
|
||||
"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.
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"title": "Endpoint reference",
|
||||
"icon": "Braces",
|
||||
"pages": [
|
||||
"mailboxes",
|
||||
"campaigns",
|
||||
"contacts",
|
||||
"unibox",
|
||||
"crm",
|
||||
"analytics",
|
||||
"api-keys",
|
||||
"webhooks",
|
||||
"integrations",
|
||||
"deliverability-ops",
|
||||
"account-org"
|
||||
]
|
||||
}
|
||||
@@ -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 <jane@acme.com>"],
|
||||
"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": "<CA+abc123@mail.acme.com>",
|
||||
"gmail_id": "18f0c2a9b7d4e5f6",
|
||||
"parent_id": "<CA+prev@mail.acme.com>",
|
||||
"uid": 4821,
|
||||
"mod_seq": 90210,
|
||||
"flags": ["\\Seen"],
|
||||
"bcc": [],
|
||||
"cc": [],
|
||||
"from_addr": ["Jane Doe <jane@acme.com>"],
|
||||
"in_reply_to": ["<CA+prev@mail.acme.com>"],
|
||||
"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": "<p>Thanks for the details...</p>",
|
||||
"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": "<p>Happy to hop on a call this week.</p>",
|
||||
"in_reply_to": ["<CA+abc123@mail.acme.com>"],
|
||||
"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": "<CA+prev@mail.acme.com>",
|
||||
"thread_id": "thread-af83b21",
|
||||
"flags": ["\\Seen"],
|
||||
"bcc": [],
|
||||
"cc": [],
|
||||
"date": "2026-06-11T14:22:00Z",
|
||||
"from": ["Jane Doe <jane@acme.com>"],
|
||||
"in_reply_to": ["<CA+prev@mail.acme.com>"],
|
||||
"message_id": "<CA+abc123@mail.acme.com>",
|
||||
"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": "<p>Thanks for the details...</p>"
|
||||
}
|
||||
```
|
||||
|
||||
## 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.
|
||||
@@ -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=<unix>,v1=<hex>`. |
|
||||
| `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=<unix-timestamp>,v1=<hex-digest>`. To verify:
|
||||
|
||||
1. Parse `t` and `v1` from the header.
|
||||
2. Compute `HMAC-SHA256(secret, "<t>." + 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"
|
||||
}
|
||||
```
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
<Callout type="info" title="Best-effort by design">
|
||||
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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -18,6 +18,7 @@
|
||||
"analytics",
|
||||
"notifications",
|
||||
"security",
|
||||
"team-roles"
|
||||
"team-roles",
|
||||
"collaboration"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
</Callout>
|
||||
|
||||
## 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:
|
||||
|
||||
@@ -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).
|
||||
|
||||
<Callout type="info">
|
||||
**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.
|
||||
</Callout>
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
<Callout type="info" title="Owner-only controls">
|
||||
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.
|
||||
<Callout type="info" title="Who can manage the team">
|
||||
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.
|
||||
</Callout>
|
||||
|
||||
## 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.
|
||||
|
||||
<Callout type="info" title="Custom roles">
|
||||
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.
|
||||
</Callout>
|
||||
## 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
|
||||
|
||||
|
||||
@@ -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=<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:<id>, campaign:<id>, contact:<id>." },
|
||||
"action": { "type": "string", "enum": ["viewing", "editing", "replying", "idle"] }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+33
-10
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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":"<uuid>"} | 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(),
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// /<APIVersion> (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()
|
||||
}
|
||||
}
|
||||
+586
-505
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 "<id>"}}.
|
||||
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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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(`<p><a href="%s" style="display:inline-block;padding:10px 20px;background:#0284c7;color:white;text-decoration:none;border-radius:6px;">Open in Warmbly</a></p>`, href)
|
||||
}
|
||||
html := fmt.Sprintf(`<h2 style="margin:0 0 8px;">%s</h2><p style="color:#475569;">%s</p>%s<p style="color:#94a3b8;font-size:12px;margin-top:24px;">You're receiving this because email notifications are on for %s. Manage them in Settings → Notifications.</p>`,
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user