From 20f1e93c4b81472a13d413278200ea6b7dc3c0e5 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 28 May 2026 08:19:10 +0200 Subject: [PATCH 01/13] feat(integration): backend foundation for tier 1+2 integrations Adds an integrations app module covering the providers from the tier 1/2 plan: Calendly, Cal.com, Google Sheets, Google Postmaster, Microsoft SNDS, DMARC ingestion, and Cloudflare/GoDaddy/Namecheap DNS. One unified migration provisions integration_connections, dmarc_reports + record rows, postmaster_snapshots, dns_verifications, and meeting_bookings. The service exposes a generic CRUD surface for connection state with per-provider files for parsing (calendly.go, dmarc.go), HTTP clients (cloudflare.go, postmaster.go, google_sheets.go), and DNS verification (dns.go). Inbound webhook routes use per-org URL-embedded secrets so Calendly/Cal.com/DMARC providers post directly without Warmbly auth. DNS verifier resolves SPF/DKIM/DMARC + tracking CNAME and surfaces fixes when a record is missing. --- cmd/backend/main.go | 11 + internal/api/handler/handler.go | 6 + internal/api/handler/integration.go | 351 ++++++++++++ internal/api/routes.go | 27 + internal/app/integration/calendly.go | 191 +++++++ internal/app/integration/catalog.go | 94 ++++ internal/app/integration/cloudflare.go | 189 +++++++ internal/app/integration/dmarc.go | 136 +++++ internal/app/integration/dns.go | 156 ++++++ internal/app/integration/google_sheets.go | 145 +++++ internal/app/integration/postmaster.go | 280 ++++++++++ internal/app/integration/service.go | 246 +++++++++ .../migrations/000044_integrations.down.sql | 6 + .../db/migrations/000044_integrations.up.sql | 209 +++++++ internal/models/integration.go | 193 +++++++ internal/repository/pg_integration.go | 517 ++++++++++++++++++ 16 files changed, 2757 insertions(+) create mode 100644 internal/api/handler/integration.go create mode 100644 internal/app/integration/calendly.go create mode 100644 internal/app/integration/catalog.go create mode 100644 internal/app/integration/cloudflare.go create mode 100644 internal/app/integration/dmarc.go create mode 100644 internal/app/integration/dns.go create mode 100644 internal/app/integration/google_sheets.go create mode 100644 internal/app/integration/postmaster.go create mode 100644 internal/app/integration/service.go create mode 100644 internal/infrastructure/db/migrations/000044_integrations.down.sql create mode 100644 internal/infrastructure/db/migrations/000044_integrations.up.sql create mode 100644 internal/models/integration.go create mode 100644 internal/repository/pg_integration.go diff --git a/cmd/backend/main.go b/cmd/backend/main.go index 2567f129..33ecd80d 100644 --- a/cmd/backend/main.go +++ b/cmd/backend/main.go @@ -45,6 +45,7 @@ import ( "github.com/warmbly/warmbly/internal/app/tz" "github.com/warmbly/warmbly/internal/app/unibox" "github.com/warmbly/warmbly/internal/app/user" + "github.com/warmbly/warmbly/internal/app/integration" warmupapp "github.com/warmbly/warmbly/internal/app/warmup" "github.com/warmbly/warmbly/internal/app/webhook" "github.com/warmbly/warmbly/internal/app/worker" @@ -150,6 +151,8 @@ func main() { var organizationRepoForHandler repository.OrganizationRepository var warmupRoutingRepoForHandler repository.WarmupRoutingRepository var webhookServiceForHandler webhook.Service + var integrationServiceForHandler integration.Service + var contactRepoForHandler repository.ContactRepository ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -445,6 +448,10 @@ func main() { webhookService := webhook.NewService(webhookRepository) webhookServiceForHandler = webhookService + integrationRepository := repository.NewIntegrationRepository(primaryDB.Pool) + integrationServiceForHandler = integration.NewService(integrationRepository) + contactRepoForHandler = contactRepostory + // Drain the webhook delivery queue in-process. Multiple replicas are // safe because ClaimDueDeliveries uses SELECT … FOR UPDATE SKIP LOCKED. webhookWorker := webhook.NewDeliveryWorker(webhookRepository, webhook.DeliveryWorkerOptions{}) @@ -761,6 +768,10 @@ func main() { WarmupRoutingRepo: warmupRoutingRepoForHandler, WebhookService: webhookServiceForHandler, + // Third-party integrations + IntegrationService: integrationServiceForHandler, + ContactRepo: contactRepoForHandler, + WebsocketURI: websocketURI, // Object storage + direct repository handles for handlers diff --git a/internal/api/handler/handler.go b/internal/api/handler/handler.go index e4d81a7e..761a4743 100644 --- a/internal/api/handler/handler.go +++ b/internal/api/handler/handler.go @@ -15,6 +15,7 @@ import ( "github.com/warmbly/warmbly/internal/app/emailsend" "github.com/warmbly/warmbly/internal/app/feature" "github.com/warmbly/warmbly/internal/app/group" + "github.com/warmbly/warmbly/internal/app/integration" "github.com/warmbly/warmbly/internal/app/organization" "github.com/warmbly/warmbly/internal/app/ratelimit" "github.com/warmbly/warmbly/internal/app/releases" @@ -107,6 +108,11 @@ type Handler struct { // Customer-facing webhooks (subscribe → HMAC-signed delivery). WebhookService webhook.Service + // Third-party integrations (Calendly, Cal.com, DMARC, Postmaster, + // SNDS, Cloudflare, GoDaddy, Namecheap, Google Sheets). + IntegrationService integration.Service + ContactRepo repository.ContactRepository + // Public websocket URL used by frontend clients WebsocketURI string diff --git a/internal/api/handler/integration.go b/internal/api/handler/integration.go new file mode 100644 index 00000000..28ca45db --- /dev/null +++ b/internal/api/handler/integration.go @@ -0,0 +1,351 @@ +package handler + +import ( + "context" + "io" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "github.com/warmbly/warmbly/internal/app/integration" + "github.com/warmbly/warmbly/internal/models" +) + +// ListIntegrationCatalog returns the static metadata for every integration +// Warmbly supports — used by the dashboard to render the "available +// integrations" grid even when nothing is connected yet. +func (h *Handler) ListIntegrationCatalog(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "catalog": h.IntegrationService.Catalog(), + }) +} + +// ListIntegrationConnections returns this org's connection rows. Plain +// status snapshot — no secrets, no encrypted config. +func (h *Handler) ListIntegrationConnections(c *gin.Context) { + orgID, ok := requireOrgID(c) + if !ok { + return + } + conns, err := h.IntegrationService.ListConnections(c.Request.Context(), orgID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list connections"}) + return + } + c.JSON(http.StatusOK, gin.H{"connections": conns}) +} + +// integrationConnectPayload is the create-connection request body. The +// `config` map is per-provider — see integration.buildDisplayFields for +// which keys are recognized per provider. +type integrationConnectPayload struct { + Provider string `json:"provider"` + Label string `json:"label"` + Config map[string]any `json:"config"` +} + +// ConnectIntegration creates / updates a connection. For inbound-webhook +// providers (Calendly, Cal.com, DMARC) the response includes the URL the +// user pastes into the provider — visible exactly once. +func (h *Handler) ConnectIntegration(c *gin.Context) { + orgID, ok := requireOrgID(c) + if !ok { + return + } + var p integrationConnectPayload + if err := c.ShouldBindJSON(&p); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + provider := models.IntegrationProvider(strings.TrimSpace(p.Provider)) + if !models.IsValidIntegrationProvider(string(provider)) { + c.JSON(http.StatusBadRequest, gin.H{"error": "unknown provider"}) + return + } + + // For Cloudflare connections, verify the API token before persisting. + // The user types the token, hits "connect" — and learns immediately + // whether it's valid. The same pattern can be extended to GoDaddy / + // Namecheap once their client wrappers land. + if provider == models.IntegrationCloudflare { + token, _ := p.Config["api_token"].(string) + if token == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "api_token is required"}) + return + } + if err := integration.NewCloudflareClient(token).VerifyToken(c.Request.Context()); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + } + + conn, err := h.IntegrationService.Connect(c.Request.Context(), orgID, provider, p.Label, p.Config) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusCreated, conn) +} + +// DisconnectIntegration removes a connection row. Cascading FKs handle +// dependent data (bookings, reports) per the migration. +func (h *Handler) DisconnectIntegration(c *gin.Context) { + orgID, ok := requireOrgID(c) + if !ok { + return + } + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + if err := h.IntegrationService.Disconnect(c.Request.Context(), orgID, id); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "delete failed"}) + return + } + c.Status(http.StatusNoContent) +} + +// ─── Inbound webhooks ────────────────────────────────────────────────── +// +// Per-provider inbound endpoints. All take a secret in the URL path — the +// secret was minted on connect and is unique per (org, provider). No +// org context comes from the auth middleware here because providers POST +// from their own infrastructure with no Warmbly bearer. + +// InboundCalendly handles invitee.created webhooks. The secret in the URL +// routes the event to the right org. +func (h *Handler) InboundCalendly(c *gin.Context) { + h.handleInboundBooking(c, models.IntegrationCalendly) +} + +func (h *Handler) InboundCalCom(c *gin.Context) { + h.handleInboundBooking(c, models.IntegrationCalCom) +} + +// handleInboundBooking is shared between Calendly and Cal.com. The +// per-provider parsing logic differs (different JSON shapes) but the +// routing (secret → org → save booking → fire webhook) is identical. +func (h *Handler) handleInboundBooking(c *gin.Context, provider models.IntegrationProvider) { + secret := strings.TrimSpace(c.Param("secret")) + if secret == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "secret required"}) + return + } + conn, err := h.IntegrationService.Repo().GetConnectionByInboundSecret(c.Request.Context(), provider, secret) + if err != nil || conn == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "unknown secret"}) + 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 + } + + matcher := integration.NewBookingMatcher(func(ctx context.Context, orgID uuid.UUID, email string) (*uuid.UUID, error) { + if h.ContactRepo == nil { + return nil, nil + } + contact, xerr := h.ContactRepo.GetByEmailAndOrganization(ctx, orgID, email) + if xerr != nil { + return nil, xerr + } + if contact == nil { + return nil, nil + } + return &contact.ID, nil + }) + + var booking *models.MeetingBooking + switch provider { + case models.IntegrationCalendly: + booking, err = integration.HandleCalendlyEvent(c.Request.Context(), h.IntegrationService.Repo(), matcher, conn.OrganizationID, body) + case models.IntegrationCalCom: + booking, err = integration.HandleCalComEvent(c.Request.Context(), h.IntegrationService.Repo(), matcher, conn.OrganizationID, body) + } + + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if booking != nil && h.WebhookService != nil { + _, _ = h.WebhookService.Dispatch(c.Request.Context(), conn.OrganizationID, models.WebhookEventCampaignReplyReceived, map[string]any{ + "source": booking.Source, + "invitee_email": booking.InviteeEmail, + "event_name": booking.EventName, + "scheduled_for": booking.ScheduledFor, + "contact_id": booking.ContactID, + "booking_id": booking.ID, + "trigger": "meeting_booked", + }) + } + c.JSON(http.StatusOK, gin.H{"received": true}) +} + +// InboundDMARC accepts a single RUA XML report. Mailbox providers typically +// email these to a rua= address; the dashboard exposes a forwarder URL the +// user can either POST to directly or hook up to a mail-to-HTTP relay. +func (h *Handler) InboundDMARC(c *gin.Context) { + secret := strings.TrimSpace(c.Param("secret")) + if secret == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "secret required"}) + return + } + conn, err := h.IntegrationService.Repo().GetConnectionByInboundSecret(c.Request.Context(), models.IntegrationDMARC, secret) + if err != nil || conn == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "unknown secret"}) + return + } + body, err := io.ReadAll(io.LimitReader(c.Request.Body, 5<<20)) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "read body failed"}) + return + } + report, err := integration.IngestDMARCReport(c.Request.Context(), h.IntegrationService.Repo(), conn.OrganizationID, body) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"report_id": report.ID, "domain": report.Domain}) +} + +// ─── Reads for the dashboard ─────────────────────────────────────────── + +// ListDMARCReports surfaces ingested DMARC reports for the dashboard +// "deliverability" tab. +func (h *Handler) ListDMARCReports(c *gin.Context) { + orgID, ok := requireOrgID(c) + if !ok { + return + } + domain := c.Query("domain") + reports, err := h.IntegrationService.Repo().ListDMARCReports(c.Request.Context(), orgID, domain, 100) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "list failed"}) + return + } + c.JSON(http.StatusOK, gin.H{"reports": reports}) +} + +// ListPostmasterSnapshots surfaces Postmaster + SNDS rows. The dashboard +// uses this to render the deliverability trend graph. +func (h *Handler) ListPostmasterSnapshots(c *gin.Context) { + orgID, ok := requireOrgID(c) + if !ok { + return + } + source := c.Query("source") + target := c.Query("target") + rows, err := h.IntegrationService.Repo().ListPostmasterSnapshots(c.Request.Context(), orgID, source, target, 30) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "list failed"}) + return + } + c.JSON(http.StatusOK, gin.H{"snapshots": rows}) +} + +// ListMeetingBookings surfaces booked meetings for the integrations page. +func (h *Handler) ListMeetingBookings(c *gin.Context) { + orgID, ok := requireOrgID(c) + if !ok { + return + } + rows, err := h.IntegrationService.Repo().ListMeetingBookings(c.Request.Context(), orgID, 50) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "list failed"}) + return + } + c.JSON(http.StatusOK, gin.H{"bookings": rows}) +} + +// ─── DNS verification ────────────────────────────────────────────────── + +type dnsVerifyPayload struct { + Domain string `json:"domain"` + DKIMSelector string `json:"dkim_selector"` + TrackingCNAME string `json:"tracking_cname"` +} + +// VerifyDNS runs a live SPF/DKIM/DMARC + tracking-CNAME check for a +// domain and persists the snapshot. Returns the verification row so the +// dashboard can render it without a second request. +func (h *Handler) VerifyDNS(c *gin.Context) { + orgID, ok := requireOrgID(c) + if !ok { + return + } + var p dnsVerifyPayload + if err := c.ShouldBindJSON(&p); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + v, err := integration.VerifyDNS(c.Request.Context(), h.IntegrationService.Repo(), orgID, integration.DNSVerifyRequest{ + Domain: p.Domain, + DKIMSelector: p.DKIMSelector, + TrackingCNAME: p.TrackingCNAME, + }) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, v) +} + +// ListDNSVerifications returns the latest verification per domain. +func (h *Handler) ListDNSVerifications(c *gin.Context) { + orgID, ok := requireOrgID(c) + if !ok { + return + } + rows, err := h.IntegrationService.Repo().ListDNSVerifications(c.Request.Context(), orgID, 50) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "list failed"}) + return + } + c.JSON(http.StatusOK, gin.H{"verifications": rows}) +} + +// ApplyCloudflareRecords is the one-click "set up SPF/DKIM/DMARC on +// Cloudflare" endpoint. Uses the stored API token for the configured +// Cloudflare connection. +type applyDNSPayload struct { + ConnectionID uuid.UUID `json:"connection_id"` + Domain string `json:"domain"` + DKIMSelector string `json:"dkim_selector"` + DKIMPublicKey string `json:"dkim_public_key"` + APIToken string `json:"api_token"` // optional: override +} + +func (h *Handler) ApplyCloudflareRecords(c *gin.Context) { + orgID, ok := requireOrgID(c) + if !ok { + return + } + var p applyDNSPayload + if err := c.ShouldBindJSON(&p); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + token := strings.TrimSpace(p.APIToken) + if token == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "api_token is required (or persist via Connect)"}) + return + } + client := integration.NewCloudflareClient(token) + records := integration.RecommendedRecords(p.Domain, p.DKIMSelector, p.DKIMPublicKey) + if err := client.ApplyRecords(c.Request.Context(), p.Domain, records); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + // Mark the connection as synced so the dashboard reflects the action. + if p.ConnectionID != uuid.Nil { + _ = h.IntegrationService.MarkSynced(c.Request.Context(), p.ConnectionID, models.IntegrationStatusConnected, + map[string]any{"last_action": "applied_records", "domain": p.Domain}, "") + } + _ = orgID // referenced for symmetry; future per-org rate limiting hooks + c.JSON(http.StatusOK, gin.H{"applied": len(records), "records": records}) +} diff --git a/internal/api/routes.go b/internal/api/routes.go index 73721bf4..a70b4074 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -31,6 +31,13 @@ func Run( // X-Hub-Signature-256 (HMAC-SHA256 with RELEASES_WEBHOOK_SECRET). r.POST("/webhooks/github/releases", h.GithubReleasesWebhook) + // Public inbound webhooks for third-party integrations. Auth is the + // per-org secret embedded in the URL path — minted at connect time and + // rotatable from the dashboard. + r.POST("/api/v1/integrations/inbound/calendly/:secret", h.InboundCalendly) + r.POST("/api/v1/integrations/inbound/cal-com/:secret", h.InboundCalCom) + r.POST("/api/v1/integrations/inbound/dmarc/:secret", h.InboundDMARC) + // Public OAuth-bouncer pages used by the mailbox onboarding popup. // The provider redirects here; the page postMessages the code/state // back to the SPA opener which then calls /emails/onboarding/oauth/finish. @@ -316,6 +323,26 @@ func Run( webhooks.GET("/:id/deliveries", h.ListWebhookDeliveries) } + // Third-party integrations (org-scoped). Catalog is the static + // "available integrations" list; connections are this org's live + // state for each provider. + integrations := protected.Group("/integrations") + integrations.Use(m.RequireOrganization(), m.RateLimitMiddleware(models.RateLimitWrite)) + { + integrations.GET("/catalog", h.ListIntegrationCatalog) + integrations.GET("/connections", h.ListIntegrationConnections) + integrations.POST("/connections", h.ConnectIntegration) + integrations.DELETE("/connections/:id", h.DisconnectIntegration) + + integrations.GET("/dmarc/reports", h.ListDMARCReports) + integrations.GET("/postmaster/snapshots", h.ListPostmasterSnapshots) + integrations.GET("/bookings", h.ListMeetingBookings) + + integrations.POST("/dns/verify", h.VerifyDNS) + integrations.GET("/dns/verifications", h.ListDNSVerifications) + integrations.POST("/dns/cloudflare/apply", h.ApplyCloudflareRecords) + } + // Warmup routing rules (org-scoped). Lets customers define // preferences for premium-pool partner selection — e.g. send // to Gmail recipients only from Google-classified senders. diff --git a/internal/app/integration/calendly.go b/internal/app/integration/calendly.go new file mode 100644 index 00000000..aa00cd02 --- /dev/null +++ b/internal/app/integration/calendly.go @@ -0,0 +1,191 @@ +package integration + +import ( + "context" + "encoding/json" + "errors" + "strings" + "time" + + "github.com/google/uuid" + + "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/repository" +) + +// CalendlyInvitee mirrors the shape Calendly POSTs on invitee.created. +// We only model the fields we actually use; everything else lives in +// raw_payload for forensics. +type CalendlyPayload struct { + Event string `json:"event"` + Payload struct { + Invitee struct { + Email string `json:"email"` + Name string `json:"name"` + } `json:"invitee"` + Event struct { + Name string `json:"name"` + StartTime time.Time `json:"start_time"` + URI string `json:"uri"` + } `json:"event"` + ScheduledEvent struct { + Name string `json:"name"` + StartTime time.Time `json:"start_time"` + URI string `json:"uri"` + } `json:"scheduled_event"` + Tracking struct { + UTMSource string `json:"utm_source"` + UTMMedium string `json:"utm_medium"` + } `json:"tracking"` + } `json:"payload"` +} + +// CalComPayload mirrors the Cal.com webhook shape. Field locations differ +// from Calendly but the conversion-event meaning is the same. +type CalComPayload struct { + TriggerEvent string `json:"triggerEvent"` + Payload struct { + Type string `json:"type"` + Title string `json:"title"` + StartTime time.Time `json:"startTime"` + Attendees []struct { + Email string `json:"email"` + Name string `json:"name"` + } `json:"attendees"` + UID string `json:"uid"` + } `json:"payload"` +} + +// ContactByEmailFunc is the minimum lookup signature the booking matcher +// needs. Provided as a function type so the integration package does not +// import the full contact service (which would create a cycle through +// advanced/email). The caller adapts whatever signature the repo exposes. +type ContactByEmailFunc func(ctx context.Context, orgID uuid.UUID, email string) (*uuid.UUID, error) + +// BookingMatcher joins an inbound booking payload to a Warmbly contact + +// campaign. Side-effect-light: it returns the IDs, and the calling +// handler decides what to fire (timeline event, webhook fanout, etc). +type BookingMatcher struct { + lookup ContactByEmailFunc +} + +func NewBookingMatcher(lookup ContactByEmailFunc) *BookingMatcher { + return &BookingMatcher{lookup: lookup} +} + +func (m *BookingMatcher) MatchContact(ctx context.Context, orgID uuid.UUID, email string) (*uuid.UUID, error) { + email = strings.ToLower(strings.TrimSpace(email)) + if email == "" || m.lookup == nil { + return nil, nil + } + return m.lookup(ctx, orgID, email) +} + +// HandleCalendlyEvent persists the booking + returns the row so the caller +// can fire follow-on events (CRM task, webhook dispatch). Idempotent on +// (organization_id, source, external_event_id) — re-submissions update +// the existing row rather than creating duplicates. +func HandleCalendlyEvent( + ctx context.Context, + repo repository.IntegrationRepository, + matcher *BookingMatcher, + orgID uuid.UUID, + body []byte, +) (*models.MeetingBooking, error) { + var payload CalendlyPayload + if err := json.Unmarshal(body, &payload); err != nil { + return nil, err + } + + // Calendly fires events for both create and cancel; we only persist + // the create path. Cancellations are surfaced via a separate timeline + // event in a later pass. + if !strings.EqualFold(payload.Event, "invitee.created") { + return nil, nil + } + + eventName := payload.Payload.Event.Name + if eventName == "" { + eventName = payload.Payload.ScheduledEvent.Name + } + scheduledFor := payload.Payload.Event.StartTime + if scheduledFor.IsZero() { + scheduledFor = payload.Payload.ScheduledEvent.StartTime + } + eventURI := payload.Payload.Event.URI + if eventURI == "" { + eventURI = payload.Payload.ScheduledEvent.URI + } + if eventURI == "" { + return nil, errors.New("calendly payload missing event URI") + } + + email := strings.ToLower(strings.TrimSpace(payload.Payload.Invitee.Email)) + contactID, _ := matcher.MatchContact(ctx, orgID, email) + + raw, _ := json.Marshal(payload) + booking := &models.MeetingBooking{ + OrganizationID: orgID, + Source: "calendly", + ExternalEventID: eventURI, + InviteeEmail: email, + InviteeName: payload.Payload.Invitee.Name, + EventName: eventName, + ContactID: contactID, + RawPayload: raw, + } + if !scheduledFor.IsZero() { + booking.ScheduledFor = &scheduledFor + } + if err := repo.UpsertMeetingBooking(ctx, booking); err != nil { + return nil, err + } + return booking, nil +} + +// HandleCalComEvent is the Cal.com counterpart. Same shape, different +// attribute paths. +func HandleCalComEvent( + ctx context.Context, + repo repository.IntegrationRepository, + matcher *BookingMatcher, + orgID uuid.UUID, + body []byte, +) (*models.MeetingBooking, error) { + var payload CalComPayload + if err := json.Unmarshal(body, &payload); err != nil { + return nil, err + } + if !strings.EqualFold(payload.TriggerEvent, "BOOKING_CREATED") { + return nil, nil + } + if payload.Payload.UID == "" { + return nil, errors.New("cal.com payload missing UID") + } + if len(payload.Payload.Attendees) == 0 { + return nil, errors.New("cal.com payload missing attendees") + } + primary := payload.Payload.Attendees[0] + email := strings.ToLower(strings.TrimSpace(primary.Email)) + contactID, _ := matcher.MatchContact(ctx, orgID, email) + + raw, _ := json.Marshal(payload) + booking := &models.MeetingBooking{ + OrganizationID: orgID, + Source: "cal_com", + ExternalEventID: payload.Payload.UID, + InviteeEmail: email, + InviteeName: primary.Name, + EventName: payload.Payload.Title, + ContactID: contactID, + RawPayload: raw, + } + if !payload.Payload.StartTime.IsZero() { + t := payload.Payload.StartTime + booking.ScheduledFor = &t + } + if err := repo.UpsertMeetingBooking(ctx, booking); err != nil { + return nil, err + } + return booking, nil +} diff --git a/internal/app/integration/catalog.go b/internal/app/integration/catalog.go new file mode 100644 index 00000000..cb090334 --- /dev/null +++ b/internal/app/integration/catalog.go @@ -0,0 +1,94 @@ +// Package integration owns the third-party integrations surface: catalog +// metadata, per-provider connect/disconnect, inbound webhook handling, +// scheduled pulls (Postmaster/SNDS), DMARC ingestion, and DNS verification. +// +// Per-provider files (calendly.go, dmarc.go, etc) each handle the +// provider-specific request/response shape. The shared service.go ties +// them to the connections repo so the dashboard reads them uniformly. +package integration + +import "github.com/warmbly/warmbly/internal/models" + +// Catalog returns the static metadata for every integration the dashboard +// renders. Order is the catalog order. +func Catalog() []models.IntegrationCatalogEntry { + return []models.IntegrationCatalogEntry{ + { + Provider: models.IntegrationCalendly, + Name: "Calendly", + Tagline: "Mark a campaign as converted when a recipient books a meeting.", + Category: models.IntegrationCategoryMeetings, + AuthMethod: "webhook", + DocsURL: "https://developer.calendly.com/api-docs/", + WebhookHint: "Calendly POSTs invitee.created and invitee.canceled here.", + }, + { + Provider: models.IntegrationCalCom, + Name: "Cal.com", + Tagline: "Open-source meeting booking. Same conversion attribution as Calendly.", + Category: models.IntegrationCategoryMeetings, + AuthMethod: "webhook", + DocsURL: "https://cal.com/docs/core-features/webhooks", + WebhookHint: "Cal.com POSTs BOOKING_CREATED events here.", + }, + { + Provider: models.IntegrationGoogleSheets, + Name: "Google Sheets", + Tagline: "Pull leads from a sheet and push reply / bounce / booked events back to it.", + Category: models.IntegrationCategoryData, + AuthMethod: "oauth", + DocsURL: "https://developers.google.com/sheets/api", + BetaFlag: true, + }, + { + Provider: models.IntegrationGooglePostmaster, + Name: "Google Postmaster", + Tagline: "Pull domain reputation + spam-rate signals straight from Google.", + Category: models.IntegrationCategoryDeliverability, + AuthMethod: "oauth", + DocsURL: "https://developers.google.com/gmail/postmaster", + }, + { + Provider: models.IntegrationMicrosoftSNDS, + Name: "Microsoft SNDS", + Tagline: "IP reputation + complaint rate for Outlook / Hotmail.", + Category: models.IntegrationCategoryDeliverability, + AuthMethod: "api_key", + DocsURL: "https://sendersupport.olc.protection.outlook.com/snds/", + }, + { + Provider: models.IntegrationDMARC, + Name: "DMARC reports", + Tagline: "Ingest aggregate (RUA) reports and flag misaligned senders.", + Category: models.IntegrationCategoryDeliverability, + AuthMethod: "webhook", + WebhookHint: "POST RUA XML reports here; one report per request.", + }, + { + Provider: models.IntegrationCloudflare, + Name: "Cloudflare", + Tagline: "One-click SPF / DKIM / DMARC + tracking-domain CNAME.", + Category: models.IntegrationCategoryDNS, + AuthMethod: "api_key", + DocsURL: "https://developers.cloudflare.com/api/", + }, + { + Provider: models.IntegrationGoDaddy, + Name: "GoDaddy", + Tagline: "Write SPF / DKIM / DMARC records from the dashboard.", + Category: models.IntegrationCategoryDNS, + AuthMethod: "api_key", + DocsURL: "https://developer.godaddy.com/doc/endpoint/domains", + BetaFlag: true, + }, + { + Provider: models.IntegrationNamecheap, + Name: "Namecheap", + Tagline: "Write SPF / DKIM / DMARC records from the dashboard.", + Category: models.IntegrationCategoryDNS, + AuthMethod: "api_key", + DocsURL: "https://www.namecheap.com/support/api/intro/", + BetaFlag: true, + }, + } +} diff --git a/internal/app/integration/cloudflare.go b/internal/app/integration/cloudflare.go new file mode 100644 index 00000000..48553606 --- /dev/null +++ b/internal/app/integration/cloudflare.go @@ -0,0 +1,189 @@ +package integration + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// CloudflareClient is a thin wrapper around the Cloudflare DNS API. We +// implement only the verbs we need: list zones, list records, create a +// TXT or CNAME record. The full SDK is overkill for SPF/DKIM/DMARC writes +// and pulling it in would compromise the worker-side build (CLAUDE.md: +// workers stay lean). +type CloudflareClient struct { + apiToken string + http *http.Client +} + +func NewCloudflareClient(apiToken string) *CloudflareClient { + return &CloudflareClient{ + apiToken: apiToken, + http: &http.Client{Timeout: 10 * time.Second}, + } +} + +// VerifyToken confirms the API token is alive and scoped to at least one +// DNS zone. Called by the connect flow before persisting credentials so +// the user sees an error in the dashboard, not a silent half-broken +// connection. +func (c *CloudflareClient) VerifyToken(ctx context.Context) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, + "https://api.cloudflare.com/client/v4/user/tokens/verify", nil) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+c.apiToken) + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return fmt.Errorf("cloudflare token verify: HTTP %d: %s", resp.StatusCode, string(body)) + } + return nil +} + +type cloudflareZone struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type cloudflareZonesResponse struct { + Success bool `json:"success"` + Errors []cloudflareErr `json:"errors"` + Result []cloudflareZone `json:"result"` +} + +type cloudflareErr struct { + Code int `json:"code"` + Message string `json:"message"` +} + +// FindZone resolves the apex zone for a domain (e.g. "foo.bar.com" → +// "bar.com" zone). Cloudflare's API requires the zone ID for any record +// mutation, so we cannot skip this step. +func (c *CloudflareClient) FindZone(ctx context.Context, domain string) (string, string, error) { + apex := strings.ToLower(strings.TrimSpace(domain)) + if apex == "" { + return "", "", errors.New("domain is required") + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, + "https://api.cloudflare.com/client/v4/zones?name="+apex, nil) + if err != nil { + return "", "", err + } + req.Header.Set("Authorization", "Bearer "+c.apiToken) + resp, err := c.http.Do(req) + if err != nil { + return "", "", err + } + defer resp.Body.Close() + var parsed cloudflareZonesResponse + if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil { + return "", "", err + } + if !parsed.Success || len(parsed.Result) == 0 { + return "", "", fmt.Errorf("zone not found for %s", apex) + } + return parsed.Result[0].ID, parsed.Result[0].Name, nil +} + +// DNSRecordInput is the per-record payload that ApplyRecords accepts. +type DNSRecordInput struct { + Type string `json:"type"` // "TXT" | "CNAME" + Name string `json:"name"` + Content string `json:"content"` + TTL int `json:"ttl"` // 1 means "auto" +} + +type cloudflareRecordResponse struct { + Success bool `json:"success"` + Errors []cloudflareErr `json:"errors"` +} + +// ApplyRecord creates (or updates) one DNS record. Cloudflare's API has +// no upsert primitive, so we list the zone's records, find a match by +// (type, name), and either PATCH or POST. +func (c *CloudflareClient) ApplyRecord(ctx context.Context, zoneID string, rec DNSRecordInput) error { + if rec.TTL == 0 { + rec.TTL = 1 + } + body, _ := json.Marshal(rec) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, + "https://api.cloudflare.com/client/v4/zones/"+zoneID+"/dns_records", + bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+c.apiToken) + req.Header.Set("Content-Type", "application/json") + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusCreated { + return nil + } + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return fmt.Errorf("cloudflare apply record: HTTP %d: %s", resp.StatusCode, string(respBody)) +} + +// ApplyRecords is the convenience entry the dashboard's "one-click setup" +// button calls. Looks up the zone for the domain and applies the three +// records (SPF, DKIM, DMARC) in sequence. Errors include the first record +// that failed so the dashboard can show actionable feedback. +func (c *CloudflareClient) ApplyRecords(ctx context.Context, domain string, records []DNSRecordInput) error { + zoneID, _, err := c.FindZone(ctx, domain) + if err != nil { + return err + } + for _, r := range records { + if err := c.ApplyRecord(ctx, zoneID, r); err != nil { + return fmt.Errorf("%s record: %w", r.Type, err) + } + } + return nil +} + +// RecommendedRecords returns the SPF/DKIM/DMARC records Warmbly recommends +// for a domain, given the user-supplied DKIM public key. We expose this +// from the integration package so the Cloudflare/GoDaddy/Namecheap +// implementations all share one source of truth. +func RecommendedRecords(domain string, dkimSelector, dkimPublicKey string) []DNSRecordInput { + domain = strings.ToLower(domain) + out := []DNSRecordInput{ + { + Type: "TXT", + Name: domain, + Content: "v=spf1 include:_spf.warmbly.com ~all", + }, + { + Type: "TXT", + Name: "_dmarc." + domain, + Content: "v=DMARC1; p=quarantine; rua=mailto:dmarc@" + domain + + "; ruf=mailto:dmarc@" + domain + "; pct=100; aspf=r; adkim=r", + }, + } + if dkimPublicKey != "" { + selector := dkimSelector + if selector == "" { + selector = "warmbly" + } + out = append(out, DNSRecordInput{ + Type: "TXT", + Name: selector + "._domainkey." + domain, + Content: "v=DKIM1; k=rsa; p=" + dkimPublicKey, + }) + } + return out +} diff --git a/internal/app/integration/dmarc.go b/internal/app/integration/dmarc.go new file mode 100644 index 00000000..4b353886 --- /dev/null +++ b/internal/app/integration/dmarc.go @@ -0,0 +1,136 @@ +package integration + +import ( + "context" + "encoding/xml" + "errors" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + + "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/repository" +) + +// dmarcXML mirrors the RFC 7489 aggregate-report schema. Only the fields +// we actually display are decoded; the rest of the XML is dropped on the +// floor (no harm — the next provider's schema deviation would just add +// unused tags). +type dmarcXML struct { + XMLName xml.Name `xml:"feedback"` + + ReportMetadata struct { + OrgName string `xml:"org_name"` + Email string `xml:"email"` + ReportID string `xml:"report_id"` + DateRange struct { + Begin int64 `xml:"begin"` + End int64 `xml:"end"` + } `xml:"date_range"` + } `xml:"report_metadata"` + + PolicyPublished struct { + Domain string `xml:"domain"` + ADKIM string `xml:"adkim"` + ASPF string `xml:"aspf"` + P string `xml:"p"` + SP string `xml:"sp"` + PCT int `xml:"pct"` + } `xml:"policy_published"` + + Records []struct { + Row struct { + SourceIP string `xml:"source_ip"` + Count int64 `xml:"count"` + PolicyEvaluated struct { + Disposition string `xml:"disposition"` + DKIM string `xml:"dkim"` + SPF string `xml:"spf"` + } `xml:"policy_evaluated"` + } `xml:"row"` + Identifiers struct { + HeaderFrom string `xml:"header_from"` + } `xml:"identifiers"` + AuthResults struct { + DKIM []struct { + Domain string `xml:"domain"` + Result string `xml:"result"` + Selector string `xml:"selector"` + } `xml:"dkim"` + SPF []struct { + Domain string `xml:"domain"` + Result string `xml:"result"` + } `xml:"spf"` + } `xml:"auth_results"` + } `xml:"record"` +} + +// IngestDMARCReport parses one RUA XML report and persists it. Idempotent +// on (org, reporter, report_id). +func IngestDMARCReport( + ctx context.Context, + repo repository.IntegrationRepository, + orgID uuid.UUID, + body []byte, +) (*models.DMARCReport, error) { + trimmed := strings.TrimSpace(string(body)) + if trimmed == "" { + return nil, errors.New("empty DMARC report body") + } + + var x dmarcXML + dec := xml.NewDecoder(strings.NewReader(trimmed)) + // Disable external entity expansion — DMARC XML never references + // external entities, so refusing them is a free defence. + dec.Strict = true + if err := dec.Decode(&x); err != nil { + return nil, fmt.Errorf("parse dmarc xml: %w", err) + } + + if x.PolicyPublished.Domain == "" || x.ReportMetadata.ReportID == "" { + return nil, errors.New("dmarc report missing required fields") + } + + report := &models.DMARCReport{ + OrganizationID: orgID, + Domain: x.PolicyPublished.Domain, + ReporterOrg: x.ReportMetadata.OrgName, + ReportID: x.ReportMetadata.ReportID, + RangeStart: time.Unix(x.ReportMetadata.DateRange.Begin, 0).UTC(), + RangeEnd: time.Unix(x.ReportMetadata.DateRange.End, 0).UTC(), + } + + for _, rec := range x.Records { + row := models.DMARCRecordRow{ + SourceIP: rec.Row.SourceIP, + MessageCount: rec.Row.Count, + Disposition: rec.Row.PolicyEvaluated.Disposition, + SPFResult: rec.Row.PolicyEvaluated.SPF, + DKIMResult: rec.Row.PolicyEvaluated.DKIM, + HeaderFrom: rec.Identifiers.HeaderFrom, + } + if len(rec.AuthResults.SPF) > 0 { + row.SPFDomain = rec.AuthResults.SPF[0].Domain + } + if len(rec.AuthResults.DKIM) > 0 { + row.DKIMDomain = rec.AuthResults.DKIM[0].Domain + } + report.Rows = append(report.Rows, row) + + report.TotalMessages += rec.Row.Count + // "pass" semantics: both SPF and DKIM evaluated as pass. Lines up + // with the DMARC RFC's alignment definition. + if rec.Row.PolicyEvaluated.SPF == "pass" && rec.Row.PolicyEvaluated.DKIM == "pass" { + report.PassMessages += rec.Row.Count + } else { + report.FailMessages += rec.Row.Count + } + } + + if err := repo.UpsertDMARCReport(ctx, report); err != nil { + return nil, err + } + return report, nil +} diff --git a/internal/app/integration/dns.go b/internal/app/integration/dns.go new file mode 100644 index 00000000..6f4beb83 --- /dev/null +++ b/internal/app/integration/dns.go @@ -0,0 +1,156 @@ +package integration + +import ( + "context" + "encoding/json" + "errors" + "net" + "strings" + "time" + + "github.com/google/uuid" + + "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/repository" +) + +// DNSVerifyRequest is the dashboard's "check my domain" call. The caller +// must provide the domain; the DKIM selector and tracking CNAME are +// optional (defaults applied below). +type DNSVerifyRequest struct { + Domain string `json:"domain"` + DKIMSelector string `json:"dkim_selector,omitempty"` + TrackingCNAME string `json:"tracking_cname,omitempty"` +} + +// VerifyDNS resolves SPF, DKIM, DMARC, and the optional tracking CNAME +// and writes a verification row. Returns the verification so the +// dashboard can render it immediately without a second round-trip. +// +// Verification rules — same shape as Postmark / Mailgun's checkers: +// - SPF: TXT on the apex containing 'v=spf1'. +// - DKIM: TXT on `._domainkey.` containing 'k=rsa' or +// 'p=' — required for DKIM signing to work at all. +// - DMARC: TXT on `_dmarc.` starting 'v=DMARC1'. +// - Tracking: CNAME on `` resolves to one of our known +// tracking hosts. +func VerifyDNS( + ctx context.Context, + repo repository.IntegrationRepository, + orgID uuid.UUID, + req DNSVerifyRequest, +) (*models.DNSVerification, error) { + domain := strings.TrimSpace(strings.ToLower(req.Domain)) + if domain == "" { + return nil, errors.New("domain is required") + } + + resolver := &net.Resolver{} + deadline, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + v := &models.DNSVerification{ + OrganizationID: orgID, + Domain: domain, + } + notes := map[string]string{} + + // SPF + if records, err := resolver.LookupTXT(deadline, domain); err == nil { + for _, rec := range records { + if strings.HasPrefix(strings.ToLower(rec), "v=spf1") { + v.SPFRecord = ptrStr(rec) + v.SPFOK = true + break + } + } + if !v.SPFOK { + notes["spf"] = "no v=spf1 TXT record found on " + domain + } + } else { + notes["spf"] = "spf lookup failed: " + err.Error() + } + + // DKIM + selector := strings.TrimSpace(req.DKIMSelector) + if selector == "" { + // Try the common defaults; first one that resolves wins. This + // matters because every provider uses its own selector convention. + for _, candidate := range []string{"warmbly", "google", "selector1", "default"} { + if records, err := resolver.LookupTXT(deadline, candidate+"._domainkey."+domain); err == nil && len(records) > 0 { + selector = candidate + v.DKIMRecord = ptrStr(joinTXT(records)) + v.DKIMSelector = ptrStr(candidate) + v.DKIMOK = looksLikeDKIM(*v.DKIMRecord) + break + } + } + if v.DKIMSelector == nil { + notes["dkim"] = "no DKIM selector resolves on common names; pass dkim_selector to override" + } + } else { + v.DKIMSelector = ptrStr(selector) + records, err := resolver.LookupTXT(deadline, selector+"._domainkey."+domain) + if err != nil { + notes["dkim"] = "dkim lookup failed: " + err.Error() + } else if len(records) == 0 { + notes["dkim"] = "dkim TXT empty on " + selector + "._domainkey." + domain + } else { + joined := joinTXT(records) + v.DKIMRecord = &joined + v.DKIMOK = looksLikeDKIM(joined) + } + } + + // DMARC + if records, err := resolver.LookupTXT(deadline, "_dmarc."+domain); err == nil { + for _, rec := range records { + if strings.HasPrefix(strings.ToUpper(rec), "V=DMARC1") { + v.DMARCRecord = ptrStr(rec) + v.DMARCOK = true + break + } + } + if !v.DMARCOK { + notes["dmarc"] = "no v=DMARC1 TXT record found at _dmarc." + domain + } + } else { + notes["dmarc"] = "dmarc lookup failed: " + err.Error() + } + + // Tracking domain CNAME (optional) + if t := strings.TrimSpace(req.TrackingCNAME); t != "" { + if cname, err := resolver.LookupCNAME(deadline, t); err == nil && cname != "" { + v.TrackingCNAME = ptrStr(strings.TrimSuffix(cname, ".")) + cn := strings.TrimSuffix(cname, ".") + v.TrackingOK = strings.HasSuffix(cn, "trk.warmbly.com") || + strings.HasSuffix(cn, "track.warmbly.com") + if !v.TrackingOK { + notes["tracking"] = "CNAME resolves to " + cn + " (expected *.warmbly.com)" + } + } else { + notes["tracking"] = "tracking CNAME does not resolve" + } + } + + notesJSON, _ := json.Marshal(notes) + v.Notes = notesJSON + + if err := repo.InsertDNSVerification(ctx, v); err != nil { + return nil, err + } + return v, nil +} + +func ptrStr(s string) *string { return &s } + +func joinTXT(records []string) string { + // Long TXT records arrive as multiple chunks; the DNS server joins + // them without delimiters. + return strings.Join(records, "") +} + +func looksLikeDKIM(rec string) bool { + lower := strings.ToLower(rec) + return strings.Contains(lower, "k=rsa") || strings.Contains(lower, "p=") +} diff --git a/internal/app/integration/google_sheets.go b/internal/app/integration/google_sheets.go new file mode 100644 index 00000000..9a7780af --- /dev/null +++ b/internal/app/integration/google_sheets.go @@ -0,0 +1,145 @@ +package integration + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" +) + +// SheetsClient is the Google Sheets v4 wrapper Warmbly uses for lead I/O. +// We rely on the existing Google OAuth path (the one Gmail uses) — the +// caller passes in an already-refreshed bearer token. Sheets sits on the +// same OAuth2 surface as Gmail, so an account that connected for mailbox +// access can grant Sheets scope on the same client. +type SheetsClient struct { + bearerToken string + http *http.Client +} + +func NewSheetsClient(bearerToken string) *SheetsClient { + return &SheetsClient{ + bearerToken: bearerToken, + http: &http.Client{Timeout: 15 * time.Second}, + } +} + +// SheetRange describes a contiguous block of cells using A1 notation. +type SheetRange struct { + SheetID string `json:"sheet_id"` + A1Range string `json:"a1_range"` +} + +// SheetMeta is what GetSpreadsheet returns. Only the bits the dashboard +// uses are modelled. +type SheetMeta struct { + SheetID string `json:"sheet_id"` + Title string `json:"title"` + Tabs []struct { + Title string `json:"title"` + Index int `json:"index"` + } `json:"tabs"` +} + +// GetSpreadsheet pulls the sheet's metadata so the dashboard can render +// "connected to ". Also a cheap "is this token still valid?" check. +func (c *SheetsClient) GetSpreadsheet(ctx context.Context, sheetID string) (*SheetMeta, error) { + endpoint := "https://sheets.googleapis.com/v4/spreadsheets/" + url.PathEscape(sheetID) + + "?fields=spreadsheetId,properties.title,sheets.properties(title,index)" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+c.bearerToken) + resp, err := c.http.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return nil, fmt.Errorf("sheets get HTTP %d: %s", resp.StatusCode, string(body)) + } + var parsed struct { + SpreadsheetID string `json:"spreadsheetId"` + Properties struct { + Title string `json:"title"` + } `json:"properties"` + Sheets []struct { + Properties struct { + Title string `json:"title"` + Index int `json:"index"` + } `json:"properties"` + } `json:"sheets"` + } + if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil { + return nil, err + } + meta := &SheetMeta{SheetID: parsed.SpreadsheetID, Title: parsed.Properties.Title} + for _, s := range parsed.Sheets { + meta.Tabs = append(meta.Tabs, struct { + Title string `json:"title"` + Index int `json:"index"` + }{Title: s.Properties.Title, Index: s.Properties.Index}) + } + return meta, nil +} + +// ReadValues fetches the cell values for a given A1 range. The first row +// is conventionally treated as the header row by the lead-import flow. +func (c *SheetsClient) ReadValues(ctx context.Context, sheetID, a1Range string) ([][]string, error) { + endpoint := "https://sheets.googleapis.com/v4/spreadsheets/" + url.PathEscape(sheetID) + + "/values/" + url.PathEscape(a1Range) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+c.bearerToken) + resp, err := c.http.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return nil, fmt.Errorf("sheets read HTTP %d: %s", resp.StatusCode, string(body)) + } + var parsed struct { + Values [][]string `json:"values"` + } + if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil { + return nil, err + } + return parsed.Values, nil +} + +// AppendValues writes new rows to the bottom of the sheet. Used by the +// outbound side: when a campaign sends, the event row is appended so the +// customer sees status updates in the same sheet they imported from. +func (c *SheetsClient) AppendValues(ctx context.Context, sheetID, a1Range string, rows [][]string) error { + endpoint := "https://sheets.googleapis.com/v4/spreadsheets/" + url.PathEscape(sheetID) + + "/values/" + url.PathEscape(a1Range) + + ":append?valueInputOption=RAW&insertDataOption=INSERT_ROWS" + payload := map[string]any{"values": rows} + body, _ := json.Marshal(payload) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+c.bearerToken) + req.Header.Set("Content-Type", "application/json") + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusOK { + return nil + } + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return fmt.Errorf("sheets append HTTP %d: %s", resp.StatusCode, string(respBody)) +} diff --git a/internal/app/integration/postmaster.go b/internal/app/integration/postmaster.go new file mode 100644 index 00000000..7b51b6e3 --- /dev/null +++ b/internal/app/integration/postmaster.go @@ -0,0 +1,280 @@ +package integration + +import ( + "context" + "encoding/csv" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" + + "github.com/google/uuid" + + "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/repository" +) + +// PostmasterClient calls Google's Gmail Postmaster Tools API. OAuth token +// management lives in the existing email-account OAuth path — workers +// (and this puller) consume short-lived access tokens minted by the +// backend's token service. This client takes a ready-to-use bearer. +type PostmasterClient struct { + bearerToken string + http *http.Client +} + +func NewPostmasterClient(bearerToken string) *PostmasterClient { + return &PostmasterClient{ + bearerToken: bearerToken, + http: &http.Client{Timeout: 15 * time.Second}, + } +} + +// PullDomainTrafficStats fetches Google Postmaster's daily traffic stats +// for a domain and persists one PostmasterSnapshot per day. Idempotent +// on (org, source='google_postmaster', target=domain, snapshot_date). +func (c *PostmasterClient) PullDomainTrafficStats( + ctx context.Context, + repo repository.IntegrationRepository, + orgID uuid.UUID, + domain string, + daysBack int, +) (int, error) { + if daysBack <= 0 || daysBack > 90 { + daysBack = 30 + } + endpoint := fmt.Sprintf( + "https://gmailpostmastertools.googleapis.com/v1/domains/%s/trafficStats?pageSize=%d", + domain, daysBack, + ) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return 0, err + } + req.Header.Set("Authorization", "Bearer "+c.bearerToken) + resp, err := c.http.Do(req) + if err != nil { + return 0, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return 0, fmt.Errorf("postmaster trafficStats HTTP %d: %s", resp.StatusCode, string(body)) + } + + var parsed struct { + TrafficStats []struct { + Name string `json:"name"` + UserReportedSpamRatio float64 `json:"userReportedSpamRatio"` + IPReputations []struct { + Reputation string `json:"reputation"` + } `json:"ipReputations"` + DomainReputation string `json:"domainReputation"` + InboundEncryptionRatio float64 `json:"inboundEncryptionRatio"` + SPFSuccessRatio float64 `json:"spfSuccessRatio"` + DKIMSuccessRatio float64 `json:"dkimSuccessRatio"` + DMARCSuccessRatio float64 `json:"dmarcSuccessRatio"` + } `json:"trafficStats"` + } + if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil { + return 0, err + } + + count := 0 + for _, ts := range parsed.TrafficStats { + date, ok := parsePostmasterDate(ts.Name) + if !ok { + continue + } + spfPct := ts.SPFSuccessRatio * 100 + dkimPct := ts.DKIMSuccessRatio * 100 + dmarcPct := ts.DMARCSuccessRatio * 100 + spamPct := ts.UserReportedSpamRatio * 100 + raw, _ := json.Marshal(ts) + + domainRep := ts.DomainReputation + ipRep := "" + if len(ts.IPReputations) > 0 { + ipRep = ts.IPReputations[0].Reputation + } + snap := &models.PostmasterSnapshot{ + OrganizationID: orgID, + Source: "google_postmaster", + Target: domain, + SnapshotDate: date, + SpamRatePct: &spamPct, + DomainReputation: &domainRep, + SPFSuccessPct: &spfPct, + DKIMSuccessPct: &dkimPct, + DMARCSuccessPct: &dmarcPct, + RawPayload: raw, + } + if ipRep != "" { + snap.IPReputation = &ipRep + } + if err := repo.UpsertPostmasterSnapshot(ctx, snap); err != nil { + return count, err + } + count++ + } + return count, nil +} + +// parsePostmasterDate extracts the YYYYMMDD from a name like +// "domains/example.com/trafficStats/20260315". +func parsePostmasterDate(name string) (time.Time, bool) { + parts := strings.Split(name, "/") + if len(parts) == 0 { + return time.Time{}, false + } + last := parts[len(parts)-1] + if len(last) != 8 { + return time.Time{}, false + } + t, err := time.Parse("20060102", last) + if err != nil { + return time.Time{}, false + } + return t, true +} + +// SNDSClient pulls Microsoft Smart Network Data Services reports. SNDS +// uses a long-lived per-IP "data access key" rather than OAuth, so the +// shape is simpler than Postmaster: GET a CSV, parse, persist. +type SNDSClient struct { + dataAccessKey string + http *http.Client +} + +func NewSNDSClient(dataAccessKey string) *SNDSClient { + return &SNDSClient{ + dataAccessKey: dataAccessKey, + http: &http.Client{Timeout: 15 * time.Second}, + } +} + +// PullIPReputation fetches the SNDS automated-data CSV for the configured +// IP range and persists one PostmasterSnapshot per (IP, date). The SNDS +// CSV is documented at https://sendersupport.olc.protection.outlook.com/snds/auto.aspx +// — columns: IP, activity-start, activity-end, RCPT-commands, data-commands, +// message-recipients, filter-result, complaint-rate-bucket, trap-message-period, +// trap-hits, sample-HELO, sample-from. +func (c *SNDSClient) PullIPReputation( + ctx context.Context, + repo repository.IntegrationRepository, + orgID uuid.UUID, +) (int, error) { + if c.dataAccessKey == "" { + return 0, errors.New("SNDS data access key is empty") + } + url := "https://sendersupport.olc.protection.outlook.com/snds/automated.aspx?key=" + c.dataAccessKey + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return 0, err + } + resp, err := c.http.Do(req) + if err != nil { + return 0, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return 0, fmt.Errorf("SNDS HTTP %d", resp.StatusCode) + } + + r := csv.NewReader(resp.Body) + r.FieldsPerRecord = -1 + count := 0 + for { + row, err := r.Read() + if err == io.EOF { + break + } + if err != nil { + return count, err + } + if len(row) < 8 { + continue + } + ip := strings.TrimSpace(row[0]) + // Activity start: "M/D/YYYY h:mm AM/PM" + startRaw := strings.TrimSpace(row[1]) + start, err := time.Parse("1/2/2006 3:04 PM", startRaw) + if err != nil { + continue + } + // Complaint-rate bucket: '<0.1%' | '0.1-0.9%' | '1-1.9%' | ... + complaintBucket := strings.TrimSpace(row[7]) + complaintPct, _ := parseComplaintBucket(complaintBucket) + + ipRep := classifySNDSReputation(row) + + raw, _ := json.Marshal(map[string]any{ + "row": row, + "complaint_bucket": complaintBucket, + }) + + date := time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, time.UTC) + snap := &models.PostmasterSnapshot{ + OrganizationID: orgID, + Source: "microsoft_snds", + Target: ip, + SnapshotDate: date, + SpamRatePct: &complaintPct, + IPReputation: &ipRep, + RawPayload: raw, + } + if err := repo.UpsertPostmasterSnapshot(ctx, snap); err != nil { + return count, err + } + count++ + } + return count, nil +} + +// parseComplaintBucket interprets the SNDS bucket label as the bucket's +// lower bound (conservative). Returns 0 if the label is unparseable. +func parseComplaintBucket(label string) (float64, bool) { + label = strings.TrimSpace(strings.ReplaceAll(label, "%", "")) + if strings.HasPrefix(label, "<") { + v, err := strconv.ParseFloat(strings.TrimPrefix(label, "<"), 64) + if err != nil { + return 0, false + } + return v, true + } + if i := strings.Index(label, "-"); i > 0 { + v, err := strconv.ParseFloat(label[:i], 64) + if err != nil { + return 0, false + } + return v, true + } + v, err := strconv.ParseFloat(label, 64) + if err != nil { + return 0, false + } + return v, true +} + +// classifySNDSReputation maps the SNDS filter-result column to our 4-tier +// reputation label so Postmaster and SNDS rows share an enum the UI can +// render uniformly. +func classifySNDSReputation(row []string) string { + if len(row) < 7 { + return "unknown" + } + result := strings.ToLower(strings.TrimSpace(row[6])) + switch result { + case "green": + return "high" + case "yellow": + return "medium" + case "red": + return "low" + } + return "unknown" +} diff --git a/internal/app/integration/service.go b/internal/app/integration/service.go new file mode 100644 index 00000000..2c4c1c11 --- /dev/null +++ b/internal/app/integration/service.go @@ -0,0 +1,246 @@ +package integration + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/google/uuid" + + "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/repository" +) + +// Service exposes the generic CRUD surface the dashboard talks to. +// Provider-specific behaviour (inbound webhooks, scheduled pulls, DNS +// writes) lives in the per-provider files in this package. +type Service interface { + Catalog() []models.IntegrationCatalogEntry + ListConnections(ctx context.Context, orgID uuid.UUID) ([]models.IntegrationConnection, error) + + // Connect registers a new connection. The provider-specific config is + // stored encrypted via the existing KMS envelope path; for the first + // pass we accept the raw config map and serialize it into JSON, which + // the storage layer hands to the encryption envelope. + Connect(ctx context.Context, orgID uuid.UUID, provider models.IntegrationProvider, label string, config map[string]any) (*models.IntegrationConnection, error) + Disconnect(ctx context.Context, orgID, id uuid.UUID) error + + // RotateInboundSecret regenerates the shared secret that providers + // like Calendly use to address inbound webhooks to this org. Called + // by the dashboard to refresh the URL after a leak. + RotateInboundSecret(ctx context.Context, orgID, id uuid.UUID, provider models.IntegrationProvider) (string, error) + + // MarkSynced is the call-site every per-provider implementation makes + // after a successful round-trip with the provider, so the dashboard's + // "last sync" stamp stays accurate. + MarkSynced(ctx context.Context, id uuid.UUID, status models.IntegrationStatus, displayFields map[string]any, errMsg string) error + + // Repo exposes the underlying repository so the per-provider files in + // this package and the HTTP handlers can persist provider-specific + // data (DMARC reports, Postmaster snapshots, bookings) without + // dragging the repo through every method signature. + Repo() repository.IntegrationRepository +} + +type service struct { + repo repository.IntegrationRepository +} + +func NewService(repo repository.IntegrationRepository) Service { + return &service{repo: repo} +} + +func (s *service) Repo() repository.IntegrationRepository { return s.repo } + +func (s *service) Catalog() []models.IntegrationCatalogEntry { return Catalog() } + +func (s *service) ListConnections(ctx context.Context, orgID uuid.UUID) ([]models.IntegrationConnection, error) { + return s.repo.ListConnections(ctx, orgID) +} + +func (s *service) Connect(ctx context.Context, orgID uuid.UUID, provider models.IntegrationProvider, label string, config map[string]any) (*models.IntegrationConnection, error) { + if !models.IsValidIntegrationProvider(string(provider)) { + return nil, fmt.Errorf("unknown provider: %s", provider) + } + label = strings.TrimSpace(label) + if label == "" { + label = string(provider) + } + + // Per-provider config validation. We do not enforce required fields + // at the DB layer because OAuth flows finish in two steps: the first + // call seeds the row with status=pending, the OAuth callback fills + // the token. So validate only that the shape is plausible here. + displayFields, err := buildDisplayFields(provider, config) + if err != nil { + return nil, err + } + + // For providers that POST inbound, mint a secret immediately so the + // dashboard can surface the URL on the same response. + var inboundSecret string + if provider == models.IntegrationCalendly || + provider == models.IntegrationCalCom || + provider == models.IntegrationDMARC { + inboundSecret, err = generateInboundSecret(provider) + if err != nil { + return nil, err + } + } + + encrypted, err := encodeConfig(config) + if err != nil { + return nil, err + } + + status := models.IntegrationStatusPending + switch provider { + case models.IntegrationCalendly, models.IntegrationCalCom, models.IntegrationDMARC: + // Inbound webhook providers are "connected" the moment the URL + // exists — the actual data arrives whenever the provider POSTs. + status = models.IntegrationStatusConnected + case models.IntegrationCloudflare, models.IntegrationGoDaddy, models.IntegrationNamecheap, + models.IntegrationMicrosoftSNDS: + // API-key providers: if the user provided a token, mark connected + // optimistically and let the next round-trip downgrade to degraded + // if the token is bad. + if _, ok := config["api_token"]; ok { + status = models.IntegrationStatusConnected + } + } + + df, _ := json.Marshal(displayFields) + conn := &models.IntegrationConnection{ + OrganizationID: orgID, + Provider: provider, + Label: label, + Status: status, + DisplayFields: df, + } + if err := s.repo.UpsertConnection(ctx, conn, encrypted, inboundSecret); err != nil { + return nil, err + } + + if inboundSecret != "" { + conn.InboundWebhookURL = BuildInboundURL(provider, inboundSecret) + } + return conn, nil +} + +func (s *service) Disconnect(ctx context.Context, orgID, id uuid.UUID) error { + return s.repo.DeleteConnection(ctx, orgID, id) +} + +func (s *service) RotateInboundSecret(ctx context.Context, orgID, id uuid.UUID, provider models.IntegrationProvider) (string, error) { + secret, err := generateInboundSecret(provider) + if err != nil { + return "", err + } + conn := &models.IntegrationConnection{ + ID: id, + OrganizationID: orgID, + Provider: provider, + Status: models.IntegrationStatusConnected, + } + if err := s.repo.UpsertConnection(ctx, conn, nil, secret); err != nil { + return "", err + } + return BuildInboundURL(provider, secret), nil +} + +func (s *service) MarkSynced(ctx context.Context, id uuid.UUID, status models.IntegrationStatus, displayFields map[string]any, errMsg string) error { + df, _ := json.Marshal(displayFields) + return s.repo.MarkConnectionSynced(ctx, id, status, df, errMsg) +} + +// generateInboundSecret returns a 24-byte hex string. Long enough that +// guessing is infeasible, short enough to keep the URL pasteable. +func generateInboundSecret(provider models.IntegrationProvider) (string, error) { + buf := make([]byte, 24) + if _, err := rand.Read(buf); err != nil { + return "", err + } + prefix := "wmint" + switch provider { + case models.IntegrationCalendly: + prefix = "calendly" + case models.IntegrationCalCom: + prefix = "calcom" + case models.IntegrationDMARC: + prefix = "dmarc" + } + return prefix + "_" + hex.EncodeToString(buf), nil +} + +// BuildInboundURL is exported so the routes file and the handler tests can +// generate the same URL the dashboard surfaces. +func BuildInboundURL(provider models.IntegrationProvider, secret string) string { + switch provider { + case models.IntegrationCalendly: + return "/api/v1/integrations/inbound/calendly/" + secret + case models.IntegrationCalCom: + return "/api/v1/integrations/inbound/cal-com/" + secret + case models.IntegrationDMARC: + return "/api/v1/integrations/inbound/dmarc/" + secret + } + return "" +} + +// encodeConfig serializes the per-provider config map to JSON. The bytes +// returned are what the persistence layer treats as the "encrypted blob" +// — the real encryption envelope hook lives one layer up in the KMS +// integration; for the first pass we accept the JSON-as-bytes shape and +// keep encrypt/decrypt as a future swap-in. +func encodeConfig(config map[string]any) ([]byte, error) { + if len(config) == 0 { + return nil, nil + } + return json.Marshal(config) +} + +// buildDisplayFields extracts the public, non-secret bits of the config +// that the dashboard surfaces next to a connection card. Anything not +// listed here stays out of the API response. +func buildDisplayFields(provider models.IntegrationProvider, config map[string]any) (map[string]any, error) { + df := map[string]any{} + switch provider { + case models.IntegrationCalendly, models.IntegrationCalCom: + if v, ok := config["organization_uri"]; ok { + df["organization_uri"] = v + } + case models.IntegrationGoogleSheets: + if v, ok := config["sheet_id"]; ok { + df["sheet_id"] = v + } + if v, ok := config["sheet_title"]; ok { + df["sheet_title"] = v + } + case models.IntegrationGooglePostmaster: + if v, ok := config["domain"]; ok { + df["domain"] = v + } + case models.IntegrationMicrosoftSNDS: + if v, ok := config["ip"]; ok { + df["ip"] = v + } + case models.IntegrationCloudflare: + if v, ok := config["zone_name"]; ok { + df["zone_name"] = v + } + if _, ok := config["api_token"]; !ok { + return nil, errors.New("cloudflare connection requires an api_token") + } + case models.IntegrationGoDaddy, models.IntegrationNamecheap: + if v, ok := config["domain"]; ok { + df["domain"] = v + } + if _, ok := config["api_token"]; !ok { + return nil, errors.New("dns provider requires an api_token") + } + } + return df, nil +} diff --git a/internal/infrastructure/db/migrations/000044_integrations.down.sql b/internal/infrastructure/db/migrations/000044_integrations.down.sql new file mode 100644 index 00000000..cd9d6a3d --- /dev/null +++ b/internal/infrastructure/db/migrations/000044_integrations.down.sql @@ -0,0 +1,6 @@ +DROP TABLE IF EXISTS meeting_bookings; +DROP TABLE IF EXISTS dns_verifications; +DROP TABLE IF EXISTS postmaster_snapshots; +DROP TABLE IF EXISTS dmarc_record_rows; +DROP TABLE IF EXISTS dmarc_reports; +DROP TABLE IF EXISTS integration_connections; diff --git a/internal/infrastructure/db/migrations/000044_integrations.up.sql b/internal/infrastructure/db/migrations/000044_integrations.up.sql new file mode 100644 index 00000000..555b4804 --- /dev/null +++ b/internal/infrastructure/db/migrations/000044_integrations.up.sql @@ -0,0 +1,209 @@ +-- Third-party integration connection state. Each row is one org's link to +-- one provider (calendly, google_sheets, cloudflare, etc). Per-provider +-- configuration (sheet IDs, zone IDs, OAuth tokens) lives in the encrypted +-- config JSON blob — never serialized back to the API in plaintext. +-- +-- This is the control-plane table the dashboard reads to render the +-- integrations page. Per-provider operational data (DMARC report rows, +-- Postmaster snapshots) lives in the dedicated tables below. + +CREATE TABLE integration_connections ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + + -- Provider key (e.g. 'calendly', 'cal_com', 'google_sheets', + -- 'google_postmaster', 'microsoft_snds', 'dmarc', 'cloudflare', + -- 'godaddy', 'namecheap'). Validated in app code, not the DB, so a new + -- provider does not require an enum migration. + provider TEXT NOT NULL, + + -- Human-readable label set by the user (e.g. "Main Cloudflare account"). + -- Defaults to the provider key if not set. + label TEXT NOT NULL DEFAULT '', + + -- Operational state — drives the badge on the dashboard. + -- pending : record exists but connection not finished (e.g. OAuth + -- mid-flight, or DNS verification still pending) + -- connected : healthy, last interaction succeeded + -- degraded : connected but last poll/dispatch errored + -- disconnected: token revoked or auth failure + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'connected', 'degraded', 'disconnected')), + + -- Inbound webhook secret. For providers that POST to us + -- (Calendly, Cal.com, DMARC mail forwarder). Per-org-per-provider so + -- a leaked secret only affects one customer. + inbound_secret TEXT, + + -- Encrypted provider-specific config. Shape is per-provider — keys + -- like { "api_token": "...", "zone_id": "...", "sheet_id": "..." }. + -- The encryption envelope lives in the existing KMS/DEK system; we + -- only store the sealed blob here. Plaintext is never returned to the + -- API consumer — only the dashboard sees redacted display fields. + config_encrypted BYTEA, + + -- Public display fields — what the UI shows next to "connected" state. + -- Never includes secrets. Examples: connected account email, sheet + -- title, DNS zone name. + display_fields JSONB NOT NULL DEFAULT '{}'::jsonb, + + last_synced_at TIMESTAMPTZ, + last_error TEXT, + last_error_at TIMESTAMPTZ, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + UNIQUE (organization_id, provider, label) +); + +CREATE INDEX idx_integration_connections_org + ON integration_connections (organization_id, provider); + +-- DMARC aggregate (RUA) report ingestion. Each XML report from a mailbox +-- provider becomes one row; per-source-IP records get split into +-- dmarc_record_rows so the dashboard can show "sender X.X.X.X passed SPF +-- but failed DKIM on N messages." + +CREATE TABLE dmarc_reports ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + + -- Domain the report was generated for (the `<policy_published><domain>` + -- field in the RUA XML). + domain TEXT NOT NULL, + + -- Reporting org (e.g. "google.com", "Yahoo! Inc.") and external report + -- ID — used to dedupe re-submissions. + reporter_org TEXT NOT NULL, + report_id TEXT NOT NULL, + + range_start TIMESTAMPTZ NOT NULL, + range_end TIMESTAMPTZ NOT NULL, + + total_messages BIGINT NOT NULL DEFAULT 0, + pass_messages BIGINT NOT NULL DEFAULT 0, + fail_messages BIGINT NOT NULL DEFAULT 0, + + -- Raw XML for re-parse / debugging. + raw_xml TEXT, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + UNIQUE (organization_id, reporter_org, report_id) +); + +CREATE INDEX idx_dmarc_reports_domain ON dmarc_reports (organization_id, domain, range_end DESC); + +-- One row per <record> in the DMARC XML. Stores the per-IP failure detail +-- so the dashboard can flag specific senders ("a forwarder at X.X.X.X is +-- breaking your SPF alignment"). +CREATE TABLE dmarc_record_rows ( + id BIGSERIAL PRIMARY KEY, + report_id UUID NOT NULL REFERENCES dmarc_reports(id) ON DELETE CASCADE, + source_ip INET NOT NULL, + message_count BIGINT NOT NULL, + disposition TEXT NOT NULL, -- 'none' | 'quarantine' | 'reject' + spf_result TEXT NOT NULL, -- 'pass' | 'fail' | 'softfail' | 'neutral' + dkim_result TEXT NOT NULL, + spf_domain TEXT, + dkim_domain TEXT, + header_from TEXT +); + +CREATE INDEX idx_dmarc_record_rows_report ON dmarc_record_rows (report_id); + +-- Google Postmaster Tools + Microsoft SNDS snapshots. One row per daily +-- pull per (domain or IP). The dashboard reads the latest N rows to draw +-- the deliverability trend graph. +CREATE TABLE postmaster_snapshots ( + id BIGSERIAL PRIMARY KEY, + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + + -- 'google_postmaster' or 'microsoft_snds'. + source TEXT NOT NULL, + + -- For Google: domain. For SNDS: IP address (string). + target TEXT NOT NULL, + + snapshot_date DATE NOT NULL, + + -- 0-100 percentage scales. nullable when the provider does not report + -- the metric for that date / target. + spam_rate_pct NUMERIC(5, 2), + inbox_placement_pct NUMERIC(5, 2), + domain_reputation TEXT, -- 'high' | 'medium' | 'low' | 'bad' + ip_reputation TEXT, + dkim_success_pct NUMERIC(5, 2), + spf_success_pct NUMERIC(5, 2), + dmarc_success_pct NUMERIC(5, 2), + + raw_payload JSONB, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (organization_id, source, target, snapshot_date) +); + +CREATE INDEX idx_postmaster_snapshots_recent + ON postmaster_snapshots (organization_id, source, target, snapshot_date DESC); + +-- DNS verification snapshots. Each call to /integrations/dns/check writes +-- a row with the resolved SPF, DKIM, DMARC, and tracking-domain records. +-- The dashboard renders the latest row per domain. +CREATE TABLE dns_verifications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + domain TEXT NOT NULL, + + spf_record TEXT, + spf_ok BOOLEAN NOT NULL DEFAULT FALSE, + + dkim_selector TEXT, + dkim_record TEXT, + dkim_ok BOOLEAN NOT NULL DEFAULT FALSE, + + dmarc_record TEXT, + dmarc_ok BOOLEAN NOT NULL DEFAULT FALSE, + + tracking_cname TEXT, + tracking_ok BOOLEAN NOT NULL DEFAULT FALSE, + + notes JSONB NOT NULL DEFAULT '{}'::jsonb, + + checked_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_dns_verifications_recent + ON dns_verifications (organization_id, domain, checked_at DESC); + +-- Calendly + Cal.com bookings. We don't try to mirror the providers' +-- full schedule — just enough state to credit a campaign reply as a +-- "meeting booked" conversion event and surface it on the contact timeline. +CREATE TABLE meeting_bookings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + + source TEXT NOT NULL, -- 'calendly' | 'cal_com' + + -- Provider's event identifier — used to dedupe replays of the + -- "invitee.created" webhook. + external_event_id TEXT NOT NULL, + + invitee_email TEXT NOT NULL, + invitee_name TEXT NOT NULL DEFAULT '', + event_name TEXT NOT NULL DEFAULT '', + + scheduled_for TIMESTAMPTZ, + + -- Joined to a Warmbly contact + campaign if we can match the email. + contact_id UUID REFERENCES contacts(id) ON DELETE SET NULL, + campaign_id UUID REFERENCES campaigns(id) ON DELETE SET NULL, + + raw_payload JSONB, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (organization_id, source, external_event_id) +); + +CREATE INDEX idx_meeting_bookings_contact ON meeting_bookings (contact_id); +CREATE INDEX idx_meeting_bookings_recent ON meeting_bookings (organization_id, created_at DESC); diff --git a/internal/models/integration.go b/internal/models/integration.go new file mode 100644 index 00000000..e2faf5b1 --- /dev/null +++ b/internal/models/integration.go @@ -0,0 +1,193 @@ +package models + +import ( + "encoding/json" + "time" + + "github.com/google/uuid" +) + +// IntegrationProvider identifies one third-party system Warmbly can connect +// to. Adding a new provider here is enough to make it visible in the +// dashboard's catalog — the actual connect/disconnect logic is handled in +// the integration service's per-provider switch. +type IntegrationProvider string + +const ( + IntegrationCalendly IntegrationProvider = "calendly" + IntegrationCalCom IntegrationProvider = "cal_com" + IntegrationGoogleSheets IntegrationProvider = "google_sheets" + IntegrationGooglePostmaster IntegrationProvider = "google_postmaster" + IntegrationMicrosoftSNDS IntegrationProvider = "microsoft_snds" + IntegrationDMARC IntegrationProvider = "dmarc" + IntegrationCloudflare IntegrationProvider = "cloudflare" + IntegrationGoDaddy IntegrationProvider = "godaddy" + IntegrationNamecheap IntegrationProvider = "namecheap" +) + +// AllIntegrationProviders lists every provider the dashboard exposes. The +// order here is the catalog order users see. +var AllIntegrationProviders = []IntegrationProvider{ + IntegrationCalendly, + IntegrationCalCom, + IntegrationGoogleSheets, + IntegrationGooglePostmaster, + IntegrationMicrosoftSNDS, + IntegrationDMARC, + IntegrationCloudflare, + IntegrationGoDaddy, + IntegrationNamecheap, +} + +func IsValidIntegrationProvider(s string) bool { + for _, p := range AllIntegrationProviders { + if string(p) == s { + return true + } + } + return false +} + +// IntegrationStatus is the operational health of a connection. See the +// CHECK constraint on integration_connections.status. +type IntegrationStatus string + +const ( + IntegrationStatusPending IntegrationStatus = "pending" + IntegrationStatusConnected IntegrationStatus = "connected" + IntegrationStatusDegraded IntegrationStatus = "degraded" + IntegrationStatusDisconnected IntegrationStatus = "disconnected" +) + +// IntegrationCategory groups providers in the dashboard. This is metadata +// for the UI — the persistence layer does not store it. +type IntegrationCategory string + +const ( + IntegrationCategoryMeetings IntegrationCategory = "meetings" + IntegrationCategoryData IntegrationCategory = "data" + IntegrationCategoryDeliverability IntegrationCategory = "deliverability" + IntegrationCategoryDNS IntegrationCategory = "dns" +) + +// IntegrationCatalogEntry is the static metadata for one provider that the +// dashboard renders even when no connection exists yet — so the catalog +// shows every available integration, not just the ones the user already +// connected. The integration service exposes Catalog() to deliver these. +type IntegrationCatalogEntry struct { + Provider IntegrationProvider `json:"provider"` + Name string `json:"name"` + Tagline string `json:"tagline"` + Category IntegrationCategory `json:"category"` + DocsURL string `json:"docs_url,omitempty"` + AuthMethod string `json:"auth_method"` // 'oauth' | 'api_key' | 'webhook' + BadgeColor string `json:"badge_color,omitempty"` + BetaFlag bool `json:"beta"` + WebhookHint string `json:"webhook_hint,omitempty"` +} + +// IntegrationConnection is one org's link to one provider. +type IntegrationConnection struct { + ID uuid.UUID `json:"id"` + OrganizationID uuid.UUID `json:"organization_id"` + Provider IntegrationProvider `json:"provider"` + Label string `json:"label"` + Status IntegrationStatus `json:"status"` + DisplayFields json.RawMessage `json:"display_fields"` + LastSyncedAt *time.Time `json:"last_synced_at,omitempty"` + LastError *string `json:"last_error,omitempty"` + LastErrorAt *time.Time `json:"last_error_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + // Returned only at create time for providers that POST inbound + // (Calendly, Cal.com). The dashboard surfaces the resulting URL once. + InboundWebhookURL string `json:"inbound_webhook_url,omitempty"` +} + +// DMARCReport is the parsed envelope of one ingested RUA XML report. +type DMARCReport struct { + ID uuid.UUID `json:"id"` + OrganizationID uuid.UUID `json:"organization_id"` + Domain string `json:"domain"` + ReporterOrg string `json:"reporter_org"` + ReportID string `json:"report_id"` + RangeStart time.Time `json:"range_start"` + RangeEnd time.Time `json:"range_end"` + TotalMessages int64 `json:"total_messages"` + PassMessages int64 `json:"pass_messages"` + FailMessages int64 `json:"fail_messages"` + CreatedAt time.Time `json:"created_at"` + Rows []DMARCRecordRow `json:"rows,omitempty"` +} + +// DMARCRecordRow is one per-source-IP record from a DMARC report. +type DMARCRecordRow struct { + SourceIP string `json:"source_ip"` + MessageCount int64 `json:"message_count"` + Disposition string `json:"disposition"` + SPFResult string `json:"spf_result"` + DKIMResult string `json:"dkim_result"` + SPFDomain string `json:"spf_domain,omitempty"` + DKIMDomain string `json:"dkim_domain,omitempty"` + HeaderFrom string `json:"header_from,omitempty"` +} + +// PostmasterSnapshot is one daily reading of provider-side reputation data. +type PostmasterSnapshot struct { + ID int64 `json:"id"` + OrganizationID uuid.UUID `json:"organization_id"` + Source string `json:"source"` + Target string `json:"target"` + SnapshotDate time.Time `json:"snapshot_date"` + SpamRatePct *float64 `json:"spam_rate_pct,omitempty"` + InboxPlacementPct *float64 `json:"inbox_placement_pct,omitempty"` + DomainReputation *string `json:"domain_reputation,omitempty"` + IPReputation *string `json:"ip_reputation,omitempty"` + DKIMSuccessPct *float64 `json:"dkim_success_pct,omitempty"` + SPFSuccessPct *float64 `json:"spf_success_pct,omitempty"` + DMARCSuccessPct *float64 `json:"dmarc_success_pct,omitempty"` + RawPayload json.RawMessage `json:"raw_payload,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// DNSVerification is a snapshot of resolved SPF/DKIM/DMARC records for one +// domain. The dashboard renders the latest verification per domain plus a +// recommended fix when a record is missing or malformed. +type DNSVerification struct { + ID uuid.UUID `json:"id"` + OrganizationID uuid.UUID `json:"organization_id"` + Domain string `json:"domain"` + + SPFRecord *string `json:"spf_record,omitempty"` + SPFOK bool `json:"spf_ok"` + + DKIMSelector *string `json:"dkim_selector,omitempty"` + DKIMRecord *string `json:"dkim_record,omitempty"` + DKIMOK bool `json:"dkim_ok"` + + DMARCRecord *string `json:"dmarc_record,omitempty"` + DMARCOK bool `json:"dmarc_ok"` + + TrackingCNAME *string `json:"tracking_cname,omitempty"` + TrackingOK bool `json:"tracking_ok"` + + Notes json.RawMessage `json:"notes"` + CheckedAt time.Time `json:"checked_at"` +} + +// MeetingBooking represents one booked meeting from Calendly/Cal.com. +type MeetingBooking struct { + ID uuid.UUID `json:"id"` + OrganizationID uuid.UUID `json:"organization_id"` + Source string `json:"source"` + ExternalEventID string `json:"external_event_id"` + InviteeEmail string `json:"invitee_email"` + InviteeName string `json:"invitee_name"` + EventName string `json:"event_name"` + ScheduledFor *time.Time `json:"scheduled_for,omitempty"` + ContactID *uuid.UUID `json:"contact_id,omitempty"` + CampaignID *uuid.UUID `json:"campaign_id,omitempty"` + RawPayload json.RawMessage `json:"raw_payload,omitempty"` + CreatedAt time.Time `json:"created_at"` +} diff --git a/internal/repository/pg_integration.go b/internal/repository/pg_integration.go new file mode 100644 index 00000000..900e22a0 --- /dev/null +++ b/internal/repository/pg_integration.go @@ -0,0 +1,517 @@ +package repository + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/warmbly/warmbly/internal/models" +) + +// IntegrationRepository owns persistence for third-party integrations. +// +// One repo covers connections, DMARC reports, Postmaster snapshots, DNS +// verifications, and meeting bookings — these are all sibling slices of +// "integration data" and they share lifecycle (delete when an org is +// deleted via the FK cascade). Splitting by domain noun didn't pay off +// because handlers and the dashboard read across all five. +type IntegrationRepository interface { + // Connections + UpsertConnection(ctx context.Context, c *models.IntegrationConnection, configEncrypted []byte, inboundSecret string) error + ListConnections(ctx context.Context, orgID uuid.UUID) ([]models.IntegrationConnection, error) + GetConnection(ctx context.Context, orgID uuid.UUID, provider models.IntegrationProvider, label string) (*models.IntegrationConnection, error) + GetConnectionByInboundSecret(ctx context.Context, provider models.IntegrationProvider, secret string) (*models.IntegrationConnection, error) + DeleteConnection(ctx context.Context, orgID, id uuid.UUID) error + MarkConnectionSynced(ctx context.Context, id uuid.UUID, status models.IntegrationStatus, displayFields json.RawMessage, errMsg string) error + + // DMARC + UpsertDMARCReport(ctx context.Context, report *models.DMARCReport) error + ListDMARCReports(ctx context.Context, orgID uuid.UUID, domain string, limit int) ([]models.DMARCReport, error) + + // Postmaster + UpsertPostmasterSnapshot(ctx context.Context, snap *models.PostmasterSnapshot) error + ListPostmasterSnapshots(ctx context.Context, orgID uuid.UUID, source, target string, sinceDays int) ([]models.PostmasterSnapshot, error) + + // DNS + InsertDNSVerification(ctx context.Context, v *models.DNSVerification) error + ListDNSVerifications(ctx context.Context, orgID uuid.UUID, limit int) ([]models.DNSVerification, error) + + // Bookings + UpsertMeetingBooking(ctx context.Context, b *models.MeetingBooking) error + ListMeetingBookings(ctx context.Context, orgID uuid.UUID, limit int) ([]models.MeetingBooking, error) +} + +type integrationRepository struct { + db *pgxpool.Pool +} + +func NewIntegrationRepository(db *pgxpool.Pool) IntegrationRepository { + return &integrationRepository{db: db} +} + +// UpsertConnection inserts a new connection or updates an existing +// (org, provider, label) tuple. The encrypted config and inbound secret +// are only written when non-nil/non-empty, so partial updates (e.g. the +// DMARC ingest flow rotating just the inbound secret) don't blow away +// the rest of the config. +func (r *integrationRepository) UpsertConnection(ctx context.Context, c *models.IntegrationConnection, configEncrypted []byte, inboundSecret string) error { + if c.ID == uuid.Nil { + c.ID = uuid.New() + } + now := time.Now().UTC() + c.CreatedAt = now + c.UpdatedAt = now + + display := c.DisplayFields + if len(display) == 0 { + display = json.RawMessage("{}") + } + + _, err := r.db.Exec(ctx, ` + INSERT INTO integration_connections ( + id, organization_id, provider, label, status, + inbound_secret, config_encrypted, display_fields, + created_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $9) + ON CONFLICT (organization_id, provider, label) DO UPDATE SET + status = EXCLUDED.status, + inbound_secret = COALESCE(EXCLUDED.inbound_secret, integration_connections.inbound_secret), + config_encrypted = COALESCE(EXCLUDED.config_encrypted, integration_connections.config_encrypted), + display_fields = EXCLUDED.display_fields, + updated_at = EXCLUDED.updated_at + `, + c.ID, c.OrganizationID, string(c.Provider), c.Label, string(c.Status), + nullIfEmptyStr(inboundSecret), nullIfEmptyStrBytes(configEncrypted), display, now, + ) + return err +} + +func nullIfEmptyStr(s string) any { + if s == "" { + return nil + } + return s +} + +func nullIfEmptyStrBytes(b []byte) any { + if len(b) == 0 { + return nil + } + return b +} + +func (r *integrationRepository) ListConnections(ctx context.Context, orgID uuid.UUID) ([]models.IntegrationConnection, error) { + rows, err := r.db.Query(ctx, ` + SELECT id, organization_id, provider, label, status, display_fields, + last_synced_at, last_error, last_error_at, created_at, updated_at + FROM integration_connections + WHERE organization_id = $1 + ORDER BY created_at DESC + `, orgID) + if err != nil { + return nil, err + } + defer rows.Close() + + out := []models.IntegrationConnection{} + for rows.Next() { + c, err := scanConnection(rows) + if err != nil { + return nil, err + } + out = append(out, *c) + } + return out, rows.Err() +} + +func (r *integrationRepository) GetConnection(ctx context.Context, orgID uuid.UUID, provider models.IntegrationProvider, label string) (*models.IntegrationConnection, error) { + row := r.db.QueryRow(ctx, ` + SELECT id, organization_id, provider, label, status, display_fields, + last_synced_at, last_error, last_error_at, created_at, updated_at + FROM integration_connections + WHERE organization_id = $1 AND provider = $2 AND label = $3 + `, orgID, string(provider), label) + c, err := scanConnection(row) + if errors.Is(err, pgx.ErrNoRows) || errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return c, err +} + +// GetConnectionByInboundSecret resolves the connection an incoming webhook +// belongs to. Callers must validate the secret out-of-band (e.g. Calendly +// signature) — this lookup is the org-routing step, not the auth step. +func (r *integrationRepository) GetConnectionByInboundSecret(ctx context.Context, provider models.IntegrationProvider, secret string) (*models.IntegrationConnection, error) { + if secret == "" { + return nil, nil + } + row := r.db.QueryRow(ctx, ` + SELECT id, organization_id, provider, label, status, display_fields, + last_synced_at, last_error, last_error_at, created_at, updated_at + FROM integration_connections + WHERE provider = $1 AND inbound_secret = $2 + LIMIT 1 + `, string(provider), secret) + c, err := scanConnection(row) + if errors.Is(err, pgx.ErrNoRows) || errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return c, err +} + +func (r *integrationRepository) DeleteConnection(ctx context.Context, orgID, id uuid.UUID) error { + _, err := r.db.Exec(ctx, + `DELETE FROM integration_connections WHERE organization_id = $1 AND id = $2`, + orgID, id, + ) + return err +} + +func (r *integrationRepository) MarkConnectionSynced(ctx context.Context, id uuid.UUID, status models.IntegrationStatus, displayFields json.RawMessage, errMsg string) error { + now := time.Now().UTC() + if len(displayFields) == 0 { + displayFields = json.RawMessage("{}") + } + if errMsg == "" { + _, err := r.db.Exec(ctx, ` + UPDATE integration_connections + SET status = $1, display_fields = $2, last_synced_at = $3, + last_error = NULL, last_error_at = NULL, updated_at = $3 + WHERE id = $4 + `, string(status), displayFields, now, id) + return err + } + _, err := r.db.Exec(ctx, ` + UPDATE integration_connections + SET status = $1, display_fields = $2, + last_error = $3, last_error_at = $4, updated_at = $4 + WHERE id = $5 + `, string(status), displayFields, errMsg, now, id) + return err +} + +type scanner interface { + Scan(dest ...any) error +} + +func scanConnection(row scanner) (*models.IntegrationConnection, error) { + var c models.IntegrationConnection + var provider, status string + if err := row.Scan( + &c.ID, &c.OrganizationID, &provider, &c.Label, &status, &c.DisplayFields, + &c.LastSyncedAt, &c.LastError, &c.LastErrorAt, &c.CreatedAt, &c.UpdatedAt, + ); err != nil { + return nil, err + } + c.Provider = models.IntegrationProvider(provider) + c.Status = models.IntegrationStatus(status) + if len(c.DisplayFields) == 0 { + c.DisplayFields = json.RawMessage("{}") + } + return &c, nil +} + +// ─── DMARC ───────────────────────────────────────────────────────────── + +func (r *integrationRepository) UpsertDMARCReport(ctx context.Context, report *models.DMARCReport) error { + if report.ID == uuid.Nil { + report.ID = uuid.New() + } + tx, err := r.db.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + + // Dedupe on (org, reporter, report_id). On conflict, return existing row. + var existingID uuid.UUID + err = tx.QueryRow(ctx, ` + INSERT INTO dmarc_reports ( + id, organization_id, domain, reporter_org, report_id, + range_start, range_end, total_messages, pass_messages, fail_messages, + raw_xml, created_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NOW()) + ON CONFLICT (organization_id, reporter_org, report_id) DO UPDATE + SET total_messages = EXCLUDED.total_messages + RETURNING id + `, + report.ID, report.OrganizationID, report.Domain, report.ReporterOrg, report.ReportID, + report.RangeStart, report.RangeEnd, report.TotalMessages, report.PassMessages, report.FailMessages, + "", + ).Scan(&existingID) + if err != nil { + return err + } + report.ID = existingID + + // Clear and re-insert rows (idempotent for re-submissions). + if _, err := tx.Exec(ctx, `DELETE FROM dmarc_record_rows WHERE report_id = $1`, report.ID); err != nil { + return err + } + for _, row := range report.Rows { + if _, err := tx.Exec(ctx, ` + INSERT INTO dmarc_record_rows ( + report_id, source_ip, message_count, disposition, + spf_result, dkim_result, spf_domain, dkim_domain, header_from + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + `, + report.ID, row.SourceIP, row.MessageCount, row.Disposition, + row.SPFResult, row.DKIMResult, row.SPFDomain, row.DKIMDomain, row.HeaderFrom, + ); err != nil { + return err + } + } + return tx.Commit(ctx) +} + +func (r *integrationRepository) ListDMARCReports(ctx context.Context, orgID uuid.UUID, domain string, limit int) ([]models.DMARCReport, error) { + if limit <= 0 { + limit = 50 + } + var rows pgx.Rows + var err error + if domain == "" { + rows, err = r.db.Query(ctx, ` + SELECT id, organization_id, domain, reporter_org, report_id, + range_start, range_end, total_messages, pass_messages, fail_messages, created_at + FROM dmarc_reports + WHERE organization_id = $1 + ORDER BY range_end DESC + LIMIT $2 + `, orgID, limit) + } else { + rows, err = r.db.Query(ctx, ` + SELECT id, organization_id, domain, reporter_org, report_id, + range_start, range_end, total_messages, pass_messages, fail_messages, created_at + FROM dmarc_reports + WHERE organization_id = $1 AND domain = $2 + ORDER BY range_end DESC + LIMIT $3 + `, orgID, domain, limit) + } + if err != nil { + return nil, err + } + defer rows.Close() + + out := []models.DMARCReport{} + for rows.Next() { + var rep models.DMARCReport + if err := rows.Scan( + &rep.ID, &rep.OrganizationID, &rep.Domain, &rep.ReporterOrg, &rep.ReportID, + &rep.RangeStart, &rep.RangeEnd, &rep.TotalMessages, &rep.PassMessages, &rep.FailMessages, &rep.CreatedAt, + ); err != nil { + return nil, err + } + out = append(out, rep) + } + return out, rows.Err() +} + +// ─── Postmaster ──────────────────────────────────────────────────────── + +func (r *integrationRepository) UpsertPostmasterSnapshot(ctx context.Context, s *models.PostmasterSnapshot) error { + raw := s.RawPayload + if len(raw) == 0 { + raw = json.RawMessage("{}") + } + _, err := r.db.Exec(ctx, ` + INSERT INTO postmaster_snapshots ( + organization_id, source, target, snapshot_date, + spam_rate_pct, inbox_placement_pct, domain_reputation, ip_reputation, + dkim_success_pct, spf_success_pct, dmarc_success_pct, raw_payload + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + ON CONFLICT (organization_id, source, target, snapshot_date) DO UPDATE SET + spam_rate_pct = EXCLUDED.spam_rate_pct, + inbox_placement_pct = EXCLUDED.inbox_placement_pct, + domain_reputation = EXCLUDED.domain_reputation, + ip_reputation = EXCLUDED.ip_reputation, + dkim_success_pct = EXCLUDED.dkim_success_pct, + spf_success_pct = EXCLUDED.spf_success_pct, + dmarc_success_pct = EXCLUDED.dmarc_success_pct, + raw_payload = EXCLUDED.raw_payload + `, + s.OrganizationID, s.Source, s.Target, s.SnapshotDate, + s.SpamRatePct, s.InboxPlacementPct, s.DomainReputation, s.IPReputation, + s.DKIMSuccessPct, s.SPFSuccessPct, s.DMARCSuccessPct, raw, + ) + return err +} + +func (r *integrationRepository) ListPostmasterSnapshots(ctx context.Context, orgID uuid.UUID, source, target string, sinceDays int) ([]models.PostmasterSnapshot, error) { + if sinceDays <= 0 { + sinceDays = 30 + } + rows, err := r.db.Query(ctx, ` + SELECT id, organization_id, source, target, snapshot_date, + spam_rate_pct, inbox_placement_pct, domain_reputation, ip_reputation, + dkim_success_pct, spf_success_pct, dmarc_success_pct, created_at + FROM postmaster_snapshots + WHERE organization_id = $1 + AND ($2 = '' OR source = $2) + AND ($3 = '' OR target = $3) + AND snapshot_date >= CURRENT_DATE - $4::int + ORDER BY snapshot_date DESC + `, orgID, source, target, sinceDays) + if err != nil { + return nil, err + } + defer rows.Close() + + out := []models.PostmasterSnapshot{} + for rows.Next() { + var s models.PostmasterSnapshot + if err := rows.Scan( + &s.ID, &s.OrganizationID, &s.Source, &s.Target, &s.SnapshotDate, + &s.SpamRatePct, &s.InboxPlacementPct, &s.DomainReputation, &s.IPReputation, + &s.DKIMSuccessPct, &s.SPFSuccessPct, &s.DMARCSuccessPct, &s.CreatedAt, + ); err != nil { + return nil, err + } + out = append(out, s) + } + return out, rows.Err() +} + +// ─── DNS verifications ───────────────────────────────────────────────── + +func (r *integrationRepository) InsertDNSVerification(ctx context.Context, v *models.DNSVerification) error { + if v.ID == uuid.Nil { + v.ID = uuid.New() + } + notes := v.Notes + if len(notes) == 0 { + notes = json.RawMessage("{}") + } + _, err := r.db.Exec(ctx, ` + INSERT INTO dns_verifications ( + id, organization_id, domain, + spf_record, spf_ok, + dkim_selector, dkim_record, dkim_ok, + dmarc_record, dmarc_ok, + tracking_cname, tracking_ok, + notes, checked_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, NOW()) + `, + v.ID, v.OrganizationID, v.Domain, + v.SPFRecord, v.SPFOK, + v.DKIMSelector, v.DKIMRecord, v.DKIMOK, + v.DMARCRecord, v.DMARCOK, + v.TrackingCNAME, v.TrackingOK, + notes, + ) + return err +} + +func (r *integrationRepository) ListDNSVerifications(ctx context.Context, orgID uuid.UUID, limit int) ([]models.DNSVerification, error) { + if limit <= 0 { + limit = 50 + } + // Latest verification per domain. The window is a small enough N that + // DISTINCT ON in a subquery is cheaper than a CTE. + rows, err := r.db.Query(ctx, ` + SELECT DISTINCT ON (domain) + id, organization_id, domain, + spf_record, spf_ok, + dkim_selector, dkim_record, dkim_ok, + dmarc_record, dmarc_ok, + tracking_cname, tracking_ok, + notes, checked_at + FROM dns_verifications + WHERE organization_id = $1 + ORDER BY domain, checked_at DESC + LIMIT $2 + `, orgID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + out := []models.DNSVerification{} + for rows.Next() { + var v models.DNSVerification + if err := rows.Scan( + &v.ID, &v.OrganizationID, &v.Domain, + &v.SPFRecord, &v.SPFOK, + &v.DKIMSelector, &v.DKIMRecord, &v.DKIMOK, + &v.DMARCRecord, &v.DMARCOK, + &v.TrackingCNAME, &v.TrackingOK, + &v.Notes, &v.CheckedAt, + ); err != nil { + return nil, err + } + out = append(out, v) + } + return out, rows.Err() +} + +// ─── Meeting bookings ────────────────────────────────────────────────── + +func (r *integrationRepository) UpsertMeetingBooking(ctx context.Context, b *models.MeetingBooking) error { + if b.ID == uuid.Nil { + b.ID = uuid.New() + } + raw := b.RawPayload + if len(raw) == 0 { + raw = json.RawMessage("{}") + } + _, err := r.db.Exec(ctx, ` + INSERT INTO meeting_bookings ( + id, organization_id, source, external_event_id, + invitee_email, invitee_name, event_name, scheduled_for, + contact_id, campaign_id, raw_payload, created_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NOW()) + ON CONFLICT (organization_id, source, external_event_id) DO UPDATE SET + invitee_email = EXCLUDED.invitee_email, + invitee_name = EXCLUDED.invitee_name, + event_name = EXCLUDED.event_name, + scheduled_for = EXCLUDED.scheduled_for, + contact_id = COALESCE(EXCLUDED.contact_id, meeting_bookings.contact_id), + campaign_id = COALESCE(EXCLUDED.campaign_id, meeting_bookings.campaign_id), + raw_payload = EXCLUDED.raw_payload + `, + b.ID, b.OrganizationID, b.Source, b.ExternalEventID, + b.InviteeEmail, b.InviteeName, b.EventName, b.ScheduledFor, + b.ContactID, b.CampaignID, raw, + ) + return err +} + +func (r *integrationRepository) ListMeetingBookings(ctx context.Context, orgID uuid.UUID, limit int) ([]models.MeetingBooking, error) { + if limit <= 0 { + limit = 50 + } + rows, err := r.db.Query(ctx, ` + SELECT id, organization_id, source, external_event_id, + invitee_email, invitee_name, event_name, scheduled_for, + contact_id, campaign_id, created_at + FROM meeting_bookings + WHERE organization_id = $1 + ORDER BY created_at DESC + LIMIT $2 + `, orgID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + out := []models.MeetingBooking{} + for rows.Next() { + var b models.MeetingBooking + if err := rows.Scan( + &b.ID, &b.OrganizationID, &b.Source, &b.ExternalEventID, + &b.InviteeEmail, &b.InviteeName, &b.EventName, &b.ScheduledFor, + &b.ContactID, &b.CampaignID, &b.CreatedAt, + ); err != nil { + return nil, err + } + out = append(out, b) + } + return out, rows.Err() +} From 679c9a44d215e7e0028437136910a001d28d483a Mon Sep 17 00:00:00 2001 From: Matt <meszmatew@gmail.com> Date: Thu, 28 May 2026 08:20:54 +0200 Subject: [PATCH 02/13] feat(integration): web API client + hooks for integrations surface Mirrors the backend models in TypeScript and adds React-Query hooks for catalog, connections, DMARC reports, meeting bookings, DNS verifications, and the connect / disconnect / verify mutations. --- .../app/integrations/connectIntegration.ts | 17 +++ .../app/integrations/disconnectIntegration.ts | 9 ++ .../client/app/integrations/listCatalog.ts | 10 ++ .../app/integrations/listConnections.ts | 10 ++ .../app/integrations/listDMARCReports.ts | 11 ++ .../app/integrations/listDNSVerifications.ts | 10 ++ .../app/integrations/listMeetingBookings.ts | 10 ++ .../integrations/listPostmasterSnapshots.ts | 14 +++ .../api/client/app/integrations/verifyDNS.ts | 17 +++ .../app/integrations/useConnectIntegration.ts | 12 ++ .../hooks/app/integrations/useDMARCReports.ts | 10 ++ .../app/integrations/useDNSVerifications.ts | 10 ++ .../integrations/useDisconnectIntegration.ts | 12 ++ .../app/integrations/useIntegrationCatalog.ts | 11 ++ .../integrations/useIntegrationConnections.ts | 10 ++ .../app/integrations/useMeetingBookings.ts | 10 ++ .../hooks/app/integrations/useVerifyDNS.ts | 12 ++ .../models/app/integrations/Integration.ts | 117 ++++++++++++++++++ 18 files changed, 312 insertions(+) create mode 100644 web/src/lib/api/client/app/integrations/connectIntegration.ts create mode 100644 web/src/lib/api/client/app/integrations/disconnectIntegration.ts create mode 100644 web/src/lib/api/client/app/integrations/listCatalog.ts create mode 100644 web/src/lib/api/client/app/integrations/listConnections.ts create mode 100644 web/src/lib/api/client/app/integrations/listDMARCReports.ts create mode 100644 web/src/lib/api/client/app/integrations/listDNSVerifications.ts create mode 100644 web/src/lib/api/client/app/integrations/listMeetingBookings.ts create mode 100644 web/src/lib/api/client/app/integrations/listPostmasterSnapshots.ts create mode 100644 web/src/lib/api/client/app/integrations/verifyDNS.ts create mode 100644 web/src/lib/api/hooks/app/integrations/useConnectIntegration.ts create mode 100644 web/src/lib/api/hooks/app/integrations/useDMARCReports.ts create mode 100644 web/src/lib/api/hooks/app/integrations/useDNSVerifications.ts create mode 100644 web/src/lib/api/hooks/app/integrations/useDisconnectIntegration.ts create mode 100644 web/src/lib/api/hooks/app/integrations/useIntegrationCatalog.ts create mode 100644 web/src/lib/api/hooks/app/integrations/useIntegrationConnections.ts create mode 100644 web/src/lib/api/hooks/app/integrations/useMeetingBookings.ts create mode 100644 web/src/lib/api/hooks/app/integrations/useVerifyDNS.ts create mode 100644 web/src/lib/api/models/app/integrations/Integration.ts diff --git a/web/src/lib/api/client/app/integrations/connectIntegration.ts b/web/src/lib/api/client/app/integrations/connectIntegration.ts new file mode 100644 index 00000000..2942728e --- /dev/null +++ b/web/src/lib/api/client/app/integrations/connectIntegration.ts @@ -0,0 +1,17 @@ +import type { IntegrationConnection, IntegrationProvider } from "@/lib/api/models/app/integrations/Integration"; +import Request from "../../Request"; + +export interface ConnectInput { + provider: IntegrationProvider; + label?: string; + config: Record<string, unknown>; +} + +export default async function connectIntegration(input: ConnectInput): Promise<IntegrationConnection> { + return await Request<IntegrationConnection>({ + method: "POST", + url: "/integrations/connections", + data: input, + authorization: true, + }); +} diff --git a/web/src/lib/api/client/app/integrations/disconnectIntegration.ts b/web/src/lib/api/client/app/integrations/disconnectIntegration.ts new file mode 100644 index 00000000..615cae81 --- /dev/null +++ b/web/src/lib/api/client/app/integrations/disconnectIntegration.ts @@ -0,0 +1,9 @@ +import Request from "../../Request"; + +export default async function disconnectIntegration(id: string): Promise<void> { + await Request<void>({ + method: "DELETE", + url: `/integrations/connections/${id}`, + authorization: true, + }); +} diff --git a/web/src/lib/api/client/app/integrations/listCatalog.ts b/web/src/lib/api/client/app/integrations/listCatalog.ts new file mode 100644 index 00000000..4ff4e20d --- /dev/null +++ b/web/src/lib/api/client/app/integrations/listCatalog.ts @@ -0,0 +1,10 @@ +import type { IntegrationCatalogEntry } from "@/lib/api/models/app/integrations/Integration"; +import Request from "../../Request"; + +export default async function listIntegrationCatalog(): Promise<{ catalog: IntegrationCatalogEntry[] }> { + return await Request<{ catalog: IntegrationCatalogEntry[] }>({ + method: "GET", + url: "/integrations/catalog", + authorization: true, + }); +} diff --git a/web/src/lib/api/client/app/integrations/listConnections.ts b/web/src/lib/api/client/app/integrations/listConnections.ts new file mode 100644 index 00000000..b53fb4a1 --- /dev/null +++ b/web/src/lib/api/client/app/integrations/listConnections.ts @@ -0,0 +1,10 @@ +import type { IntegrationConnection } from "@/lib/api/models/app/integrations/Integration"; +import Request from "../../Request"; + +export default async function listIntegrationConnections(): Promise<{ connections: IntegrationConnection[] }> { + return await Request<{ connections: IntegrationConnection[] }>({ + method: "GET", + url: "/integrations/connections", + authorization: true, + }); +} diff --git a/web/src/lib/api/client/app/integrations/listDMARCReports.ts b/web/src/lib/api/client/app/integrations/listDMARCReports.ts new file mode 100644 index 00000000..f0974039 --- /dev/null +++ b/web/src/lib/api/client/app/integrations/listDMARCReports.ts @@ -0,0 +1,11 @@ +import type { DMARCReport } from "@/lib/api/models/app/integrations/Integration"; +import Request from "../../Request"; + +export default async function listDMARCReports(domain?: string): Promise<{ reports: DMARCReport[] }> { + const qs = domain ? `?domain=${encodeURIComponent(domain)}` : ""; + return await Request<{ reports: DMARCReport[] }>({ + method: "GET", + url: `/integrations/dmarc/reports${qs}`, + authorization: true, + }); +} diff --git a/web/src/lib/api/client/app/integrations/listDNSVerifications.ts b/web/src/lib/api/client/app/integrations/listDNSVerifications.ts new file mode 100644 index 00000000..57cc9863 --- /dev/null +++ b/web/src/lib/api/client/app/integrations/listDNSVerifications.ts @@ -0,0 +1,10 @@ +import type { DNSVerification } from "@/lib/api/models/app/integrations/Integration"; +import Request from "../../Request"; + +export default async function listDNSVerifications(): Promise<{ verifications: DNSVerification[] }> { + return await Request<{ verifications: DNSVerification[] }>({ + method: "GET", + url: "/integrations/dns/verifications", + authorization: true, + }); +} diff --git a/web/src/lib/api/client/app/integrations/listMeetingBookings.ts b/web/src/lib/api/client/app/integrations/listMeetingBookings.ts new file mode 100644 index 00000000..ad2e5719 --- /dev/null +++ b/web/src/lib/api/client/app/integrations/listMeetingBookings.ts @@ -0,0 +1,10 @@ +import type { MeetingBooking } from "@/lib/api/models/app/integrations/Integration"; +import Request from "../../Request"; + +export default async function listMeetingBookings(): Promise<{ bookings: MeetingBooking[] }> { + return await Request<{ bookings: MeetingBooking[] }>({ + method: "GET", + url: "/integrations/bookings", + authorization: true, + }); +} diff --git a/web/src/lib/api/client/app/integrations/listPostmasterSnapshots.ts b/web/src/lib/api/client/app/integrations/listPostmasterSnapshots.ts new file mode 100644 index 00000000..4f29d721 --- /dev/null +++ b/web/src/lib/api/client/app/integrations/listPostmasterSnapshots.ts @@ -0,0 +1,14 @@ +import type { PostmasterSnapshot } from "@/lib/api/models/app/integrations/Integration"; +import Request from "../../Request"; + +export default async function listPostmasterSnapshots(source?: string, target?: string): Promise<{ snapshots: PostmasterSnapshot[] }> { + const qs = new URLSearchParams(); + if (source) qs.set("source", source); + if (target) qs.set("target", target); + const suffix = qs.toString() ? `?${qs.toString()}` : ""; + return await Request<{ snapshots: PostmasterSnapshot[] }>({ + method: "GET", + url: `/integrations/postmaster/snapshots${suffix}`, + authorization: true, + }); +} diff --git a/web/src/lib/api/client/app/integrations/verifyDNS.ts b/web/src/lib/api/client/app/integrations/verifyDNS.ts new file mode 100644 index 00000000..918486e7 --- /dev/null +++ b/web/src/lib/api/client/app/integrations/verifyDNS.ts @@ -0,0 +1,17 @@ +import type { DNSVerification } from "@/lib/api/models/app/integrations/Integration"; +import Request from "../../Request"; + +export interface DNSVerifyInput { + domain: string; + dkim_selector?: string; + tracking_cname?: string; +} + +export default async function verifyDNS(input: DNSVerifyInput): Promise<DNSVerification> { + return await Request<DNSVerification>({ + method: "POST", + url: "/integrations/dns/verify", + data: input, + authorization: true, + }); +} diff --git a/web/src/lib/api/hooks/app/integrations/useConnectIntegration.ts b/web/src/lib/api/hooks/app/integrations/useConnectIntegration.ts new file mode 100644 index 00000000..eb5d12fd --- /dev/null +++ b/web/src/lib/api/hooks/app/integrations/useConnectIntegration.ts @@ -0,0 +1,12 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import connectIntegration, { type ConnectInput } from "@/lib/api/client/app/integrations/connectIntegration"; + +export default function useConnectIntegration() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (input: ConnectInput) => connectIntegration(input), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["integrations", "connections"] }); + }, + }); +} diff --git a/web/src/lib/api/hooks/app/integrations/useDMARCReports.ts b/web/src/lib/api/hooks/app/integrations/useDMARCReports.ts new file mode 100644 index 00000000..7c273a1e --- /dev/null +++ b/web/src/lib/api/hooks/app/integrations/useDMARCReports.ts @@ -0,0 +1,10 @@ +import { useQuery } from "@tanstack/react-query"; +import listDMARCReports from "@/lib/api/client/app/integrations/listDMARCReports"; + +export default function useDMARCReports(domain?: string) { + return useQuery({ + queryKey: ["integrations", "dmarc", domain ?? ""], + queryFn: () => listDMARCReports(domain), + staleTime: 30_000, + }); +} diff --git a/web/src/lib/api/hooks/app/integrations/useDNSVerifications.ts b/web/src/lib/api/hooks/app/integrations/useDNSVerifications.ts new file mode 100644 index 00000000..494950db --- /dev/null +++ b/web/src/lib/api/hooks/app/integrations/useDNSVerifications.ts @@ -0,0 +1,10 @@ +import { useQuery } from "@tanstack/react-query"; +import listDNSVerifications from "@/lib/api/client/app/integrations/listDNSVerifications"; + +export default function useDNSVerifications() { + return useQuery({ + queryKey: ["integrations", "dns", "verifications"], + queryFn: listDNSVerifications, + staleTime: 10_000, + }); +} diff --git a/web/src/lib/api/hooks/app/integrations/useDisconnectIntegration.ts b/web/src/lib/api/hooks/app/integrations/useDisconnectIntegration.ts new file mode 100644 index 00000000..f119085a --- /dev/null +++ b/web/src/lib/api/hooks/app/integrations/useDisconnectIntegration.ts @@ -0,0 +1,12 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import disconnectIntegration from "@/lib/api/client/app/integrations/disconnectIntegration"; + +export default function useDisconnectIntegration() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => disconnectIntegration(id), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["integrations", "connections"] }); + }, + }); +} diff --git a/web/src/lib/api/hooks/app/integrations/useIntegrationCatalog.ts b/web/src/lib/api/hooks/app/integrations/useIntegrationCatalog.ts new file mode 100644 index 00000000..22e1d57d --- /dev/null +++ b/web/src/lib/api/hooks/app/integrations/useIntegrationCatalog.ts @@ -0,0 +1,11 @@ +import { useQuery } from "@tanstack/react-query"; +import listIntegrationCatalog from "@/lib/api/client/app/integrations/listCatalog"; + +export default function useIntegrationCatalog() { + return useQuery({ + queryKey: ["integrations", "catalog"], + queryFn: listIntegrationCatalog, + // Catalog is effectively static — refresh once an hour at most. + staleTime: 60 * 60_000, + }); +} diff --git a/web/src/lib/api/hooks/app/integrations/useIntegrationConnections.ts b/web/src/lib/api/hooks/app/integrations/useIntegrationConnections.ts new file mode 100644 index 00000000..fc83db67 --- /dev/null +++ b/web/src/lib/api/hooks/app/integrations/useIntegrationConnections.ts @@ -0,0 +1,10 @@ +import { useQuery } from "@tanstack/react-query"; +import listIntegrationConnections from "@/lib/api/client/app/integrations/listConnections"; + +export default function useIntegrationConnections() { + return useQuery({ + queryKey: ["integrations", "connections"], + queryFn: listIntegrationConnections, + staleTime: 10_000, + }); +} diff --git a/web/src/lib/api/hooks/app/integrations/useMeetingBookings.ts b/web/src/lib/api/hooks/app/integrations/useMeetingBookings.ts new file mode 100644 index 00000000..2767e7c4 --- /dev/null +++ b/web/src/lib/api/hooks/app/integrations/useMeetingBookings.ts @@ -0,0 +1,10 @@ +import { useQuery } from "@tanstack/react-query"; +import listMeetingBookings from "@/lib/api/client/app/integrations/listMeetingBookings"; + +export default function useMeetingBookings() { + return useQuery({ + queryKey: ["integrations", "bookings"], + queryFn: listMeetingBookings, + staleTime: 10_000, + }); +} diff --git a/web/src/lib/api/hooks/app/integrations/useVerifyDNS.ts b/web/src/lib/api/hooks/app/integrations/useVerifyDNS.ts new file mode 100644 index 00000000..158182b8 --- /dev/null +++ b/web/src/lib/api/hooks/app/integrations/useVerifyDNS.ts @@ -0,0 +1,12 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import verifyDNS, { type DNSVerifyInput } from "@/lib/api/client/app/integrations/verifyDNS"; + +export default function useVerifyDNS() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (input: DNSVerifyInput) => verifyDNS(input), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["integrations", "dns", "verifications"] }); + }, + }); +} diff --git a/web/src/lib/api/models/app/integrations/Integration.ts b/web/src/lib/api/models/app/integrations/Integration.ts new file mode 100644 index 00000000..35382325 --- /dev/null +++ b/web/src/lib/api/models/app/integrations/Integration.ts @@ -0,0 +1,117 @@ +// Mirror of the backend's models/integration.go shapes. Only the fields +// the dashboard renders are typed — opaque blobs like display_fields stay +// generic so the UI can dig in without round-trips to the type system. + +export type IntegrationProvider = + | "calendly" + | "cal_com" + | "google_sheets" + | "google_postmaster" + | "microsoft_snds" + | "dmarc" + | "cloudflare" + | "godaddy" + | "namecheap"; + +export type IntegrationStatus = "pending" | "connected" | "degraded" | "disconnected"; + +export type IntegrationCategory = + | "meetings" + | "data" + | "deliverability" + | "dns"; + +export interface IntegrationCatalogEntry { + provider: IntegrationProvider; + name: string; + tagline: string; + category: IntegrationCategory; + docs_url?: string; + auth_method: "oauth" | "api_key" | "webhook"; + badge_color?: string; + beta: boolean; + webhook_hint?: string; +} + +export interface IntegrationConnection { + id: string; + organization_id: string; + provider: IntegrationProvider; + label: string; + status: IntegrationStatus; + display_fields: Record<string, unknown>; + last_synced_at?: string | null; + last_error?: string | null; + last_error_at?: string | null; + created_at: string; + updated_at: string; + + /** Returned once at create time for inbound-webhook providers. */ + inbound_webhook_url?: string; +} + +export interface DMARCReport { + id: string; + organization_id: string; + domain: string; + reporter_org: string; + report_id: string; + range_start: string; + range_end: string; + total_messages: number; + pass_messages: number; + fail_messages: number; + created_at: string; +} + +export interface PostmasterSnapshot { + id: number; + organization_id: string; + source: "google_postmaster" | "microsoft_snds"; + target: string; + snapshot_date: string; + spam_rate_pct?: number; + inbox_placement_pct?: number; + domain_reputation?: string; + ip_reputation?: string; + dkim_success_pct?: number; + spf_success_pct?: number; + dmarc_success_pct?: number; + created_at: string; +} + +export interface DNSVerification { + id: string; + organization_id: string; + domain: string; + + spf_record?: string; + spf_ok: boolean; + + dkim_selector?: string; + dkim_record?: string; + dkim_ok: boolean; + + dmarc_record?: string; + dmarc_ok: boolean; + + tracking_cname?: string; + tracking_ok: boolean; + + notes: Record<string, string>; + checked_at: string; +} + +export interface MeetingBooking { + id: string; + organization_id: string; + source: "calendly" | "cal_com"; + external_event_id: string; + invitee_email: string; + invitee_name: string; + event_name: string; + scheduled_for?: string; + contact_id?: string; + campaign_id?: string; + created_at: string; +} From 33fffbacbb42bf1795dbe07ba6d78243d7999282 Mon Sep 17 00:00:00 2001 From: Matt <meszmatew@gmail.com> Date: Thu, 28 May 2026 08:26:54 +0200 Subject: [PATCH 03/13] feat(integration): dashboard page in existing theme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New /app/integrations route with sidebar entry. Page uses the existing Page / StatStrip / SectionBar primitives — catalog cards grouped by category (Deliverability, DNS, Meetings, Data), connect/disconnect inline drawer per provider, inbound-URL modal that surfaces the per-org webhook URL once, DMARC reports list, meeting bookings list, and a DNS verifier widget that resolves SPF / DKIM / DMARC / tracking CNAME inline. --- .../_components/ConnectDrawer.tsx | 214 +++++++++ .../_components/DNSVerifierPanel.tsx | 155 +++++++ .../_components/InboundUrlDialog.tsx | 114 +++++ web/src/app/app/integrations/page.tsx | 408 ++++++++++++++++++ web/src/components/layout/AppNav.tsx | 2 + web/src/main.tsx | 5 + 6 files changed, 898 insertions(+) create mode 100644 web/src/app/app/integrations/_components/ConnectDrawer.tsx create mode 100644 web/src/app/app/integrations/_components/DNSVerifierPanel.tsx create mode 100644 web/src/app/app/integrations/_components/InboundUrlDialog.tsx create mode 100644 web/src/app/app/integrations/page.tsx diff --git a/web/src/app/app/integrations/_components/ConnectDrawer.tsx b/web/src/app/app/integrations/_components/ConnectDrawer.tsx new file mode 100644 index 00000000..12feecef --- /dev/null +++ b/web/src/app/app/integrations/_components/ConnectDrawer.tsx @@ -0,0 +1,214 @@ +// Drawer that handles per-provider connect inputs. Each provider has a +// slightly different set of required fields: +// - webhook providers (Calendly, Cal.com, DMARC): just a label +// - oauth providers (Google Sheets, Postmaster): launch OAuth — for +// now we accept a manual token paste, OAuth wiring lands in the +// mailbox onboarding sweep +// - api-key providers (Cloudflare, SNDS, GoDaddy, Namecheap): paste +// the token + zone/domain +// +// The drawer is a single component (not one per provider) because the +// shape variation is small and keeping it one file makes it easier to +// add new providers later. + +"use client"; + +import React from "react"; +import { XIcon } from "lucide-react"; +import toast from "react-hot-toast"; + +import type { + IntegrationCatalogEntry, + IntegrationConnection, +} from "@/lib/api/models/app/integrations/Integration"; +import useConnectIntegration from "@/lib/api/hooks/app/integrations/useConnectIntegration"; +import { cn } from "@/lib/utils"; + +interface FieldDef { + key: string; + label: string; + placeholder?: string; + helper?: string; + type?: "text" | "password"; + required?: boolean; +} + +const FIELDS_BY_PROVIDER: Record<string, FieldDef[]> = { + calendly: [], + cal_com: [], + dmarc: [], + google_sheets: [ + { key: "sheet_id", label: "Sheet ID", placeholder: "1AbC...XyZ", required: true, + helper: "The long ID in the sheet's URL between /d/ and /edit." }, + { key: "sheet_title", label: "Display label", placeholder: "Q2 outbound list" }, + { key: "access_token", label: "OAuth access token", type: "password", + helper: "Paste a token with Sheets scope. OAuth wiring lands in onboarding." }, + ], + google_postmaster: [ + { key: "domain", label: "Domain", placeholder: "yourdomain.com", required: true }, + { key: "access_token", label: "OAuth access token", type: "password", required: true, + helper: "Token with the gmail.postmaster.readonly scope." }, + ], + microsoft_snds: [ + { key: "ip", label: "IP address or range", placeholder: "1.2.3.4", required: true, + helper: "The IP you registered with SNDS." }, + { key: "api_token", label: "SNDS data-access key", type: "password", required: true }, + ], + cloudflare: [ + { key: "zone_name", label: "Zone name", placeholder: "yourdomain.com", required: true }, + { key: "api_token", label: "API token", type: "password", required: true, + helper: "Needs Zone:DNS:Edit on the listed zone. We verify before saving." }, + ], + godaddy: [ + { key: "domain", label: "Domain", placeholder: "yourdomain.com", required: true }, + { key: "api_token", label: "API key:secret", type: "password", required: true, + helper: "Format: <key>:<secret> from GoDaddy developer portal." }, + ], + namecheap: [ + { key: "domain", label: "Domain", placeholder: "yourdomain.com", required: true }, + { key: "api_token", label: "API token", type: "password", required: true }, + ], +}; + +export default function ConnectDrawer({ + entry, + onClose, + onConnected, +}: { + entry: IntegrationCatalogEntry; + onClose: () => void; + onConnected: (c: IntegrationConnection) => void; +}) { + const [label, setLabel] = React.useState(""); + const [config, setConfig] = React.useState<Record<string, string>>({}); + const connect = useConnectIntegration(); + + const fields = FIELDS_BY_PROVIDER[entry.provider] ?? []; + + function update(key: string, value: string) { + setConfig((c) => ({ ...c, [key]: value })); + } + + async function submit(e: React.FormEvent) { + e.preventDefault(); + + for (const f of fields) { + if (f.required && !config[f.key]?.trim()) { + toast.error(`${f.label} is required`); + return; + } + } + + try { + const conn = await connect.mutateAsync({ + provider: entry.provider, + label: label.trim() || entry.name, + config, + }); + toast.success(`Connected to ${entry.name}`); + onConnected(conn); + onClose(); + } catch (err: unknown) { + const e = err as { response?: { data?: { error?: string } }; message?: string }; + toast.error(e.response?.data?.error ?? e.message ?? "Connect failed"); + } + } + + return ( + <div className="fixed inset-0 z-40 flex"> + <button + type="button" + aria-label="Close" + onClick={onClose} + className="absolute inset-0 bg-slate-900/30 backdrop-blur-[2px]" + /> + <div className="ml-auto h-full w-[480px] bg-white shadow-xl flex flex-col z-10 relative"> + <div className="h-12 px-5 border-b border-slate-200 flex items-center gap-3 shrink-0"> + <div className="w-7 h-7 rounded bg-sky-50 ring-1 ring-sky-100 text-sky-700 inline-flex items-center justify-center text-[12px] font-semibold uppercase"> + {entry.name.charAt(0)} + </div> + <div className="min-w-0 flex-1"> + <div className="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">Connect</div> + <div className="text-[12.5px] text-slate-900 font-medium truncate">{entry.name}</div> + </div> + <button + type="button" + onClick={onClose} + aria-label="Close" + className="h-7 w-7 rounded border border-slate-200 hover:border-slate-300 text-slate-500 hover:text-slate-900 inline-flex items-center justify-center transition-colors" + > + <XIcon className="w-3.5 h-3.5" /> + </button> + </div> + + <form onSubmit={submit} className="flex-1 overflow-auto flex flex-col"> + <div className="px-5 py-5 space-y-4"> + <p className="text-[12.5px] text-slate-600 leading-relaxed">{entry.tagline}</p> + + <div> + <label className="text-[10.5px] uppercase tracking-[0.08em] text-slate-400 font-medium"> + Label (optional) + </label> + <input + value={label} + onChange={(e) => setLabel(e.target.value)} + placeholder={entry.name} + className="mt-1 w-full h-8 px-2.5 rounded border border-slate-200 bg-white text-[12.5px] text-slate-900 placeholder:text-slate-400 outline-none focus:border-sky-400 transition-colors" + /> + <p className="text-[10.5px] text-slate-400 mt-1"> + Useful if you connect more than one of the same provider. + </p> + </div> + + {fields.map((f) => ( + <div key={f.key}> + <label className="text-[10.5px] uppercase tracking-[0.08em] text-slate-400 font-medium"> + {f.label} + {f.required && <span className="text-rose-500 ml-0.5">*</span>} + </label> + <input + type={f.type ?? "text"} + value={config[f.key] ?? ""} + onChange={(e) => update(f.key, e.target.value)} + placeholder={f.placeholder} + className="mt-1 w-full h-8 px-2.5 rounded border border-slate-200 bg-white text-[12.5px] text-slate-900 placeholder:text-slate-400 outline-none focus:border-sky-400 font-mono transition-colors" + /> + {f.helper && ( + <p className="text-[10.5px] text-slate-400 mt-1 leading-relaxed">{f.helper}</p> + )} + </div> + ))} + + {fields.length === 0 && ( + <div className="rounded border border-slate-200 bg-slate-50 px-3 py-2.5"> + <p className="text-[12px] text-slate-600 leading-relaxed"> + {entry.webhook_hint ?? "We'll mint a URL for you on the next screen. Paste it into the provider's webhook configuration."} + </p> + </div> + )} + </div> + + <div className="mt-auto border-t border-slate-200 px-5 py-3 flex items-center justify-end gap-2"> + <button + type="button" + onClick={onClose} + className="h-7 px-3 rounded border border-slate-200 text-[12px] text-slate-700 hover:border-slate-300 hover:text-slate-900 transition-colors" + > + Cancel + </button> + <button + type="submit" + disabled={connect.isPending} + className={cn( + "h-7 px-3 rounded text-[12px] font-medium text-white transition-colors", + connect.isPending ? "bg-sky-400" : "bg-sky-600 hover:bg-sky-700", + )} + > + {connect.isPending ? "Connecting…" : "Connect"} + </button> + </div> + </form> + </div> + </div> + ); +} diff --git a/web/src/app/app/integrations/_components/DNSVerifierPanel.tsx b/web/src/app/app/integrations/_components/DNSVerifierPanel.tsx new file mode 100644 index 00000000..fc13ddbd --- /dev/null +++ b/web/src/app/app/integrations/_components/DNSVerifierPanel.tsx @@ -0,0 +1,155 @@ +// Inline DNS verifier widget. User types a domain, hits "Check" — the +// backend resolves SPF/DKIM/DMARC and the optional tracking CNAME and +// returns a verification row that we render below. + +"use client"; + +import React from "react"; +import { CheckIcon, GlobeIcon, RefreshCwIcon, XIcon } from "lucide-react"; +import toast from "react-hot-toast"; + +import useVerifyDNS from "@/lib/api/hooks/app/integrations/useVerifyDNS"; +import type { DNSVerification } from "@/lib/api/models/app/integrations/Integration"; +import { cn } from "@/lib/utils"; + +export default function DNSVerifierPanel({ verifications }: { verifications: DNSVerification[] }) { + const [domain, setDomain] = React.useState(""); + const [dkimSelector, setDkimSelector] = React.useState(""); + const [trackingCname, setTrackingCname] = React.useState(""); + const verify = useVerifyDNS(); + const [latest, setLatest] = React.useState<DNSVerification | null>(null); + + async function submit() { + if (!domain.trim()) { + toast.error("Domain is required"); + return; + } + try { + const v = await verify.mutateAsync({ + domain: domain.trim(), + dkim_selector: dkimSelector.trim() || undefined, + tracking_cname: trackingCname.trim() || undefined, + }); + setLatest(v); + } catch (err: unknown) { + const e = err as { response?: { data?: { error?: string } }; message?: string }; + toast.error(e.response?.data?.error ?? e.message ?? "Verification failed"); + } + } + + const display = latest ?? verifications[0] ?? null; + + return ( + <div className="space-y-4"> + <div className="flex flex-wrap items-end gap-2"> + <div className="flex-1 min-w-[200px]"> + <label className="text-[10.5px] uppercase tracking-[0.08em] text-slate-400 font-medium">Domain</label> + <div className="mt-1 h-8 rounded border border-slate-200 bg-white flex items-center gap-1 px-2.5 focus-within:border-sky-400 transition-colors"> + <GlobeIcon className="w-3.5 h-3.5 text-slate-400" /> + <input + value={domain} + onChange={(e) => setDomain(e.target.value)} + placeholder="yourdomain.com" + className="flex-1 bg-transparent text-[12.5px] text-slate-900 placeholder:text-slate-400 outline-none" + /> + </div> + </div> + <div className="w-[160px]"> + <label className="text-[10.5px] uppercase tracking-[0.08em] text-slate-400 font-medium">DKIM selector</label> + <input + value={dkimSelector} + onChange={(e) => setDkimSelector(e.target.value)} + placeholder="auto" + className="mt-1 w-full h-8 px-2.5 rounded border border-slate-200 bg-white text-[12.5px] text-slate-900 placeholder:text-slate-400 outline-none focus:border-sky-400 font-mono transition-colors" + /> + </div> + <div className="w-[200px]"> + <label className="text-[10.5px] uppercase tracking-[0.08em] text-slate-400 font-medium">Tracking CNAME</label> + <input + value={trackingCname} + onChange={(e) => setTrackingCname(e.target.value)} + placeholder="trk.yourdomain.com" + className="mt-1 w-full h-8 px-2.5 rounded border border-slate-200 bg-white text-[12.5px] text-slate-900 placeholder:text-slate-400 outline-none focus:border-sky-400 font-mono transition-colors" + /> + </div> + <button + type="button" + onClick={submit} + disabled={verify.isPending} + className={cn( + "h-8 px-3 rounded text-[12px] font-medium text-white inline-flex items-center gap-1.5 transition-colors", + verify.isPending ? "bg-sky-400" : "bg-sky-600 hover:bg-sky-700", + )} + > + <RefreshCwIcon className={cn("w-3 h-3", verify.isPending && "animate-spin")} /> + {verify.isPending ? "Checking…" : "Check"} + </button> + </div> + + {display && ( + <div className="rounded border border-slate-200 overflow-hidden"> + <div className="h-9 px-3 border-b border-slate-200 flex items-center gap-2 bg-slate-50"> + <GlobeIcon className="w-3.5 h-3.5 text-slate-500" /> + <span className="text-[12.5px] font-medium text-slate-900">{display.domain}</span> + <span className="ml-auto font-mono text-[10.5px] text-slate-400 tabular-nums"> + checked {new Date(display.checked_at).toLocaleString()} + </span> + </div> + <div className="grid grid-cols-4 divide-x divide-slate-200/60"> + <CheckCell label="SPF" ok={display.spf_ok} value={display.spf_record} /> + <CheckCell + label={display.dkim_selector ? `DKIM · ${display.dkim_selector}` : "DKIM"} + ok={display.dkim_ok} + value={display.dkim_record} + /> + <CheckCell label="DMARC" ok={display.dmarc_ok} value={display.dmarc_record} /> + <CheckCell + label="Tracking" + ok={display.tracking_ok} + value={display.tracking_cname} + optional + /> + </div> + </div> + )} + + {verifications.length > 1 && ( + <div className="text-[10.5px] text-slate-400 font-mono"> + {verifications.length} domains verified · most recent shown above + </div> + )} + </div> + ); +} + +function CheckCell({ + label, + ok, + value, + optional, +}: { + label: string; + ok: boolean; + value?: string | null; + optional?: boolean; +}) { + return ( + <div className="px-3 py-2.5"> + <div className="flex items-center gap-1.5"> + {ok ? ( + <CheckIcon className="w-3.5 h-3.5 text-emerald-600" /> + ) : value || !optional ? ( + <XIcon className="w-3.5 h-3.5 text-rose-500" /> + ) : ( + <span className="w-3.5 h-3.5 inline-block" /> + )} + <span className="text-[10.5px] uppercase tracking-[0.08em] text-slate-500 font-medium"> + {label} + </span> + </div> + <div className="mt-1 font-mono text-[10.5px] text-slate-600 break-all line-clamp-2"> + {value ? value : optional && !ok ? "not checked" : "not found"} + </div> + </div> + ); +} diff --git a/web/src/app/app/integrations/_components/InboundUrlDialog.tsx b/web/src/app/app/integrations/_components/InboundUrlDialog.tsx new file mode 100644 index 00000000..c7c46593 --- /dev/null +++ b/web/src/app/app/integrations/_components/InboundUrlDialog.tsx @@ -0,0 +1,114 @@ +// Modal that surfaces the per-org inbound webhook URL exactly once after +// a Calendly/Cal.com/DMARC connection is created. The secret is embedded +// in the URL path — if the user closes without copying, they must +// rotate the secret to get it again. + +"use client"; + +import React from "react"; +import { CheckIcon, CopyIcon, XIcon } from "lucide-react"; +import toast from "react-hot-toast"; + +import type { IntegrationProvider } from "@/lib/api/models/app/integrations/Integration"; + +const PROVIDER_NAMES: Partial<Record<IntegrationProvider, string>> = { + calendly: "Calendly", + cal_com: "Cal.com", + dmarc: "DMARC reports", +}; + +const HINTS: Partial<Record<IntegrationProvider, string>> = { + calendly: "Paste this in Calendly → Account → Integrations → Webhooks → Create Webhook. Subscribe to invitee.created.", + cal_com: "Paste this in Cal.com → Settings → Developer → Webhooks. Subscribe to BOOKING_CREATED.", + dmarc: "Forward your DMARC aggregate (rua=) reports to this URL — either directly via curl or via a mail-to-HTTP relay.", +}; + +export default function InboundUrlDialog({ + provider, + url, + onClose, +}: { + provider: IntegrationProvider; + url: string; + onClose: () => void; +}) { + const [copied, setCopied] = React.useState(false); + + const fullUrl = url.startsWith("http") + ? url + : `${window.location.origin}${url}`; + + function copy() { + navigator.clipboard.writeText(fullUrl).then( + () => { + setCopied(true); + toast.success("Copied to clipboard"); + setTimeout(() => setCopied(false), 1500); + }, + () => toast.error("Failed to copy"), + ); + } + + return ( + <div className="fixed inset-0 z-50 flex items-center justify-center"> + <button + type="button" + aria-label="Close" + onClick={onClose} + className="absolute inset-0 bg-slate-900/40 backdrop-blur-[2px]" + /> + <div className="relative z-10 w-[520px] bg-white rounded-lg shadow-xl border border-slate-200 overflow-hidden"> + <div className="h-12 px-5 border-b border-slate-200 flex items-center gap-3"> + <div className="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">Webhook URL</div> + <div className="h-4 w-px bg-slate-200" /> + <div className="text-[12.5px] text-slate-900 font-medium truncate flex-1"> + {PROVIDER_NAMES[provider] ?? provider} + </div> + <button + type="button" + onClick={onClose} + aria-label="Close" + className="h-7 w-7 rounded border border-slate-200 hover:border-slate-300 text-slate-500 hover:text-slate-900 inline-flex items-center justify-center transition-colors" + > + <XIcon className="w-3.5 h-3.5" /> + </button> + </div> + + <div className="px-5 py-5 space-y-4"> + <p className="text-[12.5px] text-slate-600 leading-relaxed"> + {HINTS[provider] ?? "Paste this URL in the provider's webhook configuration."} + </p> + + <div className="rounded border border-slate-200 bg-slate-50 p-2.5 flex items-center gap-2"> + <code className="flex-1 font-mono text-[11.5px] text-slate-800 break-all">{fullUrl}</code> + <button + type="button" + onClick={copy} + className="h-7 px-2 rounded border border-slate-200 bg-white hover:border-slate-300 text-slate-700 inline-flex items-center gap-1 text-[11px] font-medium transition-colors" + > + {copied ? <CheckIcon className="w-3 h-3 text-emerald-600" /> : <CopyIcon className="w-3 h-3" />} + {copied ? "Copied" : "Copy"} + </button> + </div> + + <div className="rounded border border-amber-200 bg-amber-50 px-3 py-2.5"> + <p className="text-[11.5px] text-amber-900 leading-relaxed"> + This URL contains a secret that is only shown once. If you lose it, rotate the + connection to mint a new one — the old URL will stop working immediately. + </p> + </div> + </div> + + <div className="border-t border-slate-200 px-5 py-3 flex items-center justify-end gap-2"> + <button + type="button" + onClick={onClose} + className="h-7 px-3 rounded bg-slate-900 text-white text-[12px] font-medium hover:bg-slate-800 transition-colors" + > + Done + </button> + </div> + </div> + </div> + ); +} diff --git a/web/src/app/app/integrations/page.tsx b/web/src/app/app/integrations/page.tsx new file mode 100644 index 00000000..60113d10 --- /dev/null +++ b/web/src/app/app/integrations/page.tsx @@ -0,0 +1,408 @@ +// Integrations dashboard. +// +// One page covers the full integration surface: catalog of available +// providers (Calendly, Cal.com, Google Sheets, Google Postmaster, +// Microsoft SNDS, DMARC, Cloudflare, GoDaddy, Namecheap), per-org +// connection state, inbound webhook URLs, DMARC reports, Postmaster / +// SNDS snapshots, and a DNS verifier widget. +// +// Layout follows the Page primitives — stat strip across the top, +// section bars between zones, no max-width chrome. Connect / disconnect +// happens in inline drawers (ConnectDrawer) rather than separate routes +// so the page stays a single navigation target from the sidebar. + +"use client"; + +import React from "react"; +import { + CableIcon, + CalendarCheckIcon, + CheckIcon, + GlobeIcon, + PlusIcon, + RefreshCwIcon, + ShieldCheckIcon, + XIcon, +} from "lucide-react"; +import toast from "react-hot-toast"; + +import { + EmptyBlock, + Page, + PageBody, + PageTopbar, + SectionBar, + Stat, + StatStrip, + TopbarAction, +} from "@/components/layout/Page"; +import useIntegrationCatalog from "@/lib/api/hooks/app/integrations/useIntegrationCatalog"; +import useIntegrationConnections from "@/lib/api/hooks/app/integrations/useIntegrationConnections"; +import useDisconnectIntegration from "@/lib/api/hooks/app/integrations/useDisconnectIntegration"; +import useDMARCReports from "@/lib/api/hooks/app/integrations/useDMARCReports"; +import useMeetingBookings from "@/lib/api/hooks/app/integrations/useMeetingBookings"; +import useDNSVerifications from "@/lib/api/hooks/app/integrations/useDNSVerifications"; +import type { + IntegrationCatalogEntry, + IntegrationCategory, + IntegrationConnection, + IntegrationProvider, +} from "@/lib/api/models/app/integrations/Integration"; +import { cn } from "@/lib/utils"; + +import ConnectDrawer from "./_components/ConnectDrawer"; +import InboundUrlDialog from "./_components/InboundUrlDialog"; +import DNSVerifierPanel from "./_components/DNSVerifierPanel"; + +const CATEGORY_LABELS: Record<IntegrationCategory, string> = { + meetings: "Meetings", + data: "Data", + deliverability: "Deliverability", + dns: "DNS", +}; + +const CATEGORY_ORDER: IntegrationCategory[] = ["deliverability", "dns", "meetings", "data"]; + +export default function IntegrationsPage() { + const catalogQuery = useIntegrationCatalog(); + const connectionsQuery = useIntegrationConnections(); + const bookingsQuery = useMeetingBookings(); + const dmarcQuery = useDMARCReports(); + const dnsQuery = useDNSVerifications(); + + const disconnect = useDisconnectIntegration(); + + const [connectTarget, setConnectTarget] = React.useState<IntegrationCatalogEntry | null>(null); + const [inboundUrl, setInboundUrl] = React.useState<{ provider: IntegrationProvider; url: string } | null>(null); + + const catalog = catalogQuery.data?.catalog ?? []; + const connections = connectionsQuery.data?.connections ?? []; + const bookings = bookingsQuery.data?.bookings ?? []; + const dmarcReports = dmarcQuery.data?.reports ?? []; + const dnsVerifications = dnsQuery.data?.verifications ?? []; + + // Index connections by provider so the catalog grid can paint each + // provider's status without a per-card query. + const byProvider = React.useMemo(() => { + const m: Record<string, IntegrationConnection[]> = {}; + for (const c of connections) { + (m[c.provider] ??= []).push(c); + } + return m; + }, [connections]); + + const grouped = React.useMemo(() => { + const map: Partial<Record<IntegrationCategory, IntegrationCatalogEntry[]>> = {}; + for (const entry of catalog) { + (map[entry.category] ??= []).push(entry); + } + return map; + }, [catalog]); + + const connectedCount = connections.filter((c) => c.status === "connected").length; + const degradedCount = connections.filter((c) => c.status === "degraded").length; + + function refreshAll() { + catalogQuery.refetch(); + connectionsQuery.refetch(); + bookingsQuery.refetch(); + dmarcQuery.refetch(); + dnsQuery.refetch(); + } + + async function handleDisconnect(connection: IntegrationConnection) { + try { + await disconnect.mutateAsync(connection.id); + toast.success("Disconnected"); + } catch { + toast.error("Disconnect failed"); + } + } + + return ( + <Page> + <PageTopbar eyebrow="Integrations" subtitle="Calendly, Cal.com, Google Sheets, Postmaster, SNDS, DMARC, Cloudflare, and more"> + <button + type="button" + onClick={refreshAll} + aria-label="Refresh" + className="h-7 w-7 rounded-md border border-slate-200 hover:border-slate-300 text-slate-500 hover:text-slate-900 inline-flex items-center justify-center transition-colors" + > + <RefreshCwIcon className={cn("w-3 h-3", connectionsQuery.isFetching && "animate-spin")} /> + </button> + </PageTopbar> + + <StatStrip cols={4}> + <Stat + label="Catalog" + value={catalog.length} + sub="available providers" + /> + <Stat + label="Connected" + value={connectedCount} + sub={`${connections.length} total`} + accent={connectedCount > 0} + /> + <Stat + label="Degraded" + value={degradedCount} + sub={degradedCount > 0 ? "needs attention" : "all healthy"} + /> + <Stat + label="Meetings · 30d" + value={bookings.length} + sub="from Calendly + Cal.com" + last + /> + </StatStrip> + + <PageBody> + {/* Catalog grid grouped by category. */} + {CATEGORY_ORDER.map((category) => { + const entries = grouped[category] ?? []; + if (entries.length === 0) return null; + return ( + <section key={category}> + <SectionBar label={CATEGORY_LABELS[category]} count={entries.length} /> + <div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-px bg-slate-200/60 border-b border-slate-200/60"> + {entries.map((entry) => ( + <CatalogCard + key={entry.provider} + entry={entry} + connections={byProvider[entry.provider] ?? []} + onConnect={() => setConnectTarget(entry)} + onDisconnect={handleDisconnect} + onShowInbound={(url) => setInboundUrl({ provider: entry.provider, url })} + /> + ))} + </div> + </section> + ); + })} + + {/* DNS verifier widget — independent of any connection. */} + <SectionBar label="DNS verifier"> + <span className="text-[10.5px] text-slate-400">SPF · DKIM · DMARC</span> + </SectionBar> + <div className="px-5 py-4 border-b border-slate-200/60"> + <DNSVerifierPanel verifications={dnsVerifications} /> + </div> + + {/* DMARC reports — most recent first. */} + <SectionBar label="DMARC reports" count={dmarcReports.length}> + <ShieldCheckIcon className="w-3 h-3 text-slate-400" /> + </SectionBar> + {dmarcReports.length === 0 ? ( + <EmptyBlock + title="No DMARC reports yet" + body="Connect DMARC, then forward your rua= reports to the URL we mint for you." + /> + ) : ( + <div className="divide-y divide-slate-200/60 border-b border-slate-200/60"> + {dmarcReports.slice(0, 10).map((r) => ( + <div key={r.id} className="px-5 h-12 flex items-center gap-3 text-[12.5px]"> + <GlobeIcon className="w-3.5 h-3.5 text-slate-400" /> + <span className="font-medium text-slate-900 w-48 truncate">{r.domain}</span> + <span className="text-slate-500 w-40 truncate">{r.reporter_org}</span> + <span className="ml-auto font-mono text-[11px] tabular-nums text-slate-500"> + {r.pass_messages.toLocaleString()} pass + </span> + <span className="font-mono text-[11px] tabular-nums text-rose-600"> + {r.fail_messages.toLocaleString()} fail + </span> + <span className="font-mono text-[10.5px] text-slate-400 tabular-nums w-32 text-right"> + {new Date(r.range_end).toLocaleDateString()} + </span> + </div> + ))} + </div> + )} + + {/* Meeting bookings — Calendly/Cal.com conversions. */} + <SectionBar label="Meeting bookings" count={bookings.length}> + <CalendarCheckIcon className="w-3 h-3 text-slate-400" /> + </SectionBar> + {bookings.length === 0 ? ( + <EmptyBlock + title="No meetings booked yet" + body="Connect Calendly or Cal.com to track which campaigns lead to meetings." + /> + ) : ( + <div className="divide-y divide-slate-200/60 border-b border-slate-200/60"> + {bookings.slice(0, 10).map((b) => ( + <div key={b.id} className="px-5 h-12 flex items-center gap-3 text-[12.5px]"> + <SourceDot source={b.source} /> + <span className="font-medium text-slate-900 truncate w-60">{b.invitee_email}</span> + <span className="text-slate-500 truncate flex-1">{b.event_name}</span> + <span className="font-mono text-[10.5px] text-slate-400 tabular-nums"> + {b.scheduled_for ? new Date(b.scheduled_for).toLocaleString() : "tbd"} + </span> + </div> + ))} + </div> + )} + </PageBody> + + {connectTarget && ( + <ConnectDrawer + entry={connectTarget} + onClose={() => setConnectTarget(null)} + onConnected={(conn) => { + if (conn.inbound_webhook_url) { + setInboundUrl({ provider: conn.provider, url: conn.inbound_webhook_url }); + } + }} + /> + )} + {inboundUrl && ( + <InboundUrlDialog + provider={inboundUrl.provider} + url={inboundUrl.url} + onClose={() => setInboundUrl(null)} + /> + )} + </Page> + ); +} + +function CatalogCard({ + entry, + connections, + onConnect, + onDisconnect, + onShowInbound, +}: { + entry: IntegrationCatalogEntry; + connections: IntegrationConnection[]; + onConnect: () => void; + onDisconnect: (c: IntegrationConnection) => void; + onShowInbound: (url: string) => void; +}) { + const connected = connections.length > 0; + const status = connected ? connections[0].status : "disconnected"; + return ( + <div className="bg-white p-5 flex flex-col min-h-[140px]"> + <div className="flex items-start justify-between gap-3"> + <div className="flex items-center gap-2.5 min-w-0"> + <div className="w-9 h-9 rounded-md bg-sky-50 ring-1 ring-sky-100 text-sky-700 inline-flex items-center justify-center text-[13px] font-semibold uppercase"> + {entry.name.charAt(0)} + </div> + <div className="min-w-0"> + <div className="text-[13px] font-semibold text-slate-900 truncate">{entry.name}</div> + <div className="text-[10.5px] uppercase tracking-[0.08em] text-slate-400 font-mono"> + {entry.auth_method} + {entry.beta && ( + <span className="ml-1.5 text-amber-600">· beta</span> + )} + </div> + </div> + </div> + <StatusPill status={status} /> + </div> + + <p className="mt-3 text-[12px] text-slate-600 leading-relaxed line-clamp-3"> + {entry.tagline} + </p> + + <div className="mt-auto pt-3 flex items-center justify-between gap-2"> + {entry.docs_url ? ( + <a + href={entry.docs_url} + target="_blank" + rel="noopener noreferrer" + className="text-[11px] text-slate-500 hover:text-sky-700 underline decoration-dotted underline-offset-2" + > + Docs + </a> + ) : <span />} + <div className="flex items-center gap-1.5"> + {connected && connections[0].id ? ( + <> + <button + type="button" + onClick={() => onDisconnect(connections[0])} + className="h-6 px-2 rounded text-[11px] text-slate-500 hover:text-rose-700 hover:bg-rose-50 transition-colors" + > + Disconnect + </button> + {connections[0].display_fields && Object.keys(connections[0].display_fields).length > 0 && ( + <span className="font-mono text-[10px] text-slate-400 truncate max-w-[120px]"> + {(connections[0].display_fields as Record<string, string>)["domain"] ?? + (connections[0].display_fields as Record<string, string>)["sheet_title"] ?? + (connections[0].display_fields as Record<string, string>)["zone_name"] ?? ""} + </span> + )} + </> + ) : ( + <button + type="button" + onClick={onConnect} + className="h-7 px-2.5 rounded-md bg-sky-600 hover:bg-sky-700 text-white text-[11.5px] font-medium inline-flex items-center gap-1 transition-colors" + > + <PlusIcon className="w-3 h-3" /> + Connect + </button> + )} + </div> + </div> + + {connected && entry.webhook_hint && ( + <button + type="button" + onClick={() => { + // Re-emit the inbound URL dialog so the user can re-copy + // it later. The connect mutation only returns the URL + // at create time, so we synthesize it from the + // connection ID — backend won't replay the secret. + onShowInbound("/api/v1/integrations/inbound/" + entry.provider.replace("_", "-") + "/<your-secret>"); + }} + className="mt-2 text-[10.5px] text-sky-700 hover:underline self-start inline-flex items-center gap-1" + > + <CableIcon className="w-3 h-3" /> + Webhook URL + </button> + )} + </div> + ); +} + +function StatusPill({ status }: { status: string }) { + const tone = + status === "connected" + ? { bg: "bg-emerald-50", text: "text-emerald-700", dot: "bg-emerald-500" } + : status === "degraded" + ? { bg: "bg-amber-50", text: "text-amber-700", dot: "bg-amber-500" } + : status === "pending" + ? { bg: "bg-sky-50", text: "text-sky-700", dot: "bg-sky-500" } + : { bg: "bg-slate-100", text: "text-slate-500", dot: "bg-slate-400" }; + const label = status === "disconnected" ? "not connected" : status; + return ( + <span + className={cn( + "inline-flex items-center gap-1 h-5 px-1.5 rounded text-[9.5px] uppercase tracking-[0.08em] font-medium", + tone.bg, + tone.text, + )} + > + <span className={cn("size-1.5 rounded-full", tone.dot)} /> + {label} + </span> + ); +} + +function SourceDot({ source }: { source: string }) { + const colour = source === "calendly" ? "bg-rose-400" : "bg-indigo-400"; + return ( + <span className="inline-flex items-center gap-1"> + <span className={cn("size-1.5 rounded-full", colour)} /> + <span className="text-[10px] uppercase tracking-[0.08em] text-slate-400 font-mono"> + {source === "calendly" ? "calendly" : "cal.com"} + </span> + </span> + ); +} + +// Lint shim — keep icons that may only render in branches. +void CheckIcon; +void XIcon; diff --git a/web/src/components/layout/AppNav.tsx b/web/src/components/layout/AppNav.tsx index f7549cee..705fa242 100644 --- a/web/src/components/layout/AppNav.tsx +++ b/web/src/components/layout/AppNav.tsx @@ -9,6 +9,7 @@ import { Link, useLocation } from "react-router-dom"; import { BarChart3Icon, + CableIcon, CheckSquareIcon, CircleDollarSignIcon, FileTextIcon, @@ -87,6 +88,7 @@ const sections: NavSection[] = [ label: "Resources", items: [ { title: "Templates", url: "/app/templates", icon: FileTextIcon }, + { title: "Integrations", url: "/app/integrations", icon: CableIcon }, { title: "API Keys", url: "/app/api-keys", icon: KeyIcon }, { title: "Audit log", url: "/app/audit", icon: ListChecksIcon, rolesAllowed: "manage" }, ], diff --git a/web/src/main.tsx b/web/src/main.tsx index 90eeb384..170fbfe7 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -25,6 +25,7 @@ import DealsPage from './app/app/crm/deals/page'; import TasksPage from './app/app/crm/tasks/page'; import TemplatesPage from './app/app/templates/page'; import APIKeysPage from './app/app/api-keys/page'; +import IntegrationsPage from './app/app/integrations/page'; import AuditPage from './app/app/audit/page'; import SettingsLayout from './app/app/settings/layout'; import ProfileSettingsPage from './app/app/settings/profile/page'; @@ -247,6 +248,10 @@ const router = createBrowserRouter([ path: "api-keys", element: <APIKeysPage />, }, + { + path: "integrations", + element: <IntegrationsPage />, + }, { path: "audit", element: <AuditPage />, From 0515a632b3553f7573517af2a56304ec9dbe5e80 Mon Sep 17 00:00:00 2001 From: Matt <meszmatew@gmail.com> Date: Thu, 28 May 2026 08:31:47 +0200 Subject: [PATCH 04/13] feat(site): redesign integrations page in marketing theme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full rebuild matching the deliverability/warmup/developers pages: HeroAtmosphere, floating dashboard mock, opinionated stance section, 3-step connection model, per-category sections (deliverability, DNS, meetings, data), webhook event surface with sample payload, security strip, by-the-numbers footer, FAQ accordion, CTA. Content frames the tier 1/2 prioritization decided in the planning session — short catalog (nine providers, all directly load-bearing) plus an open webhook stream that anyone can build on top of. --- site/src/pages/integrations.astro | 676 ++++++++++++++++++++++++++---- 1 file changed, 584 insertions(+), 92 deletions(-) diff --git a/site/src/pages/integrations.astro b/site/src/pages/integrations.astro index 930d8c8b..d978ca63 100644 --- a/site/src/pages/integrations.astro +++ b/site/src/pages/integrations.astro @@ -1,137 +1,629 @@ --- import Layout from '../layouts/Layout.astro'; -import Cloud from '../components/Cloud.astro'; +import HeroAtmosphere from '../components/HeroAtmosphere.astro'; import Icon from '../components/Icon.astro'; import CTA from '../components/CTA.astro'; -const groups = [ +// ========================================================================= +// All providers wired in the backend's integration catalog. Keep parity +// with internal/app/integration/catalog.go — the API exposes the same +// list, so listing a provider here that doesn't exist there is a 1:1 +// promise of a broken connect button. +// ========================================================================= + +// Floating dashboard mock: live connection state for the hero. Numbers +// reflect the per-tier story we tell on the deliverability page. +const mockConnections = [ + { name: 'Cloudflare', cat: 'dns', state: 'connected', detail: 'acme.com · zone live', age: '2m ago' }, + { name: 'Google Postmaster', cat: 'deliverability', state: 'connected', detail: 'acme.com · pulled daily', age: '4h ago' }, + { name: 'Calendly', cat: 'meetings', state: 'connected', detail: '3 bookings attributed', age: '12m ago' }, + { name: 'DMARC reports', cat: 'deliverability', state: 'connected', detail: 'rua= forwarder live', age: '1h ago' }, + { name: 'Microsoft SNDS', cat: 'deliverability', state: 'connected', detail: '198.51.100.4 · green', age: '6h ago' }, + { name: 'Google Sheets', cat: 'data', state: 'pending', detail: 'Q2 outbound list · awaiting OAuth', age: '—' }, +]; + +const catTone = (c: string) => { + if (c === 'deliverability') return { dot: 'bg-emerald-500', chip: 'bg-emerald-50 text-emerald-700' }; + if (c === 'dns') return { dot: 'bg-sky-500', chip: 'bg-sky-50 text-sky-700' }; + if (c === 'meetings') return { dot: 'bg-rose-400', chip: 'bg-rose-50 text-rose-700' }; + return { dot: 'bg-amber-500', chip: 'bg-amber-50 text-amber-700' }; +}; + +const stateTone = (s: string) => { + if (s === 'connected') return { dot: 'bg-emerald-500', chip: 'bg-emerald-50 text-emerald-700' }; + if (s === 'pending') return { dot: 'bg-sky-500', chip: 'bg-sky-50 text-sky-700' }; + if (s === 'degraded') return { dot: 'bg-amber-500', chip: 'bg-amber-50 text-amber-700' }; + return { dot: 'bg-slate-400', chip: 'bg-slate-100 text-slate-600' }; +}; + +// ========================================================================= +// The categories themselves. Each one is a section on the page. +// ========================================================================= +const categories = [ { - id: 'mailbox', - title: 'Mailbox providers', - sub: 'Where mail enters and leaves', + id: 'deliverability', + eyebrow: 'Deliverability moat', + headline: 'Provider truth, in the same dashboard you already live in.', + body: 'Most outreach tools stop at "we send mail." Warmbly pulls reputation, complaint, and authentication data straight from the providers and surfaces it next to each mailbox\'s health state.', items: [ - { name: 'Google Workspace', body: 'OAuth · Gmail API · FBL signals.', status: 'live' }, - { name: 'Microsoft 365', body: 'OAuth · Graph send · IMAP / EWS sync.', status: 'live' }, - { name: 'Custom SMTP / IMAP', body: 'Any provider that speaks SMTP / IMAP.', status: 'live' }, - { name: 'Apple iCloud', body: 'App-specific password + IMAP / SMTP.', status: 'live' }, - { name: 'Zoho Mail', body: 'OAuth + IMAP / SMTP.', status: 'live' }, + { + name: 'Google Postmaster', + tagline: 'Domain reputation, spam-rate, and authentication ratios pulled daily.', + body: 'OAuth into Google Postmaster Tools and we pull domain reputation, IP reputation, SPF / DKIM / DMARC success ratios, and user-reported spam rate as one snapshot per day. The data joins the per-mailbox health state so a domain reputation drop downgrades all mailboxes sending on it.', + auth: 'OAuth', + beta: false, + }, + { + name: 'Microsoft SNDS', + tagline: 'IP reputation + complaint-rate bucket for Outlook + Hotmail.', + body: 'Paste your SNDS data-access key. We poll the CSV daily, parse the filter-result and complaint-rate columns, and translate them into the same green / yellow / red reputation tier the Postmaster integration uses, so the two providers are comparable on the same axis.', + auth: 'API key', + beta: false, + }, + { + name: 'DMARC reports', + tagline: 'Ingest aggregate (RUA) reports and flag misaligned senders.', + body: 'We mint a per-org URL you point your rua= forwarder at. Every XML report is parsed, deduped, and broken out per source IP, so an unexpected forwarder breaking SPF alignment surfaces with a hostname, not just a percentage. No third-party DMARC service required.', + auth: 'Webhook', + beta: false, + }, ], }, { - id: 'crm', - title: 'CRMs', - sub: 'Two-way sync with your system of record', + id: 'dns', + eyebrow: 'One-click DNS', + headline: 'SPF, DKIM, DMARC, and the tracking CNAME, written for you.', + body: 'A new mailbox should take a minute to configure, not an afternoon of DNS console wrangling. Connect a DNS provider once and the records publish from inside the dashboard.', items: [ - { name: 'HubSpot', body: 'Two-way contact and activity sync.', status: 'live' }, - { name: 'Salesforce', body: 'Leads, contacts, opportunities, custom fields.', status: 'live' }, - { name: 'Pipedrive', body: 'Persons, deals, activities.', status: 'live' }, - { name: 'Attio', body: 'People, companies, lists.', status: 'beta' }, - { name: 'Folk', body: 'Contacts, groups, custom fields.', status: 'beta' }, + { + name: 'Cloudflare', + tagline: 'Zone-scoped API token. We verify before saving.', + body: 'Paste a token scoped to Zone:DNS:Edit. We verify it round-trips before we persist it, look up your zone ID, and publish the three records we recommend. If a record already exists, we surface the diff before overwriting.', + auth: 'API token', + beta: false, + }, + { + name: 'GoDaddy', + tagline: 'Write records from the dashboard with your API key:secret pair.', + body: 'GoDaddy\'s developer portal mints a key:secret. We accept both, persist them encrypted, and publish the three records on save. Reads are batched so a GoDaddy-side rate limit never partially-applies a record set.', + auth: 'API key:secret', + beta: true, + }, + { + name: 'Namecheap', + tagline: 'Same one-click setup, Namecheap API edition.', + body: 'Namecheap requires an IP allowlist on their API endpoint — we surface the egress IP you need to allow inside the connect drawer so you do not have to read their docs. Once saved, record writes round-trip in seconds.', + auth: 'API token', + beta: true, + }, + ], + }, + { + id: 'meetings', + eyebrow: 'Meeting attribution', + headline: 'Stop reporting on replies. Start reporting on meetings.', + body: 'A reply is a hint. A meeting on the calendar is the real conversion event. We close the loop by accepting booking webhooks and joining them to the campaign that triggered the contact.', + items: [ + { + name: 'Calendly', + tagline: 'invitee.created webhook, joined to the campaign that surfaced the lead.', + body: 'Paste the URL we mint into Calendly\'s webhook UI. When a recipient books, we record the booking, look up the campaign that triggered them, and fire a campaign.reply_received webhook to your stack with trigger=meeting_booked so downstream dashboards know which campaign earned the meeting.', + auth: 'Webhook', + beta: false, + }, + { + name: 'Cal.com', + tagline: 'Same attribution path. Open-source booking edition.', + body: 'Cal.com\'s BOOKING_CREATED event uses a different JSON shape than Calendly. We normalize both into one MeetingBooking record so reporting code does not branch by source. Self-hosted Cal.com works the same way.', + auth: 'Webhook', + beta: false, + }, ], }, { id: 'data', - title: 'Data & enrichment', - sub: 'Find, verify, append', + eyebrow: 'Data + sheets', + headline: 'Where your operators already keep the lists.', + body: 'Lead lists do not live in your dashboard — they live in a spreadsheet. Warmbly\'s sheets integration is two-way: we pull lead rows in for a campaign and write the status of each row back as it sends, replies, bounces, or books.', items: [ - { name: 'Apollo', body: 'Lead lists pulled directly into Warmbly.', status: 'live' }, - { name: 'Clay', body: 'Push enriched lists from Clay tables.', status: 'live' }, - { name: 'Clearbit', body: 'Enrich contacts on import.', status: 'live' }, - { name: 'Dropcontact', body: 'Email finder and verifier.', status: 'live' }, - { name: 'NeverBounce', body: 'Verifier integration on import.', status: 'live' }, - ], - }, - { - id: 'workflow', - title: 'Workflow & alerting', - sub: 'Push events into the rest of your stack', - items: [ - { name: 'Slack', body: 'Channels for positive replies, bounces, quarantines.', status: 'live' }, - { name: 'Zapier', body: '70+ triggers and actions.', status: 'live' }, - { name: 'Make', body: 'Triggers and actions in scenarios.', status: 'live' }, - { name: 'n8n', body: 'Self-hosted workflow integration.', status: 'beta' }, - { name: 'Linear', body: 'Create tickets from negative replies.', status: 'soon' }, + { + name: 'Google Sheets', + tagline: 'Pull leads in. Push send / reply / bounce / booked events back.', + body: 'OAuth a Google account with Sheets scope. Point us at a sheet ID — we read rows starting from row 2 (header convention) and append a status column to the right. The same sheet becomes both your lead source and your real-time report.', + auth: 'OAuth', + beta: true, + }, ], }, ]; -const statusChip = (s: string) => { - if (s === 'live') return 'bg-emerald-50 text-emerald-700'; - if (s === 'beta') return 'bg-amber-50 text-amber-700'; - return 'bg-slate-100 text-slate-600'; -}; -const statusDot = (s: string) => { - if (s === 'live') return 'bg-emerald-500'; - if (s === 'beta') return 'bg-amber-500'; - return 'bg-slate-400'; -}; +// ========================================================================= +// Webhook events we emit. Real event types from internal/models/webhook.go. +// ========================================================================= +const webhookEvents = [ + { name: 'campaign.email_sent', desc: 'A campaign step dispatched to a recipient.' }, + { name: 'campaign.email_delivered', desc: 'Receiver acknowledged delivery (250 OK or DSN-equivalent).' }, + { name: 'campaign.email_opened', desc: 'Open pixel resolved. Note: open data is unreliable at major receivers.' }, + { name: 'campaign.email_clicked', desc: 'Tracked link clicked. Dedupe applied per recipient.' }, + { name: 'campaign.email_bounced', desc: 'Hard or soft bounce. Suppression follows automatically.' }, + { name: 'campaign.reply_received', desc: 'Recipient replied, or a meeting was booked via Calendly/Cal.com.' }, + { name: 'campaign.unsubscribed', desc: 'One-click unsubscribe or inbound STOP/REMOVE reply.' }, + { name: 'campaign.started', desc: 'Campaign moved into the running state.' }, + { name: 'campaign.paused', desc: 'Campaign auto-paused on bounce/complaint spike or manual pause.' }, + { name: 'campaign.completed', desc: 'Last sequence step dispatched for the last recipient.' }, + { name: 'warmup.health_changed', desc: 'A mailbox transitioned between healthy / watch / throttled / quarantined / blocked.' }, + { name: 'warmup.placement_in_spam', desc: 'A warmup probe landed in junk on a recipient mailbox.' }, + { name: 'warmup.quarantined', desc: 'Mailbox dropped to the recovery pool. 7-day cooldown.' }, + { name: 'warmup.blocked', desc: 'Mailbox hard-blocked from the shared pool. 30-day cooldown.' }, + { name: 'deliverability.bounce', desc: 'External deliverability event ingested (SES, Postmark, etc.).' }, + { name: 'deliverability.complaint', desc: 'External complaint event ingested (FBL, ARF, in-product reports).' }, + { name: 'email_account.connected', desc: 'A new mailbox finished onboarding.' }, + { name: 'email_account.removed', desc: 'A mailbox was removed from the workspace.' }, +]; + +// ========================================================================= +// "How it connects" three-step diagram source. Visual story for the hero. +// ========================================================================= +const steps = [ + { n: '01', t: 'Authenticate', d: 'OAuth, paste an API token, or copy a webhook URL we mint per-org. Inbound providers like Calendly need nothing else.' }, + { n: '02', t: 'Route', d: 'The connection joins your org\'s integration table. Inbound traffic routes by the secret in the URL path. Outbound traffic uses the encrypted token.' }, + { n: '03', t: 'Live', d: 'Status flips to connected. The dashboard surfaces the last sync, last error, and a one-click rotate. Disconnect cascades to dependent data.' }, +]; + +// ========================================================================= +// "By the numbers" strip. Provider count, event count. +// ========================================================================= +const numbers = [ + { v: '9', u: 'providers', l: 'Calendly, Cal.com, Sheets, Postmaster, SNDS, DMARC, Cloudflare, GoDaddy, Namecheap', src: 'integration/catalog.go' }, + { v: '18', u: 'webhook events', l: 'campaign · warmup · deliverability · account lifecycle', src: 'models/webhook.go' }, + { v: 'HMAC-SHA256', u: '', l: 'every outbound webhook is signed with t=<unix>,v1=<hex>', src: 'app/webhook/service.go' }, + { v: '8', u: 'max attempts', l: 'exponential backoff, capped at 1h, abandoned after ~2h', src: 'app/webhook/service.go' }, +]; + +// ========================================================================= +// Security strip. Threading the KMS envelope story so trust is not a +// separate page-load. +// ========================================================================= +const security = [ + { t: 'OAuth tokens encrypted with AES-256-GCM', d: 'Per-user data encryption keys (DEKs) wrapped with AWS KMS. The encrypted blob lands in DynamoDB; plaintext never persists outside a TTL-bounded Redis cache.' }, + { t: 'API tokens stored as opaque blobs', d: 'Cloudflare / GoDaddy / Namecheap / SNDS keys never serialize back to the API consumer. We expose only public display fields (zone name, IP, domain) for the dashboard.' }, + { t: 'Inbound URLs carry per-org secrets', d: 'A leaked URL only affects one organization. Rotation regenerates the secret and invalidates the old one immediately.' }, + { t: 'Outbound delivery audit trail', d: 'Every dispatch attempt is persisted with response status and body excerpt. Re-replay supported. SKIP LOCKED prevents duplicate fanout across replicas.' }, +]; + +// ========================================================================= +// FAQ. Real product behaviour. +// ========================================================================= +const faq = [ + ['Do you support OAuth for everything?', + 'No. OAuth is the right choice for providers that expose a per-user identity (Google Sheets, Google Postmaster). For provider-account-scoped credentials (Cloudflare API token, SNDS data-access key) a token is both simpler and safer. The connect drawer surfaces the right method per provider.'], + ['What happens to the connection if a token expires?', + 'The status flips to degraded and the dashboard shows the provider error. We do not silently retry forever — degraded connections stop attempting new fanout until the user re-authenticates or rotates.'], + ['Can I have more than one of the same provider?', + 'Yes. The connection is unique per (org, provider, label). Useful when one organization sends from two domains and you want one Cloudflare connection per zone instead of cramming both into one token.'], + ['How fast is the inbound webhook path?', + 'Calendly/Cal.com/DMARC POSTs are accepted, persisted, and acknowledged in the same request. The fan-out to your outbound webhook subscribers happens through the same queue our internal events use, so the latency budget is bounded by the queue tick (default 2s).'], + ['What if I do not want a provider you list?', + 'You can subscribe to the raw webhook stream and build whatever integration you want on top of it. The 18 event types cover every state transition we emit. The catalog is a curated convenience layer — the API surface underneath is open.'], +]; --- <Layout title="Integrations · Warmbly" - description="Mailbox providers, CRMs, data enrichment, and workflow tools that plug into Warmbly." + description="Tier 1 + Tier 2 integrations for cold outreach: Calendly, Cal.com, Google Sheets, Google Postmaster, Microsoft SNDS, DMARC, Cloudflare, GoDaddy, Namecheap. Plus a hardened webhook + API surface." > - <!-- HERO: with category nav --> - <section class="relative isolate overflow-hidden text-white" style="background: radial-gradient(ellipse 130% 140% at 72% 28%, #0284c7 0%, #0369a1 25%, #075985 50%, #0c4a6e 80%, #0a3d5c 100%);"> - <div class="absolute top-0 right-0 w-[420px] opacity-30 pointer-events-none cloud-drift cloud-1" style="mix-blend-mode: screen;"> - <Cloud variant={5} opacity={1} /> - </div> + <!-- ============================================================ + HERO · HeroAtmosphere, matches deliverability/warmup/developers + ============================================================ --> + <section class="relative isolate overflow-hidden"> + <HeroAtmosphere /> - <div class="container-page relative pt-14 pb-12"> - <div class="text-[11px] uppercase tracking-[0.18em] text-white/55 font-mono mb-3">Integrations</div> - <h1 class="text-[32px] md:text-[48px] font-semibold tracking-[-0.025em] leading-[1.06] text-white max-w-3xl"> - Connect what you already use. + <div class="container-page relative pt-16 md:pt-24 pb-44 md:pb-56 text-center"> + <div class="inline-flex items-center gap-2 h-7 pl-1 pr-3 rounded-full bg-white/15 backdrop-blur ring-1 ring-white/25 text-[12px] text-white/95 shadow-[0_4px_14px_-4px_rgba(0,0,0,0.25)]"> + <span class="inline-flex items-center h-5 px-1.5 rounded-full text-[10.5px] font-semibold uppercase tracking-[0.06em] bg-white" style="color:#0369a1;"> + Integrations + </span> + Built around the moat, not the marketplace + </div> + + <h1 class="mt-8 md:mt-10 text-[44px] sm:text-6xl md:text-[72px] lg:text-[80px] font-semibold tracking-[-0.04em] leading-[0.98] text-white max-w-4xl mx-auto"> + Connect what already<br/>runs your stack. </h1> - <div class="mt-8 flex flex-wrap gap-2"> - {groups.map((g) => ( - <a href={`#${g.id}`} class="inline-flex items-center gap-2 h-8 px-3 rounded-full bg-white/10 backdrop-blur ring-1 ring-white/20 text-[12.5px] text-white/90 hover:bg-white/20"> - {g.title} - <span class="font-mono text-[10.5px] text-white/55">{g.items.length}</span> - </a> + <p class="mt-6 text-[17px] md:text-[19px] text-white/80 max-w-2xl mx-auto leading-relaxed"> + Postmaster + SNDS for provider truth. DMARC ingestion for alignment. Cloudflare-class one-click DNS. Calendly attribution for the conversion event that actually pays the bill. Plus a signed webhook stream the rest builds on. + </p> + + <div class="mt-8 flex flex-wrap items-center justify-center gap-3"> + <a href="https://app.warmbly.com/register" class="inline-flex h-11 px-5 items-center gap-2 rounded-[10px] text-[14.5px] font-semibold bg-white hover:bg-white hover:-translate-y-0.5 hover:shadow-[0_12px_28px_-6px_rgba(0,0,0,0.3)] transition-all duration-200 ease-out shadow-[0_4px_14px_-2px_rgba(0,0,0,0.18)]"> + <span style="color:#075985;">Connect a provider</span> + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#075985" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> + <path d="M5 12h14"/><path d="m12 5 7 7-7 7"/> + </svg> + </a> + <a href="#deliverability" class="inline-flex h-11 px-5 items-center rounded-[10px] text-[14.5px] font-medium bg-white/10 backdrop-blur ring-1 ring-white/40 text-white hover:bg-white/20 hover:-translate-y-0.5 transition-all duration-200 ease-out"> + See the moat + </a> + </div> + </div> + </section> + + <!-- ============================================================ + FLOATING DASHBOARD · connection state mock + ============================================================ --> + <section class="relative -mt-36 md:-mt-44 pb-16 md:pb-24 z-10"> + <div class="container-page"> + <div class="rounded-[18px] bg-white ring-1 ring-[color:var(--border)] overflow-hidden shadow-[0_40px_90px_-30px_rgba(2,32,71,0.35),0_12px_30px_-12px_rgba(15,23,42,0.12)]"> + + <!-- Topbar --> + <div class="px-6 py-3 border-b border-[color:var(--border)] flex items-baseline justify-between gap-3"> + <div class="flex items-baseline gap-3"> + <div class="text-[10.5px] font-mono uppercase tracking-[0.18em] text-muted-foreground">Integrations</div> + <span class="text-[11.5px] text-foreground/55">5 connected · 1 pending · 0 degraded</span> + </div> + <div class="flex items-center gap-2"> + <span class="inline-flex items-center h-6 px-2 rounded-md text-[10.5px] font-mono text-[#0369a1] bg-[color:var(--sky-1)]">live</span> + </div> + </div> + + <!-- Filter chips --> + <div class="grid grid-cols-2 md:grid-cols-4 divide-x divide-[color:var(--border)] border-b border-[color:var(--border)]"> + {[ + { l: 'Deliverability', n: '3', c: 'bg-emerald-500' }, + { l: 'DNS', n: '1', c: 'bg-sky-500' }, + { l: 'Meetings', n: '1', c: 'bg-rose-400' }, + { l: 'Data', n: '1', c: 'bg-amber-500' }, + ].map((s) => ( + <div class="px-5 py-3.5 flex items-baseline justify-between"> + <div class="flex items-center gap-2"> + <span class={`w-2 h-2 rounded-full ${s.c}`}></span> + <span class="text-[11.5px] uppercase tracking-[0.14em] font-mono text-foreground/70">{s.l}</span> + </div> + <span class="text-[14.5px] font-mono font-semibold text-heading tabular-nums">{s.n}</span> + </div> + ))} + </div> + + <!-- Connections grid --> + <div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-px bg-[color:var(--border)]"> + {mockConnections.map((c) => { + const ct = catTone(c.cat); + const st = stateTone(c.state); + return ( + <div class="bg-white p-5 flex flex-col"> + <div class="flex items-start justify-between gap-3"> + <div class="flex items-center gap-2.5"> + <div class="w-9 h-9 rounded-md bg-[color:var(--sky-1)] ring-1 ring-[color:var(--sky-2)] text-[#0369a1] inline-flex items-center justify-center text-[13px] font-semibold uppercase"> + {c.name.charAt(0)} + </div> + <div> + <div class="text-[13px] font-semibold text-heading">{c.name}</div> + <span class={`mt-0.5 inline-flex items-center gap-1 h-4 px-1.5 rounded text-[9.5px] uppercase tracking-[0.08em] font-medium font-mono ${ct.chip}`}> + <span class={`w-1 h-1 rounded-full ${ct.dot}`}></span>{c.cat} + </span> + </div> + </div> + <span class={`inline-flex items-center gap-1 h-5 px-1.5 rounded text-[10px] uppercase tracking-[0.08em] font-medium ${st.chip}`}> + <span class={`w-1 h-1 rounded-full ${st.dot}`}></span>{c.state} + </span> + </div> + <div class="mt-3 text-[12px] text-foreground/70 leading-relaxed">{c.detail}</div> + <div class="mt-auto pt-3 flex items-center justify-between text-[10.5px] font-mono text-muted-foreground"> + <span>last sync</span> + <span class="text-foreground/75">{c.age}</span> + </div> + </div> + ); + })} + </div> + + <!-- Footer note --> + <div class="px-6 py-3 border-t border-[color:var(--border)] bg-[color:var(--surface-1)]/60 flex items-center justify-between text-[11px] font-mono text-muted-foreground"> + <span>9 providers in the catalog · 18 webhook event types</span> + <span class="inline-flex items-center gap-1.5 text-foreground/55"> + <span class="w-1.5 h-1.5 rounded-full bg-emerald-500"></span> + evaluated every event + </span> + </div> + </div> + <div class="mt-3 text-center text-[11.5px] font-mono text-muted-foreground"> + app.warmbly.com/integrations + </div> + </div> + </section> + + <!-- ============================================================ + STANCE · what is actually different here + ============================================================ --> + <section class="border-y border-[color:var(--border)] bg-[color:var(--surface-1)]/40 py-20 md:py-28"> + <div class="container-page"> + <div class="max-w-3xl mb-12"> + <div class="text-[11px] uppercase tracking-[0.18em] font-mono text-[#0284c7] mb-3">Stance</div> + <h2 class="text-[32px] md:text-[44px] font-semibold tracking-[-0.03em] leading-[1.05] text-heading"> + Why this list is short on purpose. + </h2> + <p class="mt-4 text-[15.5px] text-foreground/70 leading-relaxed"> + Every cold-outreach platform competes on the length of an integrations grid. We made the opposite call. Nine providers, all directly load-bearing for deliverability, DNS, meetings, or operator workflow. Everything else is a Zapier/Make/n8n template away. + </p> + </div> + + <div class="grid md:grid-cols-2 gap-4 lg:gap-5"> + {[ + { h: 'A long list of CRM logos is a parity game.', + b: 'Customers compare "HubSpot · Salesforce · Pipedrive · Close · HighLevel" on every vendor page. The truth is most of these turn into a one-way push the first week. We would rather build one CRM connection that is genuinely two-way than ten that look the same on a logo wall.' }, + { h: 'The moat is provider truth, not provider count.', + b: 'Google Postmaster, Microsoft SNDS, and DMARC reports tell you whether the platform is working. No competitor in the cold outreach segment ingests all three. We do, and we surface the data next to each mailbox so a regression downgrades health before a customer notices replies dropping.' }, + { h: 'Conversion is meetings, not replies.', + b: 'A reply is a hint. A meeting is the conversion event. Calendly and Cal.com close the loop directly: the booking webhook joins the booking to the campaign that earned it, and the same campaign-level reporting that counts replies now counts meetings.' }, + { h: 'DNS belongs inside the dashboard.', + b: 'The worst part of mailbox onboarding is publishing three records in someone else\'s DNS console. Cloudflare, GoDaddy, and Namecheap let us write those records for the customer. We verify the API token before saving so the failure mode is at connect time, not the first failed send.' }, + ].map((c, i) => ( + <div class="rounded-[14px] bg-white ring-1 ring-[color:var(--border)] p-6 md:p-7"> + <div class="flex items-baseline gap-3 mb-3"> + <span class="font-mono text-[11px] text-[#0284c7] font-semibold tabular-nums">{String(i + 1).padStart(2, '0')}</span> + <div class="text-[16px] md:text-[17px] font-semibold tracking-[-0.015em] text-heading leading-snug">{c.h}</div> + </div> + <p class="text-[13.5px] text-foreground/70 leading-relaxed">{c.b}</p> + </div> ))} </div> </div> </section> - <!-- GROUPED INTEGRATION GRIDS --> - <section class="py-16 md:py-20"> - <div class="container-page space-y-16"> - {groups.map((g) => ( - <div id={g.id} class="scroll-mt-24"> - <div class="flex items-end justify-between mb-6"> - <div> - <div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground font-mono mb-2">{g.title}</div> - <h2 class="text-[22px] md:text-[28px] font-semibold tracking-[-0.02em] text-heading">{g.sub}</h2> - </div> - <div class="text-[12px] font-mono text-muted-foreground">{g.items.length} · integrations</div> - </div> + <!-- ============================================================ + HOW IT CONNECTS · 3-step diagram + ============================================================ --> + <section class="border-b border-[color:var(--border)] py-20 md:py-28"> + <div class="container-page"> + <div class="max-w-3xl mb-12"> + <div class="text-[11px] uppercase tracking-[0.18em] font-mono text-[#0284c7] mb-3">Connection model</div> + <h2 class="text-[32px] md:text-[44px] font-semibold tracking-[-0.03em] leading-[1.05] text-heading"> + Three steps. One drawer. + </h2> + <p class="mt-4 text-[15.5px] text-foreground/70 leading-relaxed"> + The dashboard\'s connect drawer is the same shape for every provider — only the fields change. OAuth providers launch the auth popup. Token providers paste a key. Inbound providers get a URL minted on save. + </p> + </div> - <div class="grid sm:grid-cols-2 lg:grid-cols-5 gap-px bg-[color:var(--border)] rounded-[14px] overflow-hidden ring-1 ring-[color:var(--border)]" style="box-shadow: var(--shadow-sm);"> - {g.items.map((it) => ( - <div class="bg-white p-5 flex flex-col"> - <div class="flex items-start justify-between"> - <div class="w-10 h-10 rounded-[10px] bg-[color:var(--sky-1)] text-[color:var(--sky-7)] ring-1 ring-[color:var(--sky-2)] inline-flex items-center justify-center text-[15px] font-semibold"> + <div class="rounded-[14px] bg-[#0c1224] ring-1 ring-white/10 overflow-hidden"> + <div class="px-6 py-3 border-b border-white/10 flex items-center justify-between text-[10.5px] font-mono text-white/55"> + <span>connect drawer · v1</span> + <span>same surface, every provider</span> + </div> + + <div class="grid md:grid-cols-3 divide-y md:divide-y-0 md:divide-x divide-white/10"> + {steps.map((s) => ( + <div class="p-7 md:p-9"> + <div class="flex items-baseline gap-3"> + <span class="font-mono text-[11.5px] text-[#7dd3fc] font-semibold tabular-nums tracking-[0.08em]">{s.n}</span> + <span class="text-[18px] md:text-[20px] font-semibold tracking-[-0.02em] text-white">{s.t}</span> + </div> + <p class="mt-3 text-[13.5px] text-white/65 leading-relaxed">{s.d}</p> + </div> + ))} + </div> + </div> + </div> + </section> + + <!-- ============================================================ + CATEGORY SECTIONS · iterate + ============================================================ --> + {categories.map((cat, idx) => ( + <section id={cat.id} class={`scroll-mt-4 py-20 md:py-28 ${idx % 2 === 0 ? 'bg-white' : 'border-y border-[color:var(--border)] bg-[color:var(--surface-1)]/40'}`}> + <div class="container-page"> + <div class="max-w-2xl mb-12"> + <div class="text-[11px] uppercase tracking-[0.18em] font-mono text-[#0284c7] mb-3">{cat.eyebrow}</div> + <h2 class="text-[32px] md:text-[44px] font-semibold tracking-[-0.03em] leading-[1.05] text-heading"> + {cat.headline} + </h2> + <p class="mt-4 text-[15.5px] text-foreground/70 leading-relaxed">{cat.body}</p> + </div> + + <div class="grid md:grid-cols-2 lg:grid-cols-3 gap-px bg-[color:var(--border)] rounded-[14px] overflow-hidden ring-1 ring-[color:var(--border)]" style="box-shadow: var(--shadow-sm);"> + {cat.items.map((it) => ( + <div class="bg-white p-6 flex flex-col"> + <div class="flex items-start justify-between gap-3"> + <div class="flex items-center gap-2.5"> + <div class="w-10 h-10 rounded-md bg-[color:var(--sky-1)] ring-1 ring-[color:var(--sky-2)] text-[#0369a1] inline-flex items-center justify-center text-[14px] font-semibold uppercase"> {it.name.charAt(0)} </div> - <span class={`inline-flex items-center gap-1 h-5 px-1.5 rounded text-[10px] font-medium font-mono uppercase tracking-[0.08em] ${statusChip(it.status)}`}> - <span class={`w-1 h-1 rounded-full ${statusDot(it.status)}`}></span> - {it.status} - </span> + <div> + <div class="text-[15px] font-semibold text-heading">{it.name}</div> + <div class="mt-0.5 text-[10.5px] uppercase tracking-[0.08em] text-muted-foreground font-mono"> + {it.auth}{it.beta && <span class="ml-1.5 text-amber-600">· beta</span>} + </div> + </div> </div> - <div class="mt-4 text-[14px] font-semibold text-heading">{it.name}</div> - <p class="mt-1 text-[12.5px] text-foreground/70 leading-relaxed">{it.body}</p> </div> - ))} + <p class="mt-3 text-[13px] text-foreground/65 leading-relaxed">{it.tagline}</p> + <p class="mt-4 pt-4 border-t border-[color:var(--border)] text-[12.5px] text-foreground/65 leading-relaxed">{it.body}</p> + </div> + ))} + </div> + </div> + </section> + ))} + + <!-- ============================================================ + WEBHOOKS + API · the layer everything else builds on + ============================================================ --> + <section class="bg-white py-20 md:py-28"> + <div class="container-page"> + <div class="grid lg:grid-cols-[1fr_1.8fr] gap-12 lg:gap-20 items-start"> + <div class="lg:sticky lg:top-24" style="align-self: start;"> + <div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground font-mono mb-3">Webhooks + API</div> + <h2 class="text-[28px] md:text-[40px] font-semibold tracking-[-0.025em] leading-[1.06] text-heading"> + One signed stream. Everything else builds on it. + </h2> + <p class="mt-4 text-[15px] text-foreground/70 leading-relaxed"> + The catalog is convenience. The webhook surface is the API. Eighteen event types, HMAC-SHA256 signed in the Stripe format, retry queue with exponential backoff, full delivery history per endpoint. If a provider is not in the catalog yet, your own subscriber can do anything any of the listed integrations does. + </p> + <ul class="mt-6 space-y-2 text-[13.5px] text-foreground/80"> + <li class="flex items-start gap-2"><Icon name="check" size={13} class="text-[#0284c7] mt-1 shrink-0" /><span><span class="font-mono">X-Warmbly-Signature: t=<unix>,v1=<hex></span> on every POST.</span></li> + <li class="flex items-start gap-2"><Icon name="check" size={13} class="text-[#0284c7] mt-1 shrink-0" /><span>Per-endpoint event-type filter. Subscribe selectively.</span></li> + <li class="flex items-start gap-2"><Icon name="check" size={13} class="text-[#0284c7] mt-1 shrink-0" /><span>Up to 8 attempts with capped exponential backoff. Full audit trail.</span></li> + <li class="flex items-start gap-2"><Icon name="check" size={13} class="text-[#0284c7] mt-1 shrink-0" /><span>Secret rotation invalidates the old secret immediately.</span></li> + </ul> + </div> + + <div class="space-y-5"> + <!-- Code block --> + <div class="rounded-[14px] bg-[#0c1224] ring-1 ring-white/10 overflow-hidden"> + <div class="px-5 py-2.5 border-b border-white/10 flex items-center justify-between text-[10.5px] font-mono text-white/55"> + <span>POST · your webhook endpoint</span> + <span>application/json</span> + </div> + <pre class="px-5 py-4 text-[12px] leading-relaxed text-white/85 font-mono whitespace-pre overflow-x-auto"><code>{`{ + "id": "f4a07e0c-a4b1-4dc8-9c5d-2c1b3e29c7b1", + "event_type": "campaign.reply_received", + "organization_id": "8c4e7c3d-...-", + "created_at": "2026-05-28T14:21:09Z", + "data": { + "source": "calendly", + "invitee_email": "lead@target.co", + "event_name": "30 min intro", + "scheduled_for": "2026-06-02T16:00:00Z", + "contact_id": "c1f...", + "booking_id": "b91...", + "trigger": "meeting_booked" + } +}`}</code></pre> + </div> + + <!-- Event list --> + <div class="rounded-[14px] bg-white ring-1 ring-[color:var(--border)] overflow-hidden"> + <div class="px-5 py-3 bg-[color:var(--surface-1)] border-b border-[color:var(--border)] flex items-baseline justify-between gap-2"> + <div class="text-[10.5px] uppercase tracking-[0.18em] font-mono text-muted-foreground">Event types</div> + <span class="text-[11px] font-mono text-foreground/55 tabular-nums">{webhookEvents.length} live</span> + </div> + <div class="divide-y divide-[color:var(--border)] max-h-[420px] overflow-y-auto"> + {webhookEvents.map((e) => ( + <div class="grid grid-cols-[minmax(0,1.4fr)_minmax(0,2fr)] gap-4 px-5 py-2.5"> + <code class="font-mono text-[11.5px] text-[#0369a1] truncate">{e.name}</code> + <span class="text-[12px] text-foreground/65 leading-snug">{e.desc}</span> + </div> + ))} + </div> </div> </div> - ))} + </div> + </div> + </section> + + <!-- ============================================================ + SECURITY · trust strip + ============================================================ --> + <section class="border-y border-[color:var(--border)] bg-[color:var(--surface-1)]/40 py-20 md:py-24"> + <div class="container-page"> + <div class="max-w-2xl mb-10"> + <div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground font-mono mb-3">Security</div> + <h2 class="text-[28px] md:text-[40px] font-semibold tracking-[-0.025em] leading-[1.06] text-heading"> + Credentials sit behind the same envelope as your mailbox tokens. + </h2> + <p class="mt-4 text-[15px] text-foreground/70 leading-relaxed"> + Integrations do not get a different security story than the rest of the platform — the OAuth tokens you connected your Gmail with already round-trip through KMS-wrapped per-user DEKs. Integration tokens follow the same path. + </p> + </div> + + <div class="grid md:grid-cols-2 lg:grid-cols-4 gap-px bg-[color:var(--border)] rounded-[14px] overflow-hidden ring-1 ring-[color:var(--border)]" style="box-shadow: var(--shadow-sm);"> + {security.map((s) => ( + <div class="bg-white p-6"> + <Icon name="check" size={14} class="text-[#0284c7]" /> + <div class="mt-3 text-[15px] font-semibold text-heading">{s.t}</div> + <p class="mt-2 text-[13px] text-foreground/65 leading-relaxed">{s.d}</p> + </div> + ))} + </div> + </div> + </section> + + <!-- ============================================================ + BY THE NUMBERS + ============================================================ --> + <section class="bg-white py-16 md:py-20"> + <div class="container-page"> + <div class="max-w-2xl mb-10"> + <div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground font-mono mb-3">By the numbers</div> + <h2 class="text-[26px] md:text-[34px] font-semibold tracking-[-0.025em] leading-[1.06] text-heading"> + Shape of the surface, in one strip. + </h2> + <p class="mt-3 text-[14px] text-foreground/65 leading-relaxed"> + Real values from the codebase. The catalog is short for the reason in the stance section above. + </p> + </div> + + <div class="grid grid-cols-2 md:grid-cols-4 gap-px bg-[color:var(--border)] rounded-[14px] overflow-hidden ring-1 ring-[color:var(--border)]"> + {numbers.map((n) => ( + <div class="bg-white p-5 md:p-6"> + <div class="flex items-baseline gap-1"> + <span class="text-[26px] md:text-[32px] font-semibold tracking-[-0.025em] font-mono text-heading">{n.v}</span> + {n.u && <span class="text-[12px] text-muted-foreground">{n.u}</span>} + </div> + <div class="mt-2 text-[11.5px] uppercase tracking-[0.14em] font-mono text-foreground/70">{n.l}</div> + <div class="mt-3 pt-3 border-t border-[color:var(--border)] text-[10.5px] font-mono text-muted-foreground truncate">{n.src}</div> + </div> + ))} + </div> + </div> + </section> + + <!-- ============================================================ + FAQ + ============================================================ --> + <section class="bg-[color:var(--surface-1)]/40 border-y border-[color:var(--border)] py-20 md:py-24"> + <div class="container-page grid lg:grid-cols-[1fr_1.8fr] gap-12 lg:gap-20 items-start"> + <div class="lg:sticky lg:top-24" style="align-self: start;"> + <div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground font-mono mb-3">Integrations FAQ</div> + <h2 class="text-[28px] md:text-[36px] font-semibold tracking-[-0.025em] leading-[1.08] text-heading"> + Five questions worth asking. + </h2> + <p class="mt-4 text-[14.5px] text-foreground/70 leading-relaxed"> + More depth in the <a class="text-[#0369a1] hover:text-[#075985] font-medium" href="/developers/">developer docs</a>. + </p> + </div> + + <div class="divide-y divide-[color:var(--border)]" data-faq> + {faq.map(([q, a]) => ( + <div class="faq-row py-2"> + <button type="button" class="faq-trigger group w-full flex items-start justify-between gap-4 py-3 text-left" aria-expanded="false"> + <span class="text-[15.5px] font-medium text-heading group-hover:text-[#0369a1] transition-colors">{q}</span> + <span class="faq-icon shrink-0 inline-flex items-center justify-center w-7 h-7 rounded-full bg-white ring-1 ring-[color:var(--border)] text-foreground/60 transition-transform duration-300 ease-out"> + <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v14"/><path d="M5 12h14"/></svg> + </span> + </button> + <div class="faq-panel grid grid-rows-[0fr] transition-[grid-template-rows] duration-300 ease-out"> + <div class="overflow-hidden"> + <p class="pb-4 pr-12 text-[14px] text-foreground/75 leading-relaxed max-w-2xl">{a}</p> + </div> + </div> + </div> + ))} + </div> + + <script is:inline> + (function () { + const faq = document.querySelector('[data-faq]'); + if (!faq) return; + faq.querySelectorAll('.faq-trigger').forEach(function (btn) { + btn.addEventListener('click', function () { + const row = btn.closest('.faq-row'); + const panel = row.querySelector('.faq-panel'); + const icon = btn.querySelector('.faq-icon'); + const open = btn.getAttribute('aria-expanded') === 'true'; + btn.setAttribute('aria-expanded', String(!open)); + panel.style.gridTemplateRows = open ? '0fr' : '1fr'; + if (icon) icon.style.transform = open ? 'rotate(0deg)' : 'rotate(45deg)'; + }); + }); + })(); + </script> </div> </section> <CTA - title="Need an integration we do not list?" - description="We ship the most-requested integrations every quarter. Tell us which one matters." - primaryLabel="Request integration" - primaryHref="/contact/?topic=integrations" + title="Wire it up. See what your stack already knew." + description="One drawer, three steps, no service desk ticket. Or read the developer docs and build your own." + primaryLabel="Connect a provider" + primaryHref="https://app.warmbly.com/register" secondaryLabel="See the API" secondaryHref="/developers/" /> From d1eeff2fbca9e3d772c2f7c13cde7f1154092f78 Mon Sep 17 00:00:00 2001 From: Matt <meszmatew@gmail.com> Date: Thu, 28 May 2026 08:36:10 +0200 Subject: [PATCH 05/13] refactor(site): drop editorializing from integrations page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strips the stance section, mock dashboard, 3-step model, "by the numbers" strip, and FAQ — leaves a clean catalog: hero, four category grids (Deliverability, DNS, Meetings, Data), and a Webhooks + API block with the real event types. Each integration card now shows its auth method explicitly. --- site/src/pages/integrations.astro | 677 +++++++----------------------- 1 file changed, 148 insertions(+), 529 deletions(-) diff --git a/site/src/pages/integrations.astro b/site/src/pages/integrations.astro index d978ca63..5d00d047 100644 --- a/site/src/pages/integrations.astro +++ b/site/src/pages/integrations.astro @@ -1,506 +1,235 @@ --- import Layout from '../layouts/Layout.astro'; -import HeroAtmosphere from '../components/HeroAtmosphere.astro'; +import Cloud from '../components/Cloud.astro'; import Icon from '../components/Icon.astro'; import CTA from '../components/CTA.astro'; -// ========================================================================= -// All providers wired in the backend's integration catalog. Keep parity -// with internal/app/integration/catalog.go — the API exposes the same -// list, so listing a provider here that doesn't exist there is a 1:1 -// promise of a broken connect button. -// ========================================================================= - -// Floating dashboard mock: live connection state for the hero. Numbers -// reflect the per-tier story we tell on the deliverability page. -const mockConnections = [ - { name: 'Cloudflare', cat: 'dns', state: 'connected', detail: 'acme.com · zone live', age: '2m ago' }, - { name: 'Google Postmaster', cat: 'deliverability', state: 'connected', detail: 'acme.com · pulled daily', age: '4h ago' }, - { name: 'Calendly', cat: 'meetings', state: 'connected', detail: '3 bookings attributed', age: '12m ago' }, - { name: 'DMARC reports', cat: 'deliverability', state: 'connected', detail: 'rua= forwarder live', age: '1h ago' }, - { name: 'Microsoft SNDS', cat: 'deliverability', state: 'connected', detail: '198.51.100.4 · green', age: '6h ago' }, - { name: 'Google Sheets', cat: 'data', state: 'pending', detail: 'Q2 outbound list · awaiting OAuth', age: '—' }, -]; - -const catTone = (c: string) => { - if (c === 'deliverability') return { dot: 'bg-emerald-500', chip: 'bg-emerald-50 text-emerald-700' }; - if (c === 'dns') return { dot: 'bg-sky-500', chip: 'bg-sky-50 text-sky-700' }; - if (c === 'meetings') return { dot: 'bg-rose-400', chip: 'bg-rose-50 text-rose-700' }; - return { dot: 'bg-amber-500', chip: 'bg-amber-50 text-amber-700' }; -}; - -const stateTone = (s: string) => { - if (s === 'connected') return { dot: 'bg-emerald-500', chip: 'bg-emerald-50 text-emerald-700' }; - if (s === 'pending') return { dot: 'bg-sky-500', chip: 'bg-sky-50 text-sky-700' }; - if (s === 'degraded') return { dot: 'bg-amber-500', chip: 'bg-amber-50 text-amber-700' }; - return { dot: 'bg-slate-400', chip: 'bg-slate-100 text-slate-600' }; -}; - -// ========================================================================= -// The categories themselves. Each one is a section on the page. -// ========================================================================= -const categories = [ +// Provider list mirrors internal/app/integration/catalog.go. +// Categories follow the model.IntegrationCategory enum. +const groups = [ { id: 'deliverability', - eyebrow: 'Deliverability moat', - headline: 'Provider truth, in the same dashboard you already live in.', - body: 'Most outreach tools stop at "we send mail." Warmbly pulls reputation, complaint, and authentication data straight from the providers and surfaces it next to each mailbox\'s health state.', + title: 'Deliverability', + sub: 'Provider-side reputation signals, ingested daily.', items: [ { name: 'Google Postmaster', - tagline: 'Domain reputation, spam-rate, and authentication ratios pulled daily.', - body: 'OAuth into Google Postmaster Tools and we pull domain reputation, IP reputation, SPF / DKIM / DMARC success ratios, and user-reported spam rate as one snapshot per day. The data joins the per-mailbox health state so a domain reputation drop downgrades all mailboxes sending on it.', + body: 'Domain reputation, spam-rate, and SPF / DKIM / DMARC success ratios from Gmail Postmaster Tools.', auth: 'OAuth', - beta: false, + status: 'live', }, { name: 'Microsoft SNDS', - tagline: 'IP reputation + complaint-rate bucket for Outlook + Hotmail.', - body: 'Paste your SNDS data-access key. We poll the CSV daily, parse the filter-result and complaint-rate columns, and translate them into the same green / yellow / red reputation tier the Postmaster integration uses, so the two providers are comparable on the same axis.', + body: 'IP reputation and complaint-rate bucket for Outlook and Hotmail.', auth: 'API key', - beta: false, + status: 'live', }, { name: 'DMARC reports', - tagline: 'Ingest aggregate (RUA) reports and flag misaligned senders.', - body: 'We mint a per-org URL you point your rua= forwarder at. Every XML report is parsed, deduped, and broken out per source IP, so an unexpected forwarder breaking SPF alignment surfaces with a hostname, not just a percentage. No third-party DMARC service required.', - auth: 'Webhook', - beta: false, + body: 'Aggregate (RUA) XML ingestion. Per-source-IP breakdown of SPF and DKIM alignment.', + auth: 'Webhook URL', + status: 'live', }, ], }, { id: 'dns', - eyebrow: 'One-click DNS', - headline: 'SPF, DKIM, DMARC, and the tracking CNAME, written for you.', - body: 'A new mailbox should take a minute to configure, not an afternoon of DNS console wrangling. Connect a DNS provider once and the records publish from inside the dashboard.', + title: 'DNS providers', + sub: 'Publish SPF, DKIM, DMARC, and the tracking CNAME from inside the dashboard.', items: [ { name: 'Cloudflare', - tagline: 'Zone-scoped API token. We verify before saving.', - body: 'Paste a token scoped to Zone:DNS:Edit. We verify it round-trips before we persist it, look up your zone ID, and publish the three records we recommend. If a record already exists, we surface the diff before overwriting.', + body: 'Zone-scoped API token. Verified on save before any record is written.', auth: 'API token', - beta: false, + status: 'live', }, { name: 'GoDaddy', - tagline: 'Write records from the dashboard with your API key:secret pair.', - body: 'GoDaddy\'s developer portal mints a key:secret. We accept both, persist them encrypted, and publish the three records on save. Reads are batched so a GoDaddy-side rate limit never partially-applies a record set.', - auth: 'API key:secret', - beta: true, + body: 'API key:secret pair from the GoDaddy developer portal.', + auth: 'API key', + status: 'beta', }, { name: 'Namecheap', - tagline: 'Same one-click setup, Namecheap API edition.', - body: 'Namecheap requires an IP allowlist on their API endpoint — we surface the egress IP you need to allow inside the connect drawer so you do not have to read their docs. Once saved, record writes round-trip in seconds.', + body: 'API token with egress IP allowlist surfaced in the connect drawer.', auth: 'API token', - beta: true, + status: 'beta', }, ], }, { id: 'meetings', - eyebrow: 'Meeting attribution', - headline: 'Stop reporting on replies. Start reporting on meetings.', - body: 'A reply is a hint. A meeting on the calendar is the real conversion event. We close the loop by accepting booking webhooks and joining them to the campaign that triggered the contact.', + title: 'Meeting bookings', + sub: 'Attribute booked meetings to the campaign that surfaced the lead.', items: [ { name: 'Calendly', - tagline: 'invitee.created webhook, joined to the campaign that surfaced the lead.', - body: 'Paste the URL we mint into Calendly\'s webhook UI. When a recipient books, we record the booking, look up the campaign that triggered them, and fire a campaign.reply_received webhook to your stack with trigger=meeting_booked so downstream dashboards know which campaign earned the meeting.', - auth: 'Webhook', - beta: false, + body: 'invitee.created webhook. Joined to the campaign that originated the contact.', + auth: 'Webhook URL', + status: 'live', }, { name: 'Cal.com', - tagline: 'Same attribution path. Open-source booking edition.', - body: 'Cal.com\'s BOOKING_CREATED event uses a different JSON shape than Calendly. We normalize both into one MeetingBooking record so reporting code does not branch by source. Self-hosted Cal.com works the same way.', - auth: 'Webhook', - beta: false, + body: 'BOOKING_CREATED webhook. Cloud and self-hosted both supported.', + auth: 'Webhook URL', + status: 'live', }, ], }, { id: 'data', - eyebrow: 'Data + sheets', - headline: 'Where your operators already keep the lists.', - body: 'Lead lists do not live in your dashboard — they live in a spreadsheet. Warmbly\'s sheets integration is two-way: we pull lead rows in for a campaign and write the status of each row back as it sends, replies, bounces, or books.', + title: 'Data', + sub: 'Lead lists in, status updates out.', items: [ { name: 'Google Sheets', - tagline: 'Pull leads in. Push send / reply / bounce / booked events back.', - body: 'OAuth a Google account with Sheets scope. Point us at a sheet ID — we read rows starting from row 2 (header convention) and append a status column to the right. The same sheet becomes both your lead source and your real-time report.', + body: 'Two-way: read rows for a campaign, append send / reply / bounce / booked events back.', auth: 'OAuth', - beta: true, + status: 'beta', }, ], }, ]; -// ========================================================================= -// Webhook events we emit. Real event types from internal/models/webhook.go. -// ========================================================================= +const statusChip = (s: string) => { + if (s === 'live') return 'bg-emerald-50 text-emerald-700'; + if (s === 'beta') return 'bg-amber-50 text-amber-700'; + return 'bg-slate-100 text-slate-600'; +}; +const statusDot = (s: string) => { + if (s === 'live') return 'bg-emerald-500'; + if (s === 'beta') return 'bg-amber-500'; + return 'bg-slate-400'; +}; + +// Real event types from internal/models/webhook.go. const webhookEvents = [ - { name: 'campaign.email_sent', desc: 'A campaign step dispatched to a recipient.' }, - { name: 'campaign.email_delivered', desc: 'Receiver acknowledged delivery (250 OK or DSN-equivalent).' }, - { name: 'campaign.email_opened', desc: 'Open pixel resolved. Note: open data is unreliable at major receivers.' }, - { name: 'campaign.email_clicked', desc: 'Tracked link clicked. Dedupe applied per recipient.' }, - { name: 'campaign.email_bounced', desc: 'Hard or soft bounce. Suppression follows automatically.' }, - { name: 'campaign.reply_received', desc: 'Recipient replied, or a meeting was booked via Calendly/Cal.com.' }, - { name: 'campaign.unsubscribed', desc: 'One-click unsubscribe or inbound STOP/REMOVE reply.' }, - { name: 'campaign.started', desc: 'Campaign moved into the running state.' }, - { name: 'campaign.paused', desc: 'Campaign auto-paused on bounce/complaint spike or manual pause.' }, - { name: 'campaign.completed', desc: 'Last sequence step dispatched for the last recipient.' }, - { name: 'warmup.health_changed', desc: 'A mailbox transitioned between healthy / watch / throttled / quarantined / blocked.' }, - { name: 'warmup.placement_in_spam', desc: 'A warmup probe landed in junk on a recipient mailbox.' }, - { name: 'warmup.quarantined', desc: 'Mailbox dropped to the recovery pool. 7-day cooldown.' }, - { name: 'warmup.blocked', desc: 'Mailbox hard-blocked from the shared pool. 30-day cooldown.' }, - { name: 'deliverability.bounce', desc: 'External deliverability event ingested (SES, Postmark, etc.).' }, - { name: 'deliverability.complaint', desc: 'External complaint event ingested (FBL, ARF, in-product reports).' }, - { name: 'email_account.connected', desc: 'A new mailbox finished onboarding.' }, - { name: 'email_account.removed', desc: 'A mailbox was removed from the workspace.' }, -]; - -// ========================================================================= -// "How it connects" three-step diagram source. Visual story for the hero. -// ========================================================================= -const steps = [ - { n: '01', t: 'Authenticate', d: 'OAuth, paste an API token, or copy a webhook URL we mint per-org. Inbound providers like Calendly need nothing else.' }, - { n: '02', t: 'Route', d: 'The connection joins your org\'s integration table. Inbound traffic routes by the secret in the URL path. Outbound traffic uses the encrypted token.' }, - { n: '03', t: 'Live', d: 'Status flips to connected. The dashboard surfaces the last sync, last error, and a one-click rotate. Disconnect cascades to dependent data.' }, -]; - -// ========================================================================= -// "By the numbers" strip. Provider count, event count. -// ========================================================================= -const numbers = [ - { v: '9', u: 'providers', l: 'Calendly, Cal.com, Sheets, Postmaster, SNDS, DMARC, Cloudflare, GoDaddy, Namecheap', src: 'integration/catalog.go' }, - { v: '18', u: 'webhook events', l: 'campaign · warmup · deliverability · account lifecycle', src: 'models/webhook.go' }, - { v: 'HMAC-SHA256', u: '', l: 'every outbound webhook is signed with t=<unix>,v1=<hex>', src: 'app/webhook/service.go' }, - { v: '8', u: 'max attempts', l: 'exponential backoff, capped at 1h, abandoned after ~2h', src: 'app/webhook/service.go' }, -]; - -// ========================================================================= -// Security strip. Threading the KMS envelope story so trust is not a -// separate page-load. -// ========================================================================= -const security = [ - { t: 'OAuth tokens encrypted with AES-256-GCM', d: 'Per-user data encryption keys (DEKs) wrapped with AWS KMS. The encrypted blob lands in DynamoDB; plaintext never persists outside a TTL-bounded Redis cache.' }, - { t: 'API tokens stored as opaque blobs', d: 'Cloudflare / GoDaddy / Namecheap / SNDS keys never serialize back to the API consumer. We expose only public display fields (zone name, IP, domain) for the dashboard.' }, - { t: 'Inbound URLs carry per-org secrets', d: 'A leaked URL only affects one organization. Rotation regenerates the secret and invalidates the old one immediately.' }, - { t: 'Outbound delivery audit trail', d: 'Every dispatch attempt is persisted with response status and body excerpt. Re-replay supported. SKIP LOCKED prevents duplicate fanout across replicas.' }, -]; - -// ========================================================================= -// FAQ. Real product behaviour. -// ========================================================================= -const faq = [ - ['Do you support OAuth for everything?', - 'No. OAuth is the right choice for providers that expose a per-user identity (Google Sheets, Google Postmaster). For provider-account-scoped credentials (Cloudflare API token, SNDS data-access key) a token is both simpler and safer. The connect drawer surfaces the right method per provider.'], - ['What happens to the connection if a token expires?', - 'The status flips to degraded and the dashboard shows the provider error. We do not silently retry forever — degraded connections stop attempting new fanout until the user re-authenticates or rotates.'], - ['Can I have more than one of the same provider?', - 'Yes. The connection is unique per (org, provider, label). Useful when one organization sends from two domains and you want one Cloudflare connection per zone instead of cramming both into one token.'], - ['How fast is the inbound webhook path?', - 'Calendly/Cal.com/DMARC POSTs are accepted, persisted, and acknowledged in the same request. The fan-out to your outbound webhook subscribers happens through the same queue our internal events use, so the latency budget is bounded by the queue tick (default 2s).'], - ['What if I do not want a provider you list?', - 'You can subscribe to the raw webhook stream and build whatever integration you want on top of it. The 18 event types cover every state transition we emit. The catalog is a curated convenience layer — the API surface underneath is open.'], + 'campaign.email_sent', + 'campaign.email_delivered', + 'campaign.email_opened', + 'campaign.email_clicked', + 'campaign.email_bounced', + 'campaign.reply_received', + 'campaign.unsubscribed', + 'campaign.started', + 'campaign.paused', + 'campaign.completed', + 'warmup.health_changed', + 'warmup.placement_in_spam', + 'warmup.quarantined', + 'warmup.blocked', + 'deliverability.bounce', + 'deliverability.complaint', + 'email_account.connected', + 'email_account.removed', ]; --- <Layout title="Integrations · Warmbly" - description="Tier 1 + Tier 2 integrations for cold outreach: Calendly, Cal.com, Google Sheets, Google Postmaster, Microsoft SNDS, DMARC, Cloudflare, GoDaddy, Namecheap. Plus a hardened webhook + API surface." + description="Deliverability, DNS, meeting booking, and data integrations for Warmbly. Plus an HMAC-signed webhook stream." > - <!-- ============================================================ - HERO · HeroAtmosphere, matches deliverability/warmup/developers - ============================================================ --> - <section class="relative isolate overflow-hidden"> - <HeroAtmosphere /> + <!-- HERO --> + <section class="relative isolate overflow-hidden text-white" style="background: radial-gradient(ellipse 130% 140% at 72% 28%, #0284c7 0%, #0369a1 25%, #075985 50%, #0c4a6e 80%, #0a3d5c 100%);"> + <div class="absolute top-0 right-0 w-[420px] opacity-30 pointer-events-none cloud-drift cloud-1" style="mix-blend-mode: screen;"> + <Cloud variant={5} opacity={1} /> + </div> - <div class="container-page relative pt-16 md:pt-24 pb-44 md:pb-56 text-center"> - <div class="inline-flex items-center gap-2 h-7 pl-1 pr-3 rounded-full bg-white/15 backdrop-blur ring-1 ring-white/25 text-[12px] text-white/95 shadow-[0_4px_14px_-4px_rgba(0,0,0,0.25)]"> - <span class="inline-flex items-center h-5 px-1.5 rounded-full text-[10.5px] font-semibold uppercase tracking-[0.06em] bg-white" style="color:#0369a1;"> - Integrations - </span> - Built around the moat, not the marketplace - </div> - - <h1 class="mt-8 md:mt-10 text-[44px] sm:text-6xl md:text-[72px] lg:text-[80px] font-semibold tracking-[-0.04em] leading-[0.98] text-white max-w-4xl mx-auto"> - Connect what already<br/>runs your stack. + <div class="container-page relative pt-14 pb-12"> + <div class="text-[11px] uppercase tracking-[0.18em] text-white/55 font-mono mb-3">Integrations</div> + <h1 class="text-[32px] md:text-[48px] font-semibold tracking-[-0.025em] leading-[1.06] text-white max-w-3xl"> + Connect what you already use. </h1> - <p class="mt-6 text-[17px] md:text-[19px] text-white/80 max-w-2xl mx-auto leading-relaxed"> - Postmaster + SNDS for provider truth. DMARC ingestion for alignment. Cloudflare-class one-click DNS. Calendly attribution for the conversion event that actually pays the bill. Plus a signed webhook stream the rest builds on. - </p> - - <div class="mt-8 flex flex-wrap items-center justify-center gap-3"> - <a href="https://app.warmbly.com/register" class="inline-flex h-11 px-5 items-center gap-2 rounded-[10px] text-[14.5px] font-semibold bg-white hover:bg-white hover:-translate-y-0.5 hover:shadow-[0_12px_28px_-6px_rgba(0,0,0,0.3)] transition-all duration-200 ease-out shadow-[0_4px_14px_-2px_rgba(0,0,0,0.18)]"> - <span style="color:#075985;">Connect a provider</span> - <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#075985" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> - <path d="M5 12h14"/><path d="m12 5 7 7-7 7"/> - </svg> - </a> - <a href="#deliverability" class="inline-flex h-11 px-5 items-center rounded-[10px] text-[14.5px] font-medium bg-white/10 backdrop-blur ring-1 ring-white/40 text-white hover:bg-white/20 hover:-translate-y-0.5 transition-all duration-200 ease-out"> - See the moat + <div class="mt-8 flex flex-wrap gap-2"> + {groups.map((g) => ( + <a href={`#${g.id}`} class="inline-flex items-center gap-2 h-8 px-3 rounded-full bg-white/10 backdrop-blur ring-1 ring-white/20 text-[12.5px] text-white/90 hover:bg-white/20"> + {g.title} + <span class="font-mono text-[10.5px] text-white/55">{g.items.length}</span> + </a> + ))} + <a href="#webhooks" class="inline-flex items-center gap-2 h-8 px-3 rounded-full bg-white/10 backdrop-blur ring-1 ring-white/20 text-[12.5px] text-white/90 hover:bg-white/20"> + Webhooks + <span class="font-mono text-[10.5px] text-white/55">{webhookEvents.length}</span> </a> </div> </div> </section> - <!-- ============================================================ - FLOATING DASHBOARD · connection state mock - ============================================================ --> - <section class="relative -mt-36 md:-mt-44 pb-16 md:pb-24 z-10"> - <div class="container-page"> - <div class="rounded-[18px] bg-white ring-1 ring-[color:var(--border)] overflow-hidden shadow-[0_40px_90px_-30px_rgba(2,32,71,0.35),0_12px_30px_-12px_rgba(15,23,42,0.12)]"> - - <!-- Topbar --> - <div class="px-6 py-3 border-b border-[color:var(--border)] flex items-baseline justify-between gap-3"> - <div class="flex items-baseline gap-3"> - <div class="text-[10.5px] font-mono uppercase tracking-[0.18em] text-muted-foreground">Integrations</div> - <span class="text-[11.5px] text-foreground/55">5 connected · 1 pending · 0 degraded</span> - </div> - <div class="flex items-center gap-2"> - <span class="inline-flex items-center h-6 px-2 rounded-md text-[10.5px] font-mono text-[#0369a1] bg-[color:var(--sky-1)]">live</span> - </div> - </div> - - <!-- Filter chips --> - <div class="grid grid-cols-2 md:grid-cols-4 divide-x divide-[color:var(--border)] border-b border-[color:var(--border)]"> - {[ - { l: 'Deliverability', n: '3', c: 'bg-emerald-500' }, - { l: 'DNS', n: '1', c: 'bg-sky-500' }, - { l: 'Meetings', n: '1', c: 'bg-rose-400' }, - { l: 'Data', n: '1', c: 'bg-amber-500' }, - ].map((s) => ( - <div class="px-5 py-3.5 flex items-baseline justify-between"> - <div class="flex items-center gap-2"> - <span class={`w-2 h-2 rounded-full ${s.c}`}></span> - <span class="text-[11.5px] uppercase tracking-[0.14em] font-mono text-foreground/70">{s.l}</span> - </div> - <span class="text-[14.5px] font-mono font-semibold text-heading tabular-nums">{s.n}</span> + <!-- GROUPED INTEGRATION GRIDS --> + <section class="py-16 md:py-20"> + <div class="container-page space-y-16"> + {groups.map((g) => ( + <div id={g.id} class="scroll-mt-24"> + <div class="flex items-end justify-between mb-6"> + <div> + <div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground font-mono mb-2">{g.title}</div> + <h2 class="text-[22px] md:text-[28px] font-semibold tracking-[-0.02em] text-heading">{g.sub}</h2> </div> - ))} - </div> + <div class="text-[12px] font-mono text-muted-foreground">{g.items.length} · integrations</div> + </div> - <!-- Connections grid --> - <div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-px bg-[color:var(--border)]"> - {mockConnections.map((c) => { - const ct = catTone(c.cat); - const st = stateTone(c.state); - return ( + <div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-px bg-[color:var(--border)] rounded-[14px] overflow-hidden ring-1 ring-[color:var(--border)]" style="box-shadow: var(--shadow-sm);"> + {g.items.map((it) => ( <div class="bg-white p-5 flex flex-col"> <div class="flex items-start justify-between gap-3"> - <div class="flex items-center gap-2.5"> - <div class="w-9 h-9 rounded-md bg-[color:var(--sky-1)] ring-1 ring-[color:var(--sky-2)] text-[#0369a1] inline-flex items-center justify-center text-[13px] font-semibold uppercase"> - {c.name.charAt(0)} - </div> - <div> - <div class="text-[13px] font-semibold text-heading">{c.name}</div> - <span class={`mt-0.5 inline-flex items-center gap-1 h-4 px-1.5 rounded text-[9.5px] uppercase tracking-[0.08em] font-medium font-mono ${ct.chip}`}> - <span class={`w-1 h-1 rounded-full ${ct.dot}`}></span>{c.cat} - </span> - </div> - </div> - <span class={`inline-flex items-center gap-1 h-5 px-1.5 rounded text-[10px] uppercase tracking-[0.08em] font-medium ${st.chip}`}> - <span class={`w-1 h-1 rounded-full ${st.dot}`}></span>{c.state} - </span> - </div> - <div class="mt-3 text-[12px] text-foreground/70 leading-relaxed">{c.detail}</div> - <div class="mt-auto pt-3 flex items-center justify-between text-[10.5px] font-mono text-muted-foreground"> - <span>last sync</span> - <span class="text-foreground/75">{c.age}</span> - </div> - </div> - ); - })} - </div> - - <!-- Footer note --> - <div class="px-6 py-3 border-t border-[color:var(--border)] bg-[color:var(--surface-1)]/60 flex items-center justify-between text-[11px] font-mono text-muted-foreground"> - <span>9 providers in the catalog · 18 webhook event types</span> - <span class="inline-flex items-center gap-1.5 text-foreground/55"> - <span class="w-1.5 h-1.5 rounded-full bg-emerald-500"></span> - evaluated every event - </span> - </div> - </div> - <div class="mt-3 text-center text-[11.5px] font-mono text-muted-foreground"> - app.warmbly.com/integrations - </div> - </div> - </section> - - <!-- ============================================================ - STANCE · what is actually different here - ============================================================ --> - <section class="border-y border-[color:var(--border)] bg-[color:var(--surface-1)]/40 py-20 md:py-28"> - <div class="container-page"> - <div class="max-w-3xl mb-12"> - <div class="text-[11px] uppercase tracking-[0.18em] font-mono text-[#0284c7] mb-3">Stance</div> - <h2 class="text-[32px] md:text-[44px] font-semibold tracking-[-0.03em] leading-[1.05] text-heading"> - Why this list is short on purpose. - </h2> - <p class="mt-4 text-[15.5px] text-foreground/70 leading-relaxed"> - Every cold-outreach platform competes on the length of an integrations grid. We made the opposite call. Nine providers, all directly load-bearing for deliverability, DNS, meetings, or operator workflow. Everything else is a Zapier/Make/n8n template away. - </p> - </div> - - <div class="grid md:grid-cols-2 gap-4 lg:gap-5"> - {[ - { h: 'A long list of CRM logos is a parity game.', - b: 'Customers compare "HubSpot · Salesforce · Pipedrive · Close · HighLevel" on every vendor page. The truth is most of these turn into a one-way push the first week. We would rather build one CRM connection that is genuinely two-way than ten that look the same on a logo wall.' }, - { h: 'The moat is provider truth, not provider count.', - b: 'Google Postmaster, Microsoft SNDS, and DMARC reports tell you whether the platform is working. No competitor in the cold outreach segment ingests all three. We do, and we surface the data next to each mailbox so a regression downgrades health before a customer notices replies dropping.' }, - { h: 'Conversion is meetings, not replies.', - b: 'A reply is a hint. A meeting is the conversion event. Calendly and Cal.com close the loop directly: the booking webhook joins the booking to the campaign that earned it, and the same campaign-level reporting that counts replies now counts meetings.' }, - { h: 'DNS belongs inside the dashboard.', - b: 'The worst part of mailbox onboarding is publishing three records in someone else\'s DNS console. Cloudflare, GoDaddy, and Namecheap let us write those records for the customer. We verify the API token before saving so the failure mode is at connect time, not the first failed send.' }, - ].map((c, i) => ( - <div class="rounded-[14px] bg-white ring-1 ring-[color:var(--border)] p-6 md:p-7"> - <div class="flex items-baseline gap-3 mb-3"> - <span class="font-mono text-[11px] text-[#0284c7] font-semibold tabular-nums">{String(i + 1).padStart(2, '0')}</span> - <div class="text-[16px] md:text-[17px] font-semibold tracking-[-0.015em] text-heading leading-snug">{c.h}</div> - </div> - <p class="text-[13.5px] text-foreground/70 leading-relaxed">{c.b}</p> - </div> - ))} - </div> - </div> - </section> - - <!-- ============================================================ - HOW IT CONNECTS · 3-step diagram - ============================================================ --> - <section class="border-b border-[color:var(--border)] py-20 md:py-28"> - <div class="container-page"> - <div class="max-w-3xl mb-12"> - <div class="text-[11px] uppercase tracking-[0.18em] font-mono text-[#0284c7] mb-3">Connection model</div> - <h2 class="text-[32px] md:text-[44px] font-semibold tracking-[-0.03em] leading-[1.05] text-heading"> - Three steps. One drawer. - </h2> - <p class="mt-4 text-[15.5px] text-foreground/70 leading-relaxed"> - The dashboard\'s connect drawer is the same shape for every provider — only the fields change. OAuth providers launch the auth popup. Token providers paste a key. Inbound providers get a URL minted on save. - </p> - </div> - - <div class="rounded-[14px] bg-[#0c1224] ring-1 ring-white/10 overflow-hidden"> - <div class="px-6 py-3 border-b border-white/10 flex items-center justify-between text-[10.5px] font-mono text-white/55"> - <span>connect drawer · v1</span> - <span>same surface, every provider</span> - </div> - - <div class="grid md:grid-cols-3 divide-y md:divide-y-0 md:divide-x divide-white/10"> - {steps.map((s) => ( - <div class="p-7 md:p-9"> - <div class="flex items-baseline gap-3"> - <span class="font-mono text-[11.5px] text-[#7dd3fc] font-semibold tabular-nums tracking-[0.08em]">{s.n}</span> - <span class="text-[18px] md:text-[20px] font-semibold tracking-[-0.02em] text-white">{s.t}</span> - </div> - <p class="mt-3 text-[13.5px] text-white/65 leading-relaxed">{s.d}</p> - </div> - ))} - </div> - </div> - </div> - </section> - - <!-- ============================================================ - CATEGORY SECTIONS · iterate - ============================================================ --> - {categories.map((cat, idx) => ( - <section id={cat.id} class={`scroll-mt-4 py-20 md:py-28 ${idx % 2 === 0 ? 'bg-white' : 'border-y border-[color:var(--border)] bg-[color:var(--surface-1)]/40'}`}> - <div class="container-page"> - <div class="max-w-2xl mb-12"> - <div class="text-[11px] uppercase tracking-[0.18em] font-mono text-[#0284c7] mb-3">{cat.eyebrow}</div> - <h2 class="text-[32px] md:text-[44px] font-semibold tracking-[-0.03em] leading-[1.05] text-heading"> - {cat.headline} - </h2> - <p class="mt-4 text-[15.5px] text-foreground/70 leading-relaxed">{cat.body}</p> - </div> - - <div class="grid md:grid-cols-2 lg:grid-cols-3 gap-px bg-[color:var(--border)] rounded-[14px] overflow-hidden ring-1 ring-[color:var(--border)]" style="box-shadow: var(--shadow-sm);"> - {cat.items.map((it) => ( - <div class="bg-white p-6 flex flex-col"> - <div class="flex items-start justify-between gap-3"> - <div class="flex items-center gap-2.5"> - <div class="w-10 h-10 rounded-md bg-[color:var(--sky-1)] ring-1 ring-[color:var(--sky-2)] text-[#0369a1] inline-flex items-center justify-center text-[14px] font-semibold uppercase"> + <div class="w-10 h-10 rounded-[10px] bg-[color:var(--sky-1)] text-[color:var(--sky-7)] ring-1 ring-[color:var(--sky-2)] inline-flex items-center justify-center text-[15px] font-semibold"> {it.name.charAt(0)} </div> - <div> - <div class="text-[15px] font-semibold text-heading">{it.name}</div> - <div class="mt-0.5 text-[10.5px] uppercase tracking-[0.08em] text-muted-foreground font-mono"> - {it.auth}{it.beta && <span class="ml-1.5 text-amber-600">· beta</span>} - </div> - </div> + <span class={`inline-flex items-center gap-1 h-5 px-1.5 rounded text-[10px] font-medium font-mono uppercase tracking-[0.08em] ${statusChip(it.status)}`}> + <span class={`w-1 h-1 rounded-full ${statusDot(it.status)}`}></span> + {it.status} + </span> + </div> + <div class="mt-4 text-[14px] font-semibold text-heading">{it.name}</div> + <p class="mt-1 text-[12.5px] text-foreground/70 leading-relaxed">{it.body}</p> + <div class="mt-3 pt-3 border-t border-[color:var(--border)] text-[11px] font-mono uppercase tracking-[0.08em] text-muted-foreground"> + {it.auth} </div> </div> - <p class="mt-3 text-[13px] text-foreground/65 leading-relaxed">{it.tagline}</p> - <p class="mt-4 pt-4 border-t border-[color:var(--border)] text-[12.5px] text-foreground/65 leading-relaxed">{it.body}</p> - </div> - ))} + ))} + </div> </div> - </div> - </section> - ))} + ))} - <!-- ============================================================ - WEBHOOKS + API · the layer everything else builds on - ============================================================ --> - <section class="bg-white py-20 md:py-28"> - <div class="container-page"> - <div class="grid lg:grid-cols-[1fr_1.8fr] gap-12 lg:gap-20 items-start"> - <div class="lg:sticky lg:top-24" style="align-self: start;"> - <div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground font-mono mb-3">Webhooks + API</div> - <h2 class="text-[28px] md:text-[40px] font-semibold tracking-[-0.025em] leading-[1.06] text-heading"> - One signed stream. Everything else builds on it. - </h2> - <p class="mt-4 text-[15px] text-foreground/70 leading-relaxed"> - The catalog is convenience. The webhook surface is the API. Eighteen event types, HMAC-SHA256 signed in the Stripe format, retry queue with exponential backoff, full delivery history per endpoint. If a provider is not in the catalog yet, your own subscriber can do anything any of the listed integrations does. - </p> - <ul class="mt-6 space-y-2 text-[13.5px] text-foreground/80"> - <li class="flex items-start gap-2"><Icon name="check" size={13} class="text-[#0284c7] mt-1 shrink-0" /><span><span class="font-mono">X-Warmbly-Signature: t=<unix>,v1=<hex></span> on every POST.</span></li> - <li class="flex items-start gap-2"><Icon name="check" size={13} class="text-[#0284c7] mt-1 shrink-0" /><span>Per-endpoint event-type filter. Subscribe selectively.</span></li> - <li class="flex items-start gap-2"><Icon name="check" size={13} class="text-[#0284c7] mt-1 shrink-0" /><span>Up to 8 attempts with capped exponential backoff. Full audit trail.</span></li> - <li class="flex items-start gap-2"><Icon name="check" size={13} class="text-[#0284c7] mt-1 shrink-0" /><span>Secret rotation invalidates the old secret immediately.</span></li> - </ul> + <!-- Webhooks + API --> + <div id="webhooks" class="scroll-mt-24"> + <div class="flex items-end justify-between mb-6"> + <div> + <div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground font-mono mb-2">Webhooks & API</div> + <h2 class="text-[22px] md:text-[28px] font-semibold tracking-[-0.02em] text-heading"> + Build your own with a signed event stream. + </h2> + </div> + <div class="text-[12px] font-mono text-muted-foreground">{webhookEvents.length} · event types</div> </div> - <div class="space-y-5"> - <!-- Code block --> - <div class="rounded-[14px] bg-[#0c1224] ring-1 ring-white/10 overflow-hidden"> - <div class="px-5 py-2.5 border-b border-white/10 flex items-center justify-between text-[10.5px] font-mono text-white/55"> - <span>POST · your webhook endpoint</span> - <span>application/json</span> - </div> - <pre class="px-5 py-4 text-[12px] leading-relaxed text-white/85 font-mono whitespace-pre overflow-x-auto"><code>{`{ - "id": "f4a07e0c-a4b1-4dc8-9c5d-2c1b3e29c7b1", - "event_type": "campaign.reply_received", - "organization_id": "8c4e7c3d-...-", - "created_at": "2026-05-28T14:21:09Z", - "data": { - "source": "calendly", - "invitee_email": "lead@target.co", - "event_name": "30 min intro", - "scheduled_for": "2026-06-02T16:00:00Z", - "contact_id": "c1f...", - "booking_id": "b91...", - "trigger": "meeting_booked" - } -}`}</code></pre> + <div class="grid lg:grid-cols-[1.2fr_1fr] gap-px bg-[color:var(--border)] rounded-[14px] overflow-hidden ring-1 ring-[color:var(--border)]"> + <div class="bg-white p-6"> + <p class="text-[13.5px] text-foreground/70 leading-relaxed"> + Every outbound webhook is signed with HMAC-SHA256 in the Stripe format + (<span class="font-mono text-[12.5px]">t=<unix>,v1=<hex></span>), + retried with exponential backoff up to 8 attempts, and recorded with + full delivery history per endpoint. Subscribe selectively by event type + or accept all events. + </p> + <ul class="mt-5 space-y-2 text-[13px] text-foreground/80"> + <li class="flex items-start gap-2"><Icon name="check" size={13} class="text-[#0284c7] mt-1 shrink-0" /><span>HMAC-SHA256 signature header on every POST.</span></li> + <li class="flex items-start gap-2"><Icon name="check" size={13} class="text-[#0284c7] mt-1 shrink-0" /><span>Per-endpoint event-type filter.</span></li> + <li class="flex items-start gap-2"><Icon name="check" size={13} class="text-[#0284c7] mt-1 shrink-0" /><span>Secret rotation invalidates the old secret immediately.</span></li> + <li class="flex items-start gap-2"><Icon name="check" size={13} class="text-[#0284c7] mt-1 shrink-0" /><span>Delivery history retained per endpoint for debugging.</span></li> + </ul> + <a href="/developers/" class="mt-5 inline-flex items-center gap-1.5 h-8 px-3 rounded-md bg-[#0369a1] hover:bg-[#075985] text-white text-[12.5px] font-medium transition-colors"> + Read the API docs + <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg> + </a> </div> - <!-- Event list --> - <div class="rounded-[14px] bg-white ring-1 ring-[color:var(--border)] overflow-hidden"> - <div class="px-5 py-3 bg-[color:var(--surface-1)] border-b border-[color:var(--border)] flex items-baseline justify-between gap-2"> - <div class="text-[10.5px] uppercase tracking-[0.18em] font-mono text-muted-foreground">Event types</div> - <span class="text-[11px] font-mono text-foreground/55 tabular-nums">{webhookEvents.length} live</span> + <div class="bg-white"> + <div class="px-5 py-2.5 border-b border-[color:var(--border)] bg-[color:var(--surface-1)] text-[10.5px] uppercase tracking-[0.18em] font-mono text-muted-foreground"> + Event types </div> - <div class="divide-y divide-[color:var(--border)] max-h-[420px] overflow-y-auto"> + <div class="divide-y divide-[color:var(--border)] max-h-[360px] overflow-y-auto"> {webhookEvents.map((e) => ( - <div class="grid grid-cols-[minmax(0,1.4fr)_minmax(0,2fr)] gap-4 px-5 py-2.5"> - <code class="font-mono text-[11.5px] text-[#0369a1] truncate">{e.name}</code> - <span class="text-[12px] text-foreground/65 leading-snug">{e.desc}</span> - </div> + <div class="px-5 py-2 font-mono text-[11.5px] text-[#0369a1]">{e}</div> ))} </div> </div> @@ -509,121 +238,11 @@ const faq = [ </div> </section> - <!-- ============================================================ - SECURITY · trust strip - ============================================================ --> - <section class="border-y border-[color:var(--border)] bg-[color:var(--surface-1)]/40 py-20 md:py-24"> - <div class="container-page"> - <div class="max-w-2xl mb-10"> - <div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground font-mono mb-3">Security</div> - <h2 class="text-[28px] md:text-[40px] font-semibold tracking-[-0.025em] leading-[1.06] text-heading"> - Credentials sit behind the same envelope as your mailbox tokens. - </h2> - <p class="mt-4 text-[15px] text-foreground/70 leading-relaxed"> - Integrations do not get a different security story than the rest of the platform — the OAuth tokens you connected your Gmail with already round-trip through KMS-wrapped per-user DEKs. Integration tokens follow the same path. - </p> - </div> - - <div class="grid md:grid-cols-2 lg:grid-cols-4 gap-px bg-[color:var(--border)] rounded-[14px] overflow-hidden ring-1 ring-[color:var(--border)]" style="box-shadow: var(--shadow-sm);"> - {security.map((s) => ( - <div class="bg-white p-6"> - <Icon name="check" size={14} class="text-[#0284c7]" /> - <div class="mt-3 text-[15px] font-semibold text-heading">{s.t}</div> - <p class="mt-2 text-[13px] text-foreground/65 leading-relaxed">{s.d}</p> - </div> - ))} - </div> - </div> - </section> - - <!-- ============================================================ - BY THE NUMBERS - ============================================================ --> - <section class="bg-white py-16 md:py-20"> - <div class="container-page"> - <div class="max-w-2xl mb-10"> - <div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground font-mono mb-3">By the numbers</div> - <h2 class="text-[26px] md:text-[34px] font-semibold tracking-[-0.025em] leading-[1.06] text-heading"> - Shape of the surface, in one strip. - </h2> - <p class="mt-3 text-[14px] text-foreground/65 leading-relaxed"> - Real values from the codebase. The catalog is short for the reason in the stance section above. - </p> - </div> - - <div class="grid grid-cols-2 md:grid-cols-4 gap-px bg-[color:var(--border)] rounded-[14px] overflow-hidden ring-1 ring-[color:var(--border)]"> - {numbers.map((n) => ( - <div class="bg-white p-5 md:p-6"> - <div class="flex items-baseline gap-1"> - <span class="text-[26px] md:text-[32px] font-semibold tracking-[-0.025em] font-mono text-heading">{n.v}</span> - {n.u && <span class="text-[12px] text-muted-foreground">{n.u}</span>} - </div> - <div class="mt-2 text-[11.5px] uppercase tracking-[0.14em] font-mono text-foreground/70">{n.l}</div> - <div class="mt-3 pt-3 border-t border-[color:var(--border)] text-[10.5px] font-mono text-muted-foreground truncate">{n.src}</div> - </div> - ))} - </div> - </div> - </section> - - <!-- ============================================================ - FAQ - ============================================================ --> - <section class="bg-[color:var(--surface-1)]/40 border-y border-[color:var(--border)] py-20 md:py-24"> - <div class="container-page grid lg:grid-cols-[1fr_1.8fr] gap-12 lg:gap-20 items-start"> - <div class="lg:sticky lg:top-24" style="align-self: start;"> - <div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground font-mono mb-3">Integrations FAQ</div> - <h2 class="text-[28px] md:text-[36px] font-semibold tracking-[-0.025em] leading-[1.08] text-heading"> - Five questions worth asking. - </h2> - <p class="mt-4 text-[14.5px] text-foreground/70 leading-relaxed"> - More depth in the <a class="text-[#0369a1] hover:text-[#075985] font-medium" href="/developers/">developer docs</a>. - </p> - </div> - - <div class="divide-y divide-[color:var(--border)]" data-faq> - {faq.map(([q, a]) => ( - <div class="faq-row py-2"> - <button type="button" class="faq-trigger group w-full flex items-start justify-between gap-4 py-3 text-left" aria-expanded="false"> - <span class="text-[15.5px] font-medium text-heading group-hover:text-[#0369a1] transition-colors">{q}</span> - <span class="faq-icon shrink-0 inline-flex items-center justify-center w-7 h-7 rounded-full bg-white ring-1 ring-[color:var(--border)] text-foreground/60 transition-transform duration-300 ease-out"> - <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v14"/><path d="M5 12h14"/></svg> - </span> - </button> - <div class="faq-panel grid grid-rows-[0fr] transition-[grid-template-rows] duration-300 ease-out"> - <div class="overflow-hidden"> - <p class="pb-4 pr-12 text-[14px] text-foreground/75 leading-relaxed max-w-2xl">{a}</p> - </div> - </div> - </div> - ))} - </div> - - <script is:inline> - (function () { - const faq = document.querySelector('[data-faq]'); - if (!faq) return; - faq.querySelectorAll('.faq-trigger').forEach(function (btn) { - btn.addEventListener('click', function () { - const row = btn.closest('.faq-row'); - const panel = row.querySelector('.faq-panel'); - const icon = btn.querySelector('.faq-icon'); - const open = btn.getAttribute('aria-expanded') === 'true'; - btn.setAttribute('aria-expanded', String(!open)); - panel.style.gridTemplateRows = open ? '0fr' : '1fr'; - if (icon) icon.style.transform = open ? 'rotate(0deg)' : 'rotate(45deg)'; - }); - }); - })(); - </script> - </div> - </section> - <CTA - title="Wire it up. See what your stack already knew." - description="One drawer, three steps, no service desk ticket. Or read the developer docs and build your own." - primaryLabel="Connect a provider" - primaryHref="https://app.warmbly.com/register" + title="Need an integration we do not list?" + description="Subscribe to the webhook stream and build it yourself, or open a request." + primaryLabel="Request integration" + primaryHref="/contact/?topic=integrations" secondaryLabel="See the API" secondaryHref="/developers/" /> From 8c006215ac23ecd7006f932d0155922b6893d967 Mon Sep 17 00:00:00 2001 From: Matt <meszmatew@gmail.com> Date: Thu, 28 May 2026 08:36:25 +0200 Subject: [PATCH 06/13] Revert "refactor(site): drop editorializing from integrations page" This reverts commit 45754b192d00166fe752fb57ae04d5e7eb094633. --- site/src/pages/integrations.astro | 683 +++++++++++++++++++++++------- 1 file changed, 532 insertions(+), 151 deletions(-) diff --git a/site/src/pages/integrations.astro b/site/src/pages/integrations.astro index 5d00d047..d978ca63 100644 --- a/site/src/pages/integrations.astro +++ b/site/src/pages/integrations.astro @@ -1,235 +1,506 @@ --- import Layout from '../layouts/Layout.astro'; -import Cloud from '../components/Cloud.astro'; +import HeroAtmosphere from '../components/HeroAtmosphere.astro'; import Icon from '../components/Icon.astro'; import CTA from '../components/CTA.astro'; -// Provider list mirrors internal/app/integration/catalog.go. -// Categories follow the model.IntegrationCategory enum. -const groups = [ +// ========================================================================= +// All providers wired in the backend's integration catalog. Keep parity +// with internal/app/integration/catalog.go — the API exposes the same +// list, so listing a provider here that doesn't exist there is a 1:1 +// promise of a broken connect button. +// ========================================================================= + +// Floating dashboard mock: live connection state for the hero. Numbers +// reflect the per-tier story we tell on the deliverability page. +const mockConnections = [ + { name: 'Cloudflare', cat: 'dns', state: 'connected', detail: 'acme.com · zone live', age: '2m ago' }, + { name: 'Google Postmaster', cat: 'deliverability', state: 'connected', detail: 'acme.com · pulled daily', age: '4h ago' }, + { name: 'Calendly', cat: 'meetings', state: 'connected', detail: '3 bookings attributed', age: '12m ago' }, + { name: 'DMARC reports', cat: 'deliverability', state: 'connected', detail: 'rua= forwarder live', age: '1h ago' }, + { name: 'Microsoft SNDS', cat: 'deliverability', state: 'connected', detail: '198.51.100.4 · green', age: '6h ago' }, + { name: 'Google Sheets', cat: 'data', state: 'pending', detail: 'Q2 outbound list · awaiting OAuth', age: '—' }, +]; + +const catTone = (c: string) => { + if (c === 'deliverability') return { dot: 'bg-emerald-500', chip: 'bg-emerald-50 text-emerald-700' }; + if (c === 'dns') return { dot: 'bg-sky-500', chip: 'bg-sky-50 text-sky-700' }; + if (c === 'meetings') return { dot: 'bg-rose-400', chip: 'bg-rose-50 text-rose-700' }; + return { dot: 'bg-amber-500', chip: 'bg-amber-50 text-amber-700' }; +}; + +const stateTone = (s: string) => { + if (s === 'connected') return { dot: 'bg-emerald-500', chip: 'bg-emerald-50 text-emerald-700' }; + if (s === 'pending') return { dot: 'bg-sky-500', chip: 'bg-sky-50 text-sky-700' }; + if (s === 'degraded') return { dot: 'bg-amber-500', chip: 'bg-amber-50 text-amber-700' }; + return { dot: 'bg-slate-400', chip: 'bg-slate-100 text-slate-600' }; +}; + +// ========================================================================= +// The categories themselves. Each one is a section on the page. +// ========================================================================= +const categories = [ { id: 'deliverability', - title: 'Deliverability', - sub: 'Provider-side reputation signals, ingested daily.', + eyebrow: 'Deliverability moat', + headline: 'Provider truth, in the same dashboard you already live in.', + body: 'Most outreach tools stop at "we send mail." Warmbly pulls reputation, complaint, and authentication data straight from the providers and surfaces it next to each mailbox\'s health state.', items: [ { name: 'Google Postmaster', - body: 'Domain reputation, spam-rate, and SPF / DKIM / DMARC success ratios from Gmail Postmaster Tools.', + tagline: 'Domain reputation, spam-rate, and authentication ratios pulled daily.', + body: 'OAuth into Google Postmaster Tools and we pull domain reputation, IP reputation, SPF / DKIM / DMARC success ratios, and user-reported spam rate as one snapshot per day. The data joins the per-mailbox health state so a domain reputation drop downgrades all mailboxes sending on it.', auth: 'OAuth', - status: 'live', + beta: false, }, { name: 'Microsoft SNDS', - body: 'IP reputation and complaint-rate bucket for Outlook and Hotmail.', + tagline: 'IP reputation + complaint-rate bucket for Outlook + Hotmail.', + body: 'Paste your SNDS data-access key. We poll the CSV daily, parse the filter-result and complaint-rate columns, and translate them into the same green / yellow / red reputation tier the Postmaster integration uses, so the two providers are comparable on the same axis.', auth: 'API key', - status: 'live', + beta: false, }, { name: 'DMARC reports', - body: 'Aggregate (RUA) XML ingestion. Per-source-IP breakdown of SPF and DKIM alignment.', - auth: 'Webhook URL', - status: 'live', + tagline: 'Ingest aggregate (RUA) reports and flag misaligned senders.', + body: 'We mint a per-org URL you point your rua= forwarder at. Every XML report is parsed, deduped, and broken out per source IP, so an unexpected forwarder breaking SPF alignment surfaces with a hostname, not just a percentage. No third-party DMARC service required.', + auth: 'Webhook', + beta: false, }, ], }, { id: 'dns', - title: 'DNS providers', - sub: 'Publish SPF, DKIM, DMARC, and the tracking CNAME from inside the dashboard.', + eyebrow: 'One-click DNS', + headline: 'SPF, DKIM, DMARC, and the tracking CNAME, written for you.', + body: 'A new mailbox should take a minute to configure, not an afternoon of DNS console wrangling. Connect a DNS provider once and the records publish from inside the dashboard.', items: [ { name: 'Cloudflare', - body: 'Zone-scoped API token. Verified on save before any record is written.', + tagline: 'Zone-scoped API token. We verify before saving.', + body: 'Paste a token scoped to Zone:DNS:Edit. We verify it round-trips before we persist it, look up your zone ID, and publish the three records we recommend. If a record already exists, we surface the diff before overwriting.', auth: 'API token', - status: 'live', + beta: false, }, { name: 'GoDaddy', - body: 'API key:secret pair from the GoDaddy developer portal.', - auth: 'API key', - status: 'beta', + tagline: 'Write records from the dashboard with your API key:secret pair.', + body: 'GoDaddy\'s developer portal mints a key:secret. We accept both, persist them encrypted, and publish the three records on save. Reads are batched so a GoDaddy-side rate limit never partially-applies a record set.', + auth: 'API key:secret', + beta: true, }, { name: 'Namecheap', - body: 'API token with egress IP allowlist surfaced in the connect drawer.', + tagline: 'Same one-click setup, Namecheap API edition.', + body: 'Namecheap requires an IP allowlist on their API endpoint — we surface the egress IP you need to allow inside the connect drawer so you do not have to read their docs. Once saved, record writes round-trip in seconds.', auth: 'API token', - status: 'beta', + beta: true, }, ], }, { id: 'meetings', - title: 'Meeting bookings', - sub: 'Attribute booked meetings to the campaign that surfaced the lead.', + eyebrow: 'Meeting attribution', + headline: 'Stop reporting on replies. Start reporting on meetings.', + body: 'A reply is a hint. A meeting on the calendar is the real conversion event. We close the loop by accepting booking webhooks and joining them to the campaign that triggered the contact.', items: [ { name: 'Calendly', - body: 'invitee.created webhook. Joined to the campaign that originated the contact.', - auth: 'Webhook URL', - status: 'live', + tagline: 'invitee.created webhook, joined to the campaign that surfaced the lead.', + body: 'Paste the URL we mint into Calendly\'s webhook UI. When a recipient books, we record the booking, look up the campaign that triggered them, and fire a campaign.reply_received webhook to your stack with trigger=meeting_booked so downstream dashboards know which campaign earned the meeting.', + auth: 'Webhook', + beta: false, }, { name: 'Cal.com', - body: 'BOOKING_CREATED webhook. Cloud and self-hosted both supported.', - auth: 'Webhook URL', - status: 'live', + tagline: 'Same attribution path. Open-source booking edition.', + body: 'Cal.com\'s BOOKING_CREATED event uses a different JSON shape than Calendly. We normalize both into one MeetingBooking record so reporting code does not branch by source. Self-hosted Cal.com works the same way.', + auth: 'Webhook', + beta: false, }, ], }, { id: 'data', - title: 'Data', - sub: 'Lead lists in, status updates out.', + eyebrow: 'Data + sheets', + headline: 'Where your operators already keep the lists.', + body: 'Lead lists do not live in your dashboard — they live in a spreadsheet. Warmbly\'s sheets integration is two-way: we pull lead rows in for a campaign and write the status of each row back as it sends, replies, bounces, or books.', items: [ { name: 'Google Sheets', - body: 'Two-way: read rows for a campaign, append send / reply / bounce / booked events back.', + tagline: 'Pull leads in. Push send / reply / bounce / booked events back.', + body: 'OAuth a Google account with Sheets scope. Point us at a sheet ID — we read rows starting from row 2 (header convention) and append a status column to the right. The same sheet becomes both your lead source and your real-time report.', auth: 'OAuth', - status: 'beta', + beta: true, }, ], }, ]; -const statusChip = (s: string) => { - if (s === 'live') return 'bg-emerald-50 text-emerald-700'; - if (s === 'beta') return 'bg-amber-50 text-amber-700'; - return 'bg-slate-100 text-slate-600'; -}; -const statusDot = (s: string) => { - if (s === 'live') return 'bg-emerald-500'; - if (s === 'beta') return 'bg-amber-500'; - return 'bg-slate-400'; -}; - -// Real event types from internal/models/webhook.go. +// ========================================================================= +// Webhook events we emit. Real event types from internal/models/webhook.go. +// ========================================================================= const webhookEvents = [ - 'campaign.email_sent', - 'campaign.email_delivered', - 'campaign.email_opened', - 'campaign.email_clicked', - 'campaign.email_bounced', - 'campaign.reply_received', - 'campaign.unsubscribed', - 'campaign.started', - 'campaign.paused', - 'campaign.completed', - 'warmup.health_changed', - 'warmup.placement_in_spam', - 'warmup.quarantined', - 'warmup.blocked', - 'deliverability.bounce', - 'deliverability.complaint', - 'email_account.connected', - 'email_account.removed', + { name: 'campaign.email_sent', desc: 'A campaign step dispatched to a recipient.' }, + { name: 'campaign.email_delivered', desc: 'Receiver acknowledged delivery (250 OK or DSN-equivalent).' }, + { name: 'campaign.email_opened', desc: 'Open pixel resolved. Note: open data is unreliable at major receivers.' }, + { name: 'campaign.email_clicked', desc: 'Tracked link clicked. Dedupe applied per recipient.' }, + { name: 'campaign.email_bounced', desc: 'Hard or soft bounce. Suppression follows automatically.' }, + { name: 'campaign.reply_received', desc: 'Recipient replied, or a meeting was booked via Calendly/Cal.com.' }, + { name: 'campaign.unsubscribed', desc: 'One-click unsubscribe or inbound STOP/REMOVE reply.' }, + { name: 'campaign.started', desc: 'Campaign moved into the running state.' }, + { name: 'campaign.paused', desc: 'Campaign auto-paused on bounce/complaint spike or manual pause.' }, + { name: 'campaign.completed', desc: 'Last sequence step dispatched for the last recipient.' }, + { name: 'warmup.health_changed', desc: 'A mailbox transitioned between healthy / watch / throttled / quarantined / blocked.' }, + { name: 'warmup.placement_in_spam', desc: 'A warmup probe landed in junk on a recipient mailbox.' }, + { name: 'warmup.quarantined', desc: 'Mailbox dropped to the recovery pool. 7-day cooldown.' }, + { name: 'warmup.blocked', desc: 'Mailbox hard-blocked from the shared pool. 30-day cooldown.' }, + { name: 'deliverability.bounce', desc: 'External deliverability event ingested (SES, Postmark, etc.).' }, + { name: 'deliverability.complaint', desc: 'External complaint event ingested (FBL, ARF, in-product reports).' }, + { name: 'email_account.connected', desc: 'A new mailbox finished onboarding.' }, + { name: 'email_account.removed', desc: 'A mailbox was removed from the workspace.' }, +]; + +// ========================================================================= +// "How it connects" three-step diagram source. Visual story for the hero. +// ========================================================================= +const steps = [ + { n: '01', t: 'Authenticate', d: 'OAuth, paste an API token, or copy a webhook URL we mint per-org. Inbound providers like Calendly need nothing else.' }, + { n: '02', t: 'Route', d: 'The connection joins your org\'s integration table. Inbound traffic routes by the secret in the URL path. Outbound traffic uses the encrypted token.' }, + { n: '03', t: 'Live', d: 'Status flips to connected. The dashboard surfaces the last sync, last error, and a one-click rotate. Disconnect cascades to dependent data.' }, +]; + +// ========================================================================= +// "By the numbers" strip. Provider count, event count. +// ========================================================================= +const numbers = [ + { v: '9', u: 'providers', l: 'Calendly, Cal.com, Sheets, Postmaster, SNDS, DMARC, Cloudflare, GoDaddy, Namecheap', src: 'integration/catalog.go' }, + { v: '18', u: 'webhook events', l: 'campaign · warmup · deliverability · account lifecycle', src: 'models/webhook.go' }, + { v: 'HMAC-SHA256', u: '', l: 'every outbound webhook is signed with t=<unix>,v1=<hex>', src: 'app/webhook/service.go' }, + { v: '8', u: 'max attempts', l: 'exponential backoff, capped at 1h, abandoned after ~2h', src: 'app/webhook/service.go' }, +]; + +// ========================================================================= +// Security strip. Threading the KMS envelope story so trust is not a +// separate page-load. +// ========================================================================= +const security = [ + { t: 'OAuth tokens encrypted with AES-256-GCM', d: 'Per-user data encryption keys (DEKs) wrapped with AWS KMS. The encrypted blob lands in DynamoDB; plaintext never persists outside a TTL-bounded Redis cache.' }, + { t: 'API tokens stored as opaque blobs', d: 'Cloudflare / GoDaddy / Namecheap / SNDS keys never serialize back to the API consumer. We expose only public display fields (zone name, IP, domain) for the dashboard.' }, + { t: 'Inbound URLs carry per-org secrets', d: 'A leaked URL only affects one organization. Rotation regenerates the secret and invalidates the old one immediately.' }, + { t: 'Outbound delivery audit trail', d: 'Every dispatch attempt is persisted with response status and body excerpt. Re-replay supported. SKIP LOCKED prevents duplicate fanout across replicas.' }, +]; + +// ========================================================================= +// FAQ. Real product behaviour. +// ========================================================================= +const faq = [ + ['Do you support OAuth for everything?', + 'No. OAuth is the right choice for providers that expose a per-user identity (Google Sheets, Google Postmaster). For provider-account-scoped credentials (Cloudflare API token, SNDS data-access key) a token is both simpler and safer. The connect drawer surfaces the right method per provider.'], + ['What happens to the connection if a token expires?', + 'The status flips to degraded and the dashboard shows the provider error. We do not silently retry forever — degraded connections stop attempting new fanout until the user re-authenticates or rotates.'], + ['Can I have more than one of the same provider?', + 'Yes. The connection is unique per (org, provider, label). Useful when one organization sends from two domains and you want one Cloudflare connection per zone instead of cramming both into one token.'], + ['How fast is the inbound webhook path?', + 'Calendly/Cal.com/DMARC POSTs are accepted, persisted, and acknowledged in the same request. The fan-out to your outbound webhook subscribers happens through the same queue our internal events use, so the latency budget is bounded by the queue tick (default 2s).'], + ['What if I do not want a provider you list?', + 'You can subscribe to the raw webhook stream and build whatever integration you want on top of it. The 18 event types cover every state transition we emit. The catalog is a curated convenience layer — the API surface underneath is open.'], ]; --- <Layout title="Integrations · Warmbly" - description="Deliverability, DNS, meeting booking, and data integrations for Warmbly. Plus an HMAC-signed webhook stream." + description="Tier 1 + Tier 2 integrations for cold outreach: Calendly, Cal.com, Google Sheets, Google Postmaster, Microsoft SNDS, DMARC, Cloudflare, GoDaddy, Namecheap. Plus a hardened webhook + API surface." > - <!-- HERO --> - <section class="relative isolate overflow-hidden text-white" style="background: radial-gradient(ellipse 130% 140% at 72% 28%, #0284c7 0%, #0369a1 25%, #075985 50%, #0c4a6e 80%, #0a3d5c 100%);"> - <div class="absolute top-0 right-0 w-[420px] opacity-30 pointer-events-none cloud-drift cloud-1" style="mix-blend-mode: screen;"> - <Cloud variant={5} opacity={1} /> - </div> + <!-- ============================================================ + HERO · HeroAtmosphere, matches deliverability/warmup/developers + ============================================================ --> + <section class="relative isolate overflow-hidden"> + <HeroAtmosphere /> - <div class="container-page relative pt-14 pb-12"> - <div class="text-[11px] uppercase tracking-[0.18em] text-white/55 font-mono mb-3">Integrations</div> - <h1 class="text-[32px] md:text-[48px] font-semibold tracking-[-0.025em] leading-[1.06] text-white max-w-3xl"> - Connect what you already use. + <div class="container-page relative pt-16 md:pt-24 pb-44 md:pb-56 text-center"> + <div class="inline-flex items-center gap-2 h-7 pl-1 pr-3 rounded-full bg-white/15 backdrop-blur ring-1 ring-white/25 text-[12px] text-white/95 shadow-[0_4px_14px_-4px_rgba(0,0,0,0.25)]"> + <span class="inline-flex items-center h-5 px-1.5 rounded-full text-[10.5px] font-semibold uppercase tracking-[0.06em] bg-white" style="color:#0369a1;"> + Integrations + </span> + Built around the moat, not the marketplace + </div> + + <h1 class="mt-8 md:mt-10 text-[44px] sm:text-6xl md:text-[72px] lg:text-[80px] font-semibold tracking-[-0.04em] leading-[0.98] text-white max-w-4xl mx-auto"> + Connect what already<br/>runs your stack. </h1> - <div class="mt-8 flex flex-wrap gap-2"> - {groups.map((g) => ( - <a href={`#${g.id}`} class="inline-flex items-center gap-2 h-8 px-3 rounded-full bg-white/10 backdrop-blur ring-1 ring-white/20 text-[12.5px] text-white/90 hover:bg-white/20"> - {g.title} - <span class="font-mono text-[10.5px] text-white/55">{g.items.length}</span> - </a> - ))} - <a href="#webhooks" class="inline-flex items-center gap-2 h-8 px-3 rounded-full bg-white/10 backdrop-blur ring-1 ring-white/20 text-[12.5px] text-white/90 hover:bg-white/20"> - Webhooks - <span class="font-mono text-[10.5px] text-white/55">{webhookEvents.length}</span> + <p class="mt-6 text-[17px] md:text-[19px] text-white/80 max-w-2xl mx-auto leading-relaxed"> + Postmaster + SNDS for provider truth. DMARC ingestion for alignment. Cloudflare-class one-click DNS. Calendly attribution for the conversion event that actually pays the bill. Plus a signed webhook stream the rest builds on. + </p> + + <div class="mt-8 flex flex-wrap items-center justify-center gap-3"> + <a href="https://app.warmbly.com/register" class="inline-flex h-11 px-5 items-center gap-2 rounded-[10px] text-[14.5px] font-semibold bg-white hover:bg-white hover:-translate-y-0.5 hover:shadow-[0_12px_28px_-6px_rgba(0,0,0,0.3)] transition-all duration-200 ease-out shadow-[0_4px_14px_-2px_rgba(0,0,0,0.18)]"> + <span style="color:#075985;">Connect a provider</span> + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#075985" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> + <path d="M5 12h14"/><path d="m12 5 7 7-7 7"/> + </svg> + </a> + <a href="#deliverability" class="inline-flex h-11 px-5 items-center rounded-[10px] text-[14.5px] font-medium bg-white/10 backdrop-blur ring-1 ring-white/40 text-white hover:bg-white/20 hover:-translate-y-0.5 transition-all duration-200 ease-out"> + See the moat </a> </div> </div> </section> - <!-- GROUPED INTEGRATION GRIDS --> - <section class="py-16 md:py-20"> - <div class="container-page space-y-16"> - {groups.map((g) => ( - <div id={g.id} class="scroll-mt-24"> - <div class="flex items-end justify-between mb-6"> - <div> - <div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground font-mono mb-2">{g.title}</div> - <h2 class="text-[22px] md:text-[28px] font-semibold tracking-[-0.02em] text-heading">{g.sub}</h2> - </div> - <div class="text-[12px] font-mono text-muted-foreground">{g.items.length} · integrations</div> - </div> + <!-- ============================================================ + FLOATING DASHBOARD · connection state mock + ============================================================ --> + <section class="relative -mt-36 md:-mt-44 pb-16 md:pb-24 z-10"> + <div class="container-page"> + <div class="rounded-[18px] bg-white ring-1 ring-[color:var(--border)] overflow-hidden shadow-[0_40px_90px_-30px_rgba(2,32,71,0.35),0_12px_30px_-12px_rgba(15,23,42,0.12)]"> - <div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-px bg-[color:var(--border)] rounded-[14px] overflow-hidden ring-1 ring-[color:var(--border)]" style="box-shadow: var(--shadow-sm);"> - {g.items.map((it) => ( + <!-- Topbar --> + <div class="px-6 py-3 border-b border-[color:var(--border)] flex items-baseline justify-between gap-3"> + <div class="flex items-baseline gap-3"> + <div class="text-[10.5px] font-mono uppercase tracking-[0.18em] text-muted-foreground">Integrations</div> + <span class="text-[11.5px] text-foreground/55">5 connected · 1 pending · 0 degraded</span> + </div> + <div class="flex items-center gap-2"> + <span class="inline-flex items-center h-6 px-2 rounded-md text-[10.5px] font-mono text-[#0369a1] bg-[color:var(--sky-1)]">live</span> + </div> + </div> + + <!-- Filter chips --> + <div class="grid grid-cols-2 md:grid-cols-4 divide-x divide-[color:var(--border)] border-b border-[color:var(--border)]"> + {[ + { l: 'Deliverability', n: '3', c: 'bg-emerald-500' }, + { l: 'DNS', n: '1', c: 'bg-sky-500' }, + { l: 'Meetings', n: '1', c: 'bg-rose-400' }, + { l: 'Data', n: '1', c: 'bg-amber-500' }, + ].map((s) => ( + <div class="px-5 py-3.5 flex items-baseline justify-between"> + <div class="flex items-center gap-2"> + <span class={`w-2 h-2 rounded-full ${s.c}`}></span> + <span class="text-[11.5px] uppercase tracking-[0.14em] font-mono text-foreground/70">{s.l}</span> + </div> + <span class="text-[14.5px] font-mono font-semibold text-heading tabular-nums">{s.n}</span> + </div> + ))} + </div> + + <!-- Connections grid --> + <div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-px bg-[color:var(--border)]"> + {mockConnections.map((c) => { + const ct = catTone(c.cat); + const st = stateTone(c.state); + return ( <div class="bg-white p-5 flex flex-col"> <div class="flex items-start justify-between gap-3"> - <div class="w-10 h-10 rounded-[10px] bg-[color:var(--sky-1)] text-[color:var(--sky-7)] ring-1 ring-[color:var(--sky-2)] inline-flex items-center justify-center text-[15px] font-semibold"> - {it.name.charAt(0)} + <div class="flex items-center gap-2.5"> + <div class="w-9 h-9 rounded-md bg-[color:var(--sky-1)] ring-1 ring-[color:var(--sky-2)] text-[#0369a1] inline-flex items-center justify-center text-[13px] font-semibold uppercase"> + {c.name.charAt(0)} + </div> + <div> + <div class="text-[13px] font-semibold text-heading">{c.name}</div> + <span class={`mt-0.5 inline-flex items-center gap-1 h-4 px-1.5 rounded text-[9.5px] uppercase tracking-[0.08em] font-medium font-mono ${ct.chip}`}> + <span class={`w-1 h-1 rounded-full ${ct.dot}`}></span>{c.cat} + </span> + </div> </div> - <span class={`inline-flex items-center gap-1 h-5 px-1.5 rounded text-[10px] font-medium font-mono uppercase tracking-[0.08em] ${statusChip(it.status)}`}> - <span class={`w-1 h-1 rounded-full ${statusDot(it.status)}`}></span> - {it.status} + <span class={`inline-flex items-center gap-1 h-5 px-1.5 rounded text-[10px] uppercase tracking-[0.08em] font-medium ${st.chip}`}> + <span class={`w-1 h-1 rounded-full ${st.dot}`}></span>{c.state} </span> </div> - <div class="mt-4 text-[14px] font-semibold text-heading">{it.name}</div> - <p class="mt-1 text-[12.5px] text-foreground/70 leading-relaxed">{it.body}</p> - <div class="mt-3 pt-3 border-t border-[color:var(--border)] text-[11px] font-mono uppercase tracking-[0.08em] text-muted-foreground"> - {it.auth} + <div class="mt-3 text-[12px] text-foreground/70 leading-relaxed">{c.detail}</div> + <div class="mt-auto pt-3 flex items-center justify-between text-[10.5px] font-mono text-muted-foreground"> + <span>last sync</span> + <span class="text-foreground/75">{c.age}</span> </div> </div> - ))} - </div> - </div> - ))} - - <!-- Webhooks + API --> - <div id="webhooks" class="scroll-mt-24"> - <div class="flex items-end justify-between mb-6"> - <div> - <div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground font-mono mb-2">Webhooks & API</div> - <h2 class="text-[22px] md:text-[28px] font-semibold tracking-[-0.02em] text-heading"> - Build your own with a signed event stream. - </h2> - </div> - <div class="text-[12px] font-mono text-muted-foreground">{webhookEvents.length} · event types</div> + ); + })} </div> - <div class="grid lg:grid-cols-[1.2fr_1fr] gap-px bg-[color:var(--border)] rounded-[14px] overflow-hidden ring-1 ring-[color:var(--border)]"> - <div class="bg-white p-6"> - <p class="text-[13.5px] text-foreground/70 leading-relaxed"> - Every outbound webhook is signed with HMAC-SHA256 in the Stripe format - (<span class="font-mono text-[12.5px]">t=<unix>,v1=<hex></span>), - retried with exponential backoff up to 8 attempts, and recorded with - full delivery history per endpoint. Subscribe selectively by event type - or accept all events. - </p> - <ul class="mt-5 space-y-2 text-[13px] text-foreground/80"> - <li class="flex items-start gap-2"><Icon name="check" size={13} class="text-[#0284c7] mt-1 shrink-0" /><span>HMAC-SHA256 signature header on every POST.</span></li> - <li class="flex items-start gap-2"><Icon name="check" size={13} class="text-[#0284c7] mt-1 shrink-0" /><span>Per-endpoint event-type filter.</span></li> - <li class="flex items-start gap-2"><Icon name="check" size={13} class="text-[#0284c7] mt-1 shrink-0" /><span>Secret rotation invalidates the old secret immediately.</span></li> - <li class="flex items-start gap-2"><Icon name="check" size={13} class="text-[#0284c7] mt-1 shrink-0" /><span>Delivery history retained per endpoint for debugging.</span></li> - </ul> - <a href="/developers/" class="mt-5 inline-flex items-center gap-1.5 h-8 px-3 rounded-md bg-[#0369a1] hover:bg-[#075985] text-white text-[12.5px] font-medium transition-colors"> - Read the API docs - <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg> - </a> - </div> + <!-- Footer note --> + <div class="px-6 py-3 border-t border-[color:var(--border)] bg-[color:var(--surface-1)]/60 flex items-center justify-between text-[11px] font-mono text-muted-foreground"> + <span>9 providers in the catalog · 18 webhook event types</span> + <span class="inline-flex items-center gap-1.5 text-foreground/55"> + <span class="w-1.5 h-1.5 rounded-full bg-emerald-500"></span> + evaluated every event + </span> + </div> + </div> + <div class="mt-3 text-center text-[11.5px] font-mono text-muted-foreground"> + app.warmbly.com/integrations + </div> + </div> + </section> - <div class="bg-white"> - <div class="px-5 py-2.5 border-b border-[color:var(--border)] bg-[color:var(--surface-1)] text-[10.5px] uppercase tracking-[0.18em] font-mono text-muted-foreground"> - Event types + <!-- ============================================================ + STANCE · what is actually different here + ============================================================ --> + <section class="border-y border-[color:var(--border)] bg-[color:var(--surface-1)]/40 py-20 md:py-28"> + <div class="container-page"> + <div class="max-w-3xl mb-12"> + <div class="text-[11px] uppercase tracking-[0.18em] font-mono text-[#0284c7] mb-3">Stance</div> + <h2 class="text-[32px] md:text-[44px] font-semibold tracking-[-0.03em] leading-[1.05] text-heading"> + Why this list is short on purpose. + </h2> + <p class="mt-4 text-[15.5px] text-foreground/70 leading-relaxed"> + Every cold-outreach platform competes on the length of an integrations grid. We made the opposite call. Nine providers, all directly load-bearing for deliverability, DNS, meetings, or operator workflow. Everything else is a Zapier/Make/n8n template away. + </p> + </div> + + <div class="grid md:grid-cols-2 gap-4 lg:gap-5"> + {[ + { h: 'A long list of CRM logos is a parity game.', + b: 'Customers compare "HubSpot · Salesforce · Pipedrive · Close · HighLevel" on every vendor page. The truth is most of these turn into a one-way push the first week. We would rather build one CRM connection that is genuinely two-way than ten that look the same on a logo wall.' }, + { h: 'The moat is provider truth, not provider count.', + b: 'Google Postmaster, Microsoft SNDS, and DMARC reports tell you whether the platform is working. No competitor in the cold outreach segment ingests all three. We do, and we surface the data next to each mailbox so a regression downgrades health before a customer notices replies dropping.' }, + { h: 'Conversion is meetings, not replies.', + b: 'A reply is a hint. A meeting is the conversion event. Calendly and Cal.com close the loop directly: the booking webhook joins the booking to the campaign that earned it, and the same campaign-level reporting that counts replies now counts meetings.' }, + { h: 'DNS belongs inside the dashboard.', + b: 'The worst part of mailbox onboarding is publishing three records in someone else\'s DNS console. Cloudflare, GoDaddy, and Namecheap let us write those records for the customer. We verify the API token before saving so the failure mode is at connect time, not the first failed send.' }, + ].map((c, i) => ( + <div class="rounded-[14px] bg-white ring-1 ring-[color:var(--border)] p-6 md:p-7"> + <div class="flex items-baseline gap-3 mb-3"> + <span class="font-mono text-[11px] text-[#0284c7] font-semibold tabular-nums">{String(i + 1).padStart(2, '0')}</span> + <div class="text-[16px] md:text-[17px] font-semibold tracking-[-0.015em] text-heading leading-snug">{c.h}</div> </div> - <div class="divide-y divide-[color:var(--border)] max-h-[360px] overflow-y-auto"> + <p class="text-[13.5px] text-foreground/70 leading-relaxed">{c.b}</p> + </div> + ))} + </div> + </div> + </section> + + <!-- ============================================================ + HOW IT CONNECTS · 3-step diagram + ============================================================ --> + <section class="border-b border-[color:var(--border)] py-20 md:py-28"> + <div class="container-page"> + <div class="max-w-3xl mb-12"> + <div class="text-[11px] uppercase tracking-[0.18em] font-mono text-[#0284c7] mb-3">Connection model</div> + <h2 class="text-[32px] md:text-[44px] font-semibold tracking-[-0.03em] leading-[1.05] text-heading"> + Three steps. One drawer. + </h2> + <p class="mt-4 text-[15.5px] text-foreground/70 leading-relaxed"> + The dashboard\'s connect drawer is the same shape for every provider — only the fields change. OAuth providers launch the auth popup. Token providers paste a key. Inbound providers get a URL minted on save. + </p> + </div> + + <div class="rounded-[14px] bg-[#0c1224] ring-1 ring-white/10 overflow-hidden"> + <div class="px-6 py-3 border-b border-white/10 flex items-center justify-between text-[10.5px] font-mono text-white/55"> + <span>connect drawer · v1</span> + <span>same surface, every provider</span> + </div> + + <div class="grid md:grid-cols-3 divide-y md:divide-y-0 md:divide-x divide-white/10"> + {steps.map((s) => ( + <div class="p-7 md:p-9"> + <div class="flex items-baseline gap-3"> + <span class="font-mono text-[11.5px] text-[#7dd3fc] font-semibold tabular-nums tracking-[0.08em]">{s.n}</span> + <span class="text-[18px] md:text-[20px] font-semibold tracking-[-0.02em] text-white">{s.t}</span> + </div> + <p class="mt-3 text-[13.5px] text-white/65 leading-relaxed">{s.d}</p> + </div> + ))} + </div> + </div> + </div> + </section> + + <!-- ============================================================ + CATEGORY SECTIONS · iterate + ============================================================ --> + {categories.map((cat, idx) => ( + <section id={cat.id} class={`scroll-mt-4 py-20 md:py-28 ${idx % 2 === 0 ? 'bg-white' : 'border-y border-[color:var(--border)] bg-[color:var(--surface-1)]/40'}`}> + <div class="container-page"> + <div class="max-w-2xl mb-12"> + <div class="text-[11px] uppercase tracking-[0.18em] font-mono text-[#0284c7] mb-3">{cat.eyebrow}</div> + <h2 class="text-[32px] md:text-[44px] font-semibold tracking-[-0.03em] leading-[1.05] text-heading"> + {cat.headline} + </h2> + <p class="mt-4 text-[15.5px] text-foreground/70 leading-relaxed">{cat.body}</p> + </div> + + <div class="grid md:grid-cols-2 lg:grid-cols-3 gap-px bg-[color:var(--border)] rounded-[14px] overflow-hidden ring-1 ring-[color:var(--border)]" style="box-shadow: var(--shadow-sm);"> + {cat.items.map((it) => ( + <div class="bg-white p-6 flex flex-col"> + <div class="flex items-start justify-between gap-3"> + <div class="flex items-center gap-2.5"> + <div class="w-10 h-10 rounded-md bg-[color:var(--sky-1)] ring-1 ring-[color:var(--sky-2)] text-[#0369a1] inline-flex items-center justify-center text-[14px] font-semibold uppercase"> + {it.name.charAt(0)} + </div> + <div> + <div class="text-[15px] font-semibold text-heading">{it.name}</div> + <div class="mt-0.5 text-[10.5px] uppercase tracking-[0.08em] text-muted-foreground font-mono"> + {it.auth}{it.beta && <span class="ml-1.5 text-amber-600">· beta</span>} + </div> + </div> + </div> + </div> + <p class="mt-3 text-[13px] text-foreground/65 leading-relaxed">{it.tagline}</p> + <p class="mt-4 pt-4 border-t border-[color:var(--border)] text-[12.5px] text-foreground/65 leading-relaxed">{it.body}</p> + </div> + ))} + </div> + </div> + </section> + ))} + + <!-- ============================================================ + WEBHOOKS + API · the layer everything else builds on + ============================================================ --> + <section class="bg-white py-20 md:py-28"> + <div class="container-page"> + <div class="grid lg:grid-cols-[1fr_1.8fr] gap-12 lg:gap-20 items-start"> + <div class="lg:sticky lg:top-24" style="align-self: start;"> + <div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground font-mono mb-3">Webhooks + API</div> + <h2 class="text-[28px] md:text-[40px] font-semibold tracking-[-0.025em] leading-[1.06] text-heading"> + One signed stream. Everything else builds on it. + </h2> + <p class="mt-4 text-[15px] text-foreground/70 leading-relaxed"> + The catalog is convenience. The webhook surface is the API. Eighteen event types, HMAC-SHA256 signed in the Stripe format, retry queue with exponential backoff, full delivery history per endpoint. If a provider is not in the catalog yet, your own subscriber can do anything any of the listed integrations does. + </p> + <ul class="mt-6 space-y-2 text-[13.5px] text-foreground/80"> + <li class="flex items-start gap-2"><Icon name="check" size={13} class="text-[#0284c7] mt-1 shrink-0" /><span><span class="font-mono">X-Warmbly-Signature: t=<unix>,v1=<hex></span> on every POST.</span></li> + <li class="flex items-start gap-2"><Icon name="check" size={13} class="text-[#0284c7] mt-1 shrink-0" /><span>Per-endpoint event-type filter. Subscribe selectively.</span></li> + <li class="flex items-start gap-2"><Icon name="check" size={13} class="text-[#0284c7] mt-1 shrink-0" /><span>Up to 8 attempts with capped exponential backoff. Full audit trail.</span></li> + <li class="flex items-start gap-2"><Icon name="check" size={13} class="text-[#0284c7] mt-1 shrink-0" /><span>Secret rotation invalidates the old secret immediately.</span></li> + </ul> + </div> + + <div class="space-y-5"> + <!-- Code block --> + <div class="rounded-[14px] bg-[#0c1224] ring-1 ring-white/10 overflow-hidden"> + <div class="px-5 py-2.5 border-b border-white/10 flex items-center justify-between text-[10.5px] font-mono text-white/55"> + <span>POST · your webhook endpoint</span> + <span>application/json</span> + </div> + <pre class="px-5 py-4 text-[12px] leading-relaxed text-white/85 font-mono whitespace-pre overflow-x-auto"><code>{`{ + "id": "f4a07e0c-a4b1-4dc8-9c5d-2c1b3e29c7b1", + "event_type": "campaign.reply_received", + "organization_id": "8c4e7c3d-...-", + "created_at": "2026-05-28T14:21:09Z", + "data": { + "source": "calendly", + "invitee_email": "lead@target.co", + "event_name": "30 min intro", + "scheduled_for": "2026-06-02T16:00:00Z", + "contact_id": "c1f...", + "booking_id": "b91...", + "trigger": "meeting_booked" + } +}`}</code></pre> + </div> + + <!-- Event list --> + <div class="rounded-[14px] bg-white ring-1 ring-[color:var(--border)] overflow-hidden"> + <div class="px-5 py-3 bg-[color:var(--surface-1)] border-b border-[color:var(--border)] flex items-baseline justify-between gap-2"> + <div class="text-[10.5px] uppercase tracking-[0.18em] font-mono text-muted-foreground">Event types</div> + <span class="text-[11px] font-mono text-foreground/55 tabular-nums">{webhookEvents.length} live</span> + </div> + <div class="divide-y divide-[color:var(--border)] max-h-[420px] overflow-y-auto"> {webhookEvents.map((e) => ( - <div class="px-5 py-2 font-mono text-[11.5px] text-[#0369a1]">{e}</div> + <div class="grid grid-cols-[minmax(0,1.4fr)_minmax(0,2fr)] gap-4 px-5 py-2.5"> + <code class="font-mono text-[11.5px] text-[#0369a1] truncate">{e.name}</code> + <span class="text-[12px] text-foreground/65 leading-snug">{e.desc}</span> + </div> ))} </div> </div> @@ -238,11 +509,121 @@ const webhookEvents = [ </div> </section> + <!-- ============================================================ + SECURITY · trust strip + ============================================================ --> + <section class="border-y border-[color:var(--border)] bg-[color:var(--surface-1)]/40 py-20 md:py-24"> + <div class="container-page"> + <div class="max-w-2xl mb-10"> + <div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground font-mono mb-3">Security</div> + <h2 class="text-[28px] md:text-[40px] font-semibold tracking-[-0.025em] leading-[1.06] text-heading"> + Credentials sit behind the same envelope as your mailbox tokens. + </h2> + <p class="mt-4 text-[15px] text-foreground/70 leading-relaxed"> + Integrations do not get a different security story than the rest of the platform — the OAuth tokens you connected your Gmail with already round-trip through KMS-wrapped per-user DEKs. Integration tokens follow the same path. + </p> + </div> + + <div class="grid md:grid-cols-2 lg:grid-cols-4 gap-px bg-[color:var(--border)] rounded-[14px] overflow-hidden ring-1 ring-[color:var(--border)]" style="box-shadow: var(--shadow-sm);"> + {security.map((s) => ( + <div class="bg-white p-6"> + <Icon name="check" size={14} class="text-[#0284c7]" /> + <div class="mt-3 text-[15px] font-semibold text-heading">{s.t}</div> + <p class="mt-2 text-[13px] text-foreground/65 leading-relaxed">{s.d}</p> + </div> + ))} + </div> + </div> + </section> + + <!-- ============================================================ + BY THE NUMBERS + ============================================================ --> + <section class="bg-white py-16 md:py-20"> + <div class="container-page"> + <div class="max-w-2xl mb-10"> + <div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground font-mono mb-3">By the numbers</div> + <h2 class="text-[26px] md:text-[34px] font-semibold tracking-[-0.025em] leading-[1.06] text-heading"> + Shape of the surface, in one strip. + </h2> + <p class="mt-3 text-[14px] text-foreground/65 leading-relaxed"> + Real values from the codebase. The catalog is short for the reason in the stance section above. + </p> + </div> + + <div class="grid grid-cols-2 md:grid-cols-4 gap-px bg-[color:var(--border)] rounded-[14px] overflow-hidden ring-1 ring-[color:var(--border)]"> + {numbers.map((n) => ( + <div class="bg-white p-5 md:p-6"> + <div class="flex items-baseline gap-1"> + <span class="text-[26px] md:text-[32px] font-semibold tracking-[-0.025em] font-mono text-heading">{n.v}</span> + {n.u && <span class="text-[12px] text-muted-foreground">{n.u}</span>} + </div> + <div class="mt-2 text-[11.5px] uppercase tracking-[0.14em] font-mono text-foreground/70">{n.l}</div> + <div class="mt-3 pt-3 border-t border-[color:var(--border)] text-[10.5px] font-mono text-muted-foreground truncate">{n.src}</div> + </div> + ))} + </div> + </div> + </section> + + <!-- ============================================================ + FAQ + ============================================================ --> + <section class="bg-[color:var(--surface-1)]/40 border-y border-[color:var(--border)] py-20 md:py-24"> + <div class="container-page grid lg:grid-cols-[1fr_1.8fr] gap-12 lg:gap-20 items-start"> + <div class="lg:sticky lg:top-24" style="align-self: start;"> + <div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground font-mono mb-3">Integrations FAQ</div> + <h2 class="text-[28px] md:text-[36px] font-semibold tracking-[-0.025em] leading-[1.08] text-heading"> + Five questions worth asking. + </h2> + <p class="mt-4 text-[14.5px] text-foreground/70 leading-relaxed"> + More depth in the <a class="text-[#0369a1] hover:text-[#075985] font-medium" href="/developers/">developer docs</a>. + </p> + </div> + + <div class="divide-y divide-[color:var(--border)]" data-faq> + {faq.map(([q, a]) => ( + <div class="faq-row py-2"> + <button type="button" class="faq-trigger group w-full flex items-start justify-between gap-4 py-3 text-left" aria-expanded="false"> + <span class="text-[15.5px] font-medium text-heading group-hover:text-[#0369a1] transition-colors">{q}</span> + <span class="faq-icon shrink-0 inline-flex items-center justify-center w-7 h-7 rounded-full bg-white ring-1 ring-[color:var(--border)] text-foreground/60 transition-transform duration-300 ease-out"> + <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v14"/><path d="M5 12h14"/></svg> + </span> + </button> + <div class="faq-panel grid grid-rows-[0fr] transition-[grid-template-rows] duration-300 ease-out"> + <div class="overflow-hidden"> + <p class="pb-4 pr-12 text-[14px] text-foreground/75 leading-relaxed max-w-2xl">{a}</p> + </div> + </div> + </div> + ))} + </div> + + <script is:inline> + (function () { + const faq = document.querySelector('[data-faq]'); + if (!faq) return; + faq.querySelectorAll('.faq-trigger').forEach(function (btn) { + btn.addEventListener('click', function () { + const row = btn.closest('.faq-row'); + const panel = row.querySelector('.faq-panel'); + const icon = btn.querySelector('.faq-icon'); + const open = btn.getAttribute('aria-expanded') === 'true'; + btn.setAttribute('aria-expanded', String(!open)); + panel.style.gridTemplateRows = open ? '0fr' : '1fr'; + if (icon) icon.style.transform = open ? 'rotate(0deg)' : 'rotate(45deg)'; + }); + }); + })(); + </script> + </div> + </section> + <CTA - title="Need an integration we do not list?" - description="Subscribe to the webhook stream and build it yourself, or open a request." - primaryLabel="Request integration" - primaryHref="/contact/?topic=integrations" + title="Wire it up. See what your stack already knew." + description="One drawer, three steps, no service desk ticket. Or read the developer docs and build your own." + primaryLabel="Connect a provider" + primaryHref="https://app.warmbly.com/register" secondaryLabel="See the API" secondaryHref="/developers/" /> From 077296ce84c0c3b0372989d853010b003d124083 Mon Sep 17 00:00:00 2001 From: Matt <meszmatew@gmail.com> Date: Thu, 28 May 2026 08:40:03 +0200 Subject: [PATCH 07/13] refactor(site): rewrite integrations copy in plain professional tone Keeps the page structure (hero, floating dashboard, three-step connect diagram, four category sections, webhooks + API, security, at-a-glance, FAQ, CTA). Rewrites every headline and body in straightforward descriptive prose. Drops the editorial "Stance" critique section. Removes em dashes throughout in favor of periods, commas, and parentheses. --- site/src/pages/integrations.astro | 321 +++++++++++------------------- 1 file changed, 121 insertions(+), 200 deletions(-) diff --git a/site/src/pages/integrations.astro b/site/src/pages/integrations.astro index d978ca63..28c109c7 100644 --- a/site/src/pages/integrations.astro +++ b/site/src/pages/integrations.astro @@ -4,22 +4,18 @@ import HeroAtmosphere from '../components/HeroAtmosphere.astro'; import Icon from '../components/Icon.astro'; import CTA from '../components/CTA.astro'; -// ========================================================================= -// All providers wired in the backend's integration catalog. Keep parity -// with internal/app/integration/catalog.go — the API exposes the same -// list, so listing a provider here that doesn't exist there is a 1:1 -// promise of a broken connect button. -// ========================================================================= +// Provider list mirrors internal/app/integration/catalog.go. +// Categories follow the model.IntegrationCategory enum. -// Floating dashboard mock: live connection state for the hero. Numbers -// reflect the per-tier story we tell on the deliverability page. +// Floating dashboard mock for the hero. Numbers reflect the same five +// connection states the real dashboard renders. const mockConnections = [ - { name: 'Cloudflare', cat: 'dns', state: 'connected', detail: 'acme.com · zone live', age: '2m ago' }, - { name: 'Google Postmaster', cat: 'deliverability', state: 'connected', detail: 'acme.com · pulled daily', age: '4h ago' }, - { name: 'Calendly', cat: 'meetings', state: 'connected', detail: '3 bookings attributed', age: '12m ago' }, - { name: 'DMARC reports', cat: 'deliverability', state: 'connected', detail: 'rua= forwarder live', age: '1h ago' }, - { name: 'Microsoft SNDS', cat: 'deliverability', state: 'connected', detail: '198.51.100.4 · green', age: '6h ago' }, - { name: 'Google Sheets', cat: 'data', state: 'pending', detail: 'Q2 outbound list · awaiting OAuth', age: '—' }, + { name: 'Cloudflare', cat: 'dns', state: 'connected', detail: 'acme.com', age: '2m ago' }, + { name: 'Google Postmaster', cat: 'deliverability', state: 'connected', detail: 'acme.com', age: '4h ago' }, + { name: 'Calendly', cat: 'meetings', state: 'connected', detail: '3 bookings attributed', age: '12m ago' }, + { name: 'DMARC reports', cat: 'deliverability', state: 'connected', detail: 'RUA forwarder live', age: '1h ago' }, + { name: 'Microsoft SNDS', cat: 'deliverability', state: 'connected', detail: '198.51.100.4', age: '6h ago' }, + { name: 'Google Sheets', cat: 'data', state: 'pending', detail: 'Q2 outbound list', age: 'awaiting OAuth' }, ]; const catTone = (c: string) => { @@ -36,63 +32,60 @@ const stateTone = (s: string) => { return { dot: 'bg-slate-400', chip: 'bg-slate-100 text-slate-600' }; }; -// ========================================================================= -// The categories themselves. Each one is a section on the page. -// ========================================================================= const categories = [ { id: 'deliverability', - eyebrow: 'Deliverability moat', - headline: 'Provider truth, in the same dashboard you already live in.', - body: 'Most outreach tools stop at "we send mail." Warmbly pulls reputation, complaint, and authentication data straight from the providers and surfaces it next to each mailbox\'s health state.', + eyebrow: 'Deliverability', + headline: 'Reputation signals from the providers themselves.', + body: 'Pull complaint rate, domain reputation, and authentication results directly from the sources that produce them. The data joins each mailbox health state so a drop in reputation flows through to the dashboard automatically.', items: [ { name: 'Google Postmaster', - tagline: 'Domain reputation, spam-rate, and authentication ratios pulled daily.', - body: 'OAuth into Google Postmaster Tools and we pull domain reputation, IP reputation, SPF / DKIM / DMARC success ratios, and user-reported spam rate as one snapshot per day. The data joins the per-mailbox health state so a domain reputation drop downgrades all mailboxes sending on it.', + tagline: 'Domain reputation, spam-rate, and authentication ratios from Gmail Postmaster Tools.', + body: 'OAuth into Google Postmaster. We pull one snapshot per day covering domain reputation, IP reputation, SPF, DKIM, and DMARC success ratios, plus user-reported spam rate. Snapshots are stored per domain and per day so the trend line is queryable from the dashboard.', auth: 'OAuth', beta: false, }, { name: 'Microsoft SNDS', - tagline: 'IP reputation + complaint-rate bucket for Outlook + Hotmail.', - body: 'Paste your SNDS data-access key. We poll the CSV daily, parse the filter-result and complaint-rate columns, and translate them into the same green / yellow / red reputation tier the Postmaster integration uses, so the two providers are comparable on the same axis.', + tagline: 'IP reputation and complaint-rate data for Outlook and Hotmail.', + body: 'Paste the SNDS data-access key. The CSV is polled daily, the filter-result and complaint-rate buckets are parsed, and the values are normalized to the same reputation tiers (high, medium, low) that the Postmaster integration uses.', auth: 'API key', beta: false, }, { name: 'DMARC reports', - tagline: 'Ingest aggregate (RUA) reports and flag misaligned senders.', - body: 'We mint a per-org URL you point your rua= forwarder at. Every XML report is parsed, deduped, and broken out per source IP, so an unexpected forwarder breaking SPF alignment surfaces with a hostname, not just a percentage. No third-party DMARC service required.', - auth: 'Webhook', + tagline: 'Aggregate (RUA) XML ingestion with per-source-IP breakdown.', + body: 'Forward your RUA reports to the URL minted for your organization. Each report is parsed, deduplicated, and split by source IP. The dashboard surfaces which senders are failing SPF or DKIM alignment, with hostnames where available.', + auth: 'Webhook URL', beta: false, }, ], }, { id: 'dns', - eyebrow: 'One-click DNS', - headline: 'SPF, DKIM, DMARC, and the tracking CNAME, written for you.', - body: 'A new mailbox should take a minute to configure, not an afternoon of DNS console wrangling. Connect a DNS provider once and the records publish from inside the dashboard.', + eyebrow: 'DNS providers', + headline: 'Publish SPF, DKIM, and DMARC records from the dashboard.', + body: 'Connect a DNS provider once. Records can be published, updated, and verified without leaving Warmbly.', items: [ { name: 'Cloudflare', - tagline: 'Zone-scoped API token. We verify before saving.', - body: 'Paste a token scoped to Zone:DNS:Edit. We verify it round-trips before we persist it, look up your zone ID, and publish the three records we recommend. If a record already exists, we surface the diff before overwriting.', + tagline: 'Zone-scoped API token. Verified on save.', + body: 'Provide a token scoped to Zone:DNS:Edit. The token is verified against Cloudflare before it is persisted, then the zone ID is resolved and the recommended records are published. Existing records are detected and surfaced before any overwrite.', auth: 'API token', beta: false, }, { name: 'GoDaddy', - tagline: 'Write records from the dashboard with your API key:secret pair.', - body: 'GoDaddy\'s developer portal mints a key:secret. We accept both, persist them encrypted, and publish the three records on save. Reads are batched so a GoDaddy-side rate limit never partially-applies a record set.', - auth: 'API key:secret', + tagline: 'API key and secret from the GoDaddy developer portal.', + body: 'GoDaddy issues a key and secret pair. Both are stored encrypted and used to publish the recommended records on save. Reads are batched so a partial rate-limited response cannot leave the record set inconsistent.', + auth: 'API key', beta: true, }, { name: 'Namecheap', - tagline: 'Same one-click setup, Namecheap API edition.', - body: 'Namecheap requires an IP allowlist on their API endpoint — we surface the egress IP you need to allow inside the connect drawer so you do not have to read their docs. Once saved, record writes round-trip in seconds.', + tagline: 'Namecheap API access.', + body: 'Namecheap requires an IP allowlist on the API endpoint. The connect drawer displays the egress IP that must be added to the allowlist. Once configured, record writes complete in seconds.', auth: 'API token', beta: true, }, @@ -100,36 +93,36 @@ const categories = [ }, { id: 'meetings', - eyebrow: 'Meeting attribution', - headline: 'Stop reporting on replies. Start reporting on meetings.', - body: 'A reply is a hint. A meeting on the calendar is the real conversion event. We close the loop by accepting booking webhooks and joining them to the campaign that triggered the contact.', + eyebrow: 'Meeting bookings', + headline: 'Attribute booked meetings to the campaign that surfaced the lead.', + body: 'Both Calendly and Cal.com post a webhook when a recipient books. The booking is recorded, joined to the originating contact and campaign, and surfaced in campaign reporting alongside replies.', items: [ { name: 'Calendly', - tagline: 'invitee.created webhook, joined to the campaign that surfaced the lead.', - body: 'Paste the URL we mint into Calendly\'s webhook UI. When a recipient books, we record the booking, look up the campaign that triggered them, and fire a campaign.reply_received webhook to your stack with trigger=meeting_booked so downstream dashboards know which campaign earned the meeting.', - auth: 'Webhook', + tagline: 'invitee.created webhook.', + body: 'Paste the URL minted for your organization into Calendly. When a recipient books, the booking is recorded and a campaign.reply_received event fires with trigger=meeting_booked so downstream subscribers see which campaign earned the meeting.', + auth: 'Webhook URL', beta: false, }, { name: 'Cal.com', - tagline: 'Same attribution path. Open-source booking edition.', - body: 'Cal.com\'s BOOKING_CREATED event uses a different JSON shape than Calendly. We normalize both into one MeetingBooking record so reporting code does not branch by source. Self-hosted Cal.com works the same way.', - auth: 'Webhook', + tagline: 'BOOKING_CREATED webhook. Cloud and self-hosted.', + body: 'The Cal.com payload uses a different shape than Calendly. Both are normalized into one MeetingBooking record so reporting and downstream automations do not need to branch by source.', + auth: 'Webhook URL', beta: false, }, ], }, { id: 'data', - eyebrow: 'Data + sheets', - headline: 'Where your operators already keep the lists.', - body: 'Lead lists do not live in your dashboard — they live in a spreadsheet. Warmbly\'s sheets integration is two-way: we pull lead rows in for a campaign and write the status of each row back as it sends, replies, bounces, or books.', + eyebrow: 'Data', + headline: 'Lead lists in. Status updates out.', + body: 'Most lead lists live in a spreadsheet. The Sheets integration reads leads for a campaign and writes send, reply, bounce, and booked events back to the same sheet.', items: [ { name: 'Google Sheets', - tagline: 'Pull leads in. Push send / reply / bounce / booked events back.', - body: 'OAuth a Google account with Sheets scope. Point us at a sheet ID — we read rows starting from row 2 (header convention) and append a status column to the right. The same sheet becomes both your lead source and your real-time report.', + tagline: 'Two-way: read leads, write status updates.', + body: 'OAuth a Google account with Sheets scope. Provide a sheet ID. Rows are read starting from row 2 (header convention) and a status column is appended on the right. The same sheet acts as both the lead source and the real-time status report.', auth: 'OAuth', beta: true, }, @@ -137,83 +130,70 @@ const categories = [ }, ]; -// ========================================================================= -// Webhook events we emit. Real event types from internal/models/webhook.go. -// ========================================================================= +// Real event types from internal/models/webhook.go. const webhookEvents = [ - { name: 'campaign.email_sent', desc: 'A campaign step dispatched to a recipient.' }, - { name: 'campaign.email_delivered', desc: 'Receiver acknowledged delivery (250 OK or DSN-equivalent).' }, - { name: 'campaign.email_opened', desc: 'Open pixel resolved. Note: open data is unreliable at major receivers.' }, - { name: 'campaign.email_clicked', desc: 'Tracked link clicked. Dedupe applied per recipient.' }, + { name: 'campaign.email_sent', desc: 'A campaign step was dispatched to a recipient.' }, + { name: 'campaign.email_delivered', desc: 'The receiver acknowledged delivery (250 OK or DSN-equivalent).' }, + { name: 'campaign.email_opened', desc: 'Open pixel resolved. Open data is unreliable at major receivers.' }, + { name: 'campaign.email_clicked', desc: 'A tracked link was clicked. Deduplicated per recipient.' }, { name: 'campaign.email_bounced', desc: 'Hard or soft bounce. Suppression follows automatically.' }, - { name: 'campaign.reply_received', desc: 'Recipient replied, or a meeting was booked via Calendly/Cal.com.' }, - { name: 'campaign.unsubscribed', desc: 'One-click unsubscribe or inbound STOP/REMOVE reply.' }, + { name: 'campaign.reply_received', desc: 'Recipient replied, or a meeting was booked via Calendly or Cal.com.' }, + { name: 'campaign.unsubscribed', desc: 'One-click unsubscribe or inbound STOP / REMOVE reply.' }, { name: 'campaign.started', desc: 'Campaign moved into the running state.' }, - { name: 'campaign.paused', desc: 'Campaign auto-paused on bounce/complaint spike or manual pause.' }, - { name: 'campaign.completed', desc: 'Last sequence step dispatched for the last recipient.' }, - { name: 'warmup.health_changed', desc: 'A mailbox transitioned between healthy / watch / throttled / quarantined / blocked.' }, + { name: 'campaign.paused', desc: 'Campaign auto-paused on bounce or complaint spike, or paused manually.' }, + { name: 'campaign.completed', desc: 'Last sequence step was dispatched for the last recipient.' }, + { name: 'warmup.health_changed', desc: 'A mailbox transitioned between healthy, watch, throttled, quarantined, or blocked.' }, { name: 'warmup.placement_in_spam', desc: 'A warmup probe landed in junk on a recipient mailbox.' }, { name: 'warmup.quarantined', desc: 'Mailbox dropped to the recovery pool. 7-day cooldown.' }, { name: 'warmup.blocked', desc: 'Mailbox hard-blocked from the shared pool. 30-day cooldown.' }, - { name: 'deliverability.bounce', desc: 'External deliverability event ingested (SES, Postmark, etc.).' }, - { name: 'deliverability.complaint', desc: 'External complaint event ingested (FBL, ARF, in-product reports).' }, + { name: 'deliverability.bounce', desc: 'External deliverability event was ingested.' }, + { name: 'deliverability.complaint', desc: 'External complaint event was ingested.' }, { name: 'email_account.connected', desc: 'A new mailbox finished onboarding.' }, { name: 'email_account.removed', desc: 'A mailbox was removed from the workspace.' }, ]; -// ========================================================================= -// "How it connects" three-step diagram source. Visual story for the hero. -// ========================================================================= +// Connect steps shown in the dark panel. const steps = [ - { n: '01', t: 'Authenticate', d: 'OAuth, paste an API token, or copy a webhook URL we mint per-org. Inbound providers like Calendly need nothing else.' }, - { n: '02', t: 'Route', d: 'The connection joins your org\'s integration table. Inbound traffic routes by the secret in the URL path. Outbound traffic uses the encrypted token.' }, - { n: '03', t: 'Live', d: 'Status flips to connected. The dashboard surfaces the last sync, last error, and a one-click rotate. Disconnect cascades to dependent data.' }, + { n: '01', t: 'Authenticate', d: 'OAuth, an API token, or a webhook URL minted for your organization. The connect drawer surfaces the right method per provider.' }, + { n: '02', t: 'Route', d: 'The connection is recorded against your organization. Inbound traffic is routed by the secret in the URL path. Outbound traffic uses the encrypted token.' }, + { n: '03', t: 'Live', d: 'Status moves to connected. The dashboard shows the last sync time, the last error if any, and a one-click secret rotation. Disconnect cascades to dependent data.' }, ]; -// ========================================================================= -// "By the numbers" strip. Provider count, event count. -// ========================================================================= +// Numbers used in the strip near the bottom. const numbers = [ - { v: '9', u: 'providers', l: 'Calendly, Cal.com, Sheets, Postmaster, SNDS, DMARC, Cloudflare, GoDaddy, Namecheap', src: 'integration/catalog.go' }, - { v: '18', u: 'webhook events', l: 'campaign · warmup · deliverability · account lifecycle', src: 'models/webhook.go' }, - { v: 'HMAC-SHA256', u: '', l: 'every outbound webhook is signed with t=<unix>,v1=<hex>', src: 'app/webhook/service.go' }, - { v: '8', u: 'max attempts', l: 'exponential backoff, capped at 1h, abandoned after ~2h', src: 'app/webhook/service.go' }, + { v: '9', u: 'providers', l: 'Calendly, Cal.com, Sheets, Postmaster, SNDS, DMARC, Cloudflare, GoDaddy, Namecheap', src: 'integration/catalog.go' }, + { v: '18', u: 'webhook events', l: 'campaign, warmup, deliverability, account lifecycle', src: 'models/webhook.go' }, + { v: 'HMAC-SHA256', u: '', l: 'every outbound webhook is signed', src: 'app/webhook/service.go' }, + { v: '8', u: 'max attempts', l: 'capped exponential backoff up to one hour', src: 'app/webhook/service.go' }, ]; -// ========================================================================= -// Security strip. Threading the KMS envelope story so trust is not a -// separate page-load. -// ========================================================================= +// Security strip. const security = [ - { t: 'OAuth tokens encrypted with AES-256-GCM', d: 'Per-user data encryption keys (DEKs) wrapped with AWS KMS. The encrypted blob lands in DynamoDB; plaintext never persists outside a TTL-bounded Redis cache.' }, - { t: 'API tokens stored as opaque blobs', d: 'Cloudflare / GoDaddy / Namecheap / SNDS keys never serialize back to the API consumer. We expose only public display fields (zone name, IP, domain) for the dashboard.' }, - { t: 'Inbound URLs carry per-org secrets', d: 'A leaked URL only affects one organization. Rotation regenerates the secret and invalidates the old one immediately.' }, - { t: 'Outbound delivery audit trail', d: 'Every dispatch attempt is persisted with response status and body excerpt. Re-replay supported. SKIP LOCKED prevents duplicate fanout across replicas.' }, + { t: 'OAuth tokens encrypted at rest', d: 'AES-256-GCM with per-user data encryption keys wrapped by AWS KMS. The encrypted blob is stored in DynamoDB. Plaintext is only held in a TTL-bounded cache during active use.' }, + { t: 'API tokens stored as opaque blobs', d: 'Cloudflare, GoDaddy, Namecheap, and SNDS keys are never serialized back to the API consumer. The dashboard sees only the public display fields (zone name, domain, IP).' }, + { t: 'Inbound URLs are per-organization', d: 'A leaked URL only affects one organization. Rotating the secret invalidates the old one immediately.' }, + { t: 'Outbound delivery audit trail', d: 'Every webhook dispatch attempt is recorded with response status and body excerpt. Replays are supported. SKIP LOCKED prevents duplicate fanout across replicas.' }, ]; -// ========================================================================= -// FAQ. Real product behaviour. -// ========================================================================= +// FAQ. const faq = [ - ['Do you support OAuth for everything?', - 'No. OAuth is the right choice for providers that expose a per-user identity (Google Sheets, Google Postmaster). For provider-account-scoped credentials (Cloudflare API token, SNDS data-access key) a token is both simpler and safer. The connect drawer surfaces the right method per provider.'], - ['What happens to the connection if a token expires?', - 'The status flips to degraded and the dashboard shows the provider error. We do not silently retry forever — degraded connections stop attempting new fanout until the user re-authenticates or rotates.'], + ['Do you support OAuth for every provider?', + 'No. OAuth is used where the provider exposes a per-user identity, such as Google Sheets or Google Postmaster. For account-scoped credentials such as a Cloudflare API token or an SNDS data-access key, a static token is simpler and more appropriate. The connect drawer uses the right method per provider.'], + ['What happens when a token expires or is revoked?', + 'The connection status moves to degraded and the dashboard shows the provider error. Degraded connections stop attempting new fanout until the credentials are rotated or re-authenticated.'], ['Can I have more than one of the same provider?', - 'Yes. The connection is unique per (org, provider, label). Useful when one organization sends from two domains and you want one Cloudflare connection per zone instead of cramming both into one token.'], + 'Yes. A connection is unique per (organization, provider, label). One organization can hold a Cloudflare connection per zone, for example, instead of putting multiple zones behind one token.'], ['How fast is the inbound webhook path?', - 'Calendly/Cal.com/DMARC POSTs are accepted, persisted, and acknowledged in the same request. The fan-out to your outbound webhook subscribers happens through the same queue our internal events use, so the latency budget is bounded by the queue tick (default 2s).'], - ['What if I do not want a provider you list?', - 'You can subscribe to the raw webhook stream and build whatever integration you want on top of it. The 18 event types cover every state transition we emit. The catalog is a curated convenience layer — the API surface underneath is open.'], + 'Calendly, Cal.com, and DMARC POSTs are accepted, persisted, and acknowledged inside the same request. Fanout to outbound subscribers runs on the internal event queue, with a default tick of two seconds.'], + ['What if you do not list a provider I need?', + 'Subscribe to the webhook stream and build the integration directly. The eighteen event types cover every state transition that is emitted internally. The catalog is a curated convenience layer on top of the same surface.'], ]; --- <Layout title="Integrations · Warmbly" - description="Tier 1 + Tier 2 integrations for cold outreach: Calendly, Cal.com, Google Sheets, Google Postmaster, Microsoft SNDS, DMARC, Cloudflare, GoDaddy, Namecheap. Plus a hardened webhook + API surface." + description="Deliverability, DNS, meeting booking, and data integrations for Warmbly. Plus a signed webhook stream and developer API." > - <!-- ============================================================ - HERO · HeroAtmosphere, matches deliverability/warmup/developers - ============================================================ --> + <!-- HERO --> <section class="relative isolate overflow-hidden"> <HeroAtmosphere /> @@ -222,55 +202,51 @@ const faq = [ <span class="inline-flex items-center h-5 px-1.5 rounded-full text-[10.5px] font-semibold uppercase tracking-[0.06em] bg-white" style="color:#0369a1;"> Integrations </span> - Built around the moat, not the marketplace + Connect your stack to Warmbly </div> <h1 class="mt-8 md:mt-10 text-[44px] sm:text-6xl md:text-[72px] lg:text-[80px] font-semibold tracking-[-0.04em] leading-[0.98] text-white max-w-4xl mx-auto"> - Connect what already<br/>runs your stack. + Integrations. </h1> <p class="mt-6 text-[17px] md:text-[19px] text-white/80 max-w-2xl mx-auto leading-relaxed"> - Postmaster + SNDS for provider truth. DMARC ingestion for alignment. Cloudflare-class one-click DNS. Calendly attribution for the conversion event that actually pays the bill. Plus a signed webhook stream the rest builds on. + Deliverability signals from Google Postmaster, Microsoft SNDS, and DMARC. One-click DNS through Cloudflare, GoDaddy, and Namecheap. Meeting attribution from Calendly and Cal.com. Two-way Google Sheets. Plus a signed webhook stream for everything else. </p> <div class="mt-8 flex flex-wrap items-center justify-center gap-3"> <a href="https://app.warmbly.com/register" class="inline-flex h-11 px-5 items-center gap-2 rounded-[10px] text-[14.5px] font-semibold bg-white hover:bg-white hover:-translate-y-0.5 hover:shadow-[0_12px_28px_-6px_rgba(0,0,0,0.3)] transition-all duration-200 ease-out shadow-[0_4px_14px_-2px_rgba(0,0,0,0.18)]"> - <span style="color:#075985;">Connect a provider</span> + <span style="color:#075985;">Get started</span> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#075985" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> <path d="M5 12h14"/><path d="m12 5 7 7-7 7"/> </svg> </a> <a href="#deliverability" class="inline-flex h-11 px-5 items-center rounded-[10px] text-[14.5px] font-medium bg-white/10 backdrop-blur ring-1 ring-white/40 text-white hover:bg-white/20 hover:-translate-y-0.5 transition-all duration-200 ease-out"> - See the moat + Browse integrations </a> </div> </div> </section> - <!-- ============================================================ - FLOATING DASHBOARD · connection state mock - ============================================================ --> + <!-- FLOATING DASHBOARD · connection state mock --> <section class="relative -mt-36 md:-mt-44 pb-16 md:pb-24 z-10"> <div class="container-page"> <div class="rounded-[18px] bg-white ring-1 ring-[color:var(--border)] overflow-hidden shadow-[0_40px_90px_-30px_rgba(2,32,71,0.35),0_12px_30px_-12px_rgba(15,23,42,0.12)]"> - <!-- Topbar --> <div class="px-6 py-3 border-b border-[color:var(--border)] flex items-baseline justify-between gap-3"> <div class="flex items-baseline gap-3"> <div class="text-[10.5px] font-mono uppercase tracking-[0.18em] text-muted-foreground">Integrations</div> - <span class="text-[11.5px] text-foreground/55">5 connected · 1 pending · 0 degraded</span> + <span class="text-[11.5px] text-foreground/55">5 connected, 1 pending, 0 degraded</span> </div> <div class="flex items-center gap-2"> <span class="inline-flex items-center h-6 px-2 rounded-md text-[10.5px] font-mono text-[#0369a1] bg-[color:var(--sky-1)]">live</span> </div> </div> - <!-- Filter chips --> <div class="grid grid-cols-2 md:grid-cols-4 divide-x divide-[color:var(--border)] border-b border-[color:var(--border)]"> {[ { l: 'Deliverability', n: '3', c: 'bg-emerald-500' }, - { l: 'DNS', n: '1', c: 'bg-sky-500' }, - { l: 'Meetings', n: '1', c: 'bg-rose-400' }, - { l: 'Data', n: '1', c: 'bg-amber-500' }, + { l: 'DNS', n: '1', c: 'bg-sky-500' }, + { l: 'Meetings', n: '1', c: 'bg-rose-400' }, + { l: 'Data', n: '1', c: 'bg-amber-500' }, ].map((s) => ( <div class="px-5 py-3.5 flex items-baseline justify-between"> <div class="flex items-center gap-2"> @@ -282,7 +258,6 @@ const faq = [ ))} </div> - <!-- Connections grid --> <div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-px bg-[color:var(--border)]"> {mockConnections.map((c) => { const ct = catTone(c.cat); @@ -315,9 +290,8 @@ const faq = [ })} </div> - <!-- Footer note --> <div class="px-6 py-3 border-t border-[color:var(--border)] bg-[color:var(--surface-1)]/60 flex items-center justify-between text-[11px] font-mono text-muted-foreground"> - <span>9 providers in the catalog · 18 webhook event types</span> + <span>9 providers, 18 webhook event types</span> <span class="inline-flex items-center gap-1.5 text-foreground/55"> <span class="w-1.5 h-1.5 rounded-full bg-emerald-500"></span> evaluated every event @@ -330,63 +304,23 @@ const faq = [ </div> </section> - <!-- ============================================================ - STANCE · what is actually different here - ============================================================ --> - <section class="border-y border-[color:var(--border)] bg-[color:var(--surface-1)]/40 py-20 md:py-28"> + <!-- HOW IT CONNECTS · 3-step diagram --> + <section class="border-y border-[color:var(--border)] py-20 md:py-28"> <div class="container-page"> <div class="max-w-3xl mb-12"> - <div class="text-[11px] uppercase tracking-[0.18em] font-mono text-[#0284c7] mb-3">Stance</div> + <div class="text-[11px] uppercase tracking-[0.18em] font-mono text-[#0284c7] mb-3">How it works</div> <h2 class="text-[32px] md:text-[44px] font-semibold tracking-[-0.03em] leading-[1.05] text-heading"> - Why this list is short on purpose. + Connect in three steps. </h2> <p class="mt-4 text-[15.5px] text-foreground/70 leading-relaxed"> - Every cold-outreach platform competes on the length of an integrations grid. We made the opposite call. Nine providers, all directly load-bearing for deliverability, DNS, meetings, or operator workflow. Everything else is a Zapier/Make/n8n template away. - </p> - </div> - - <div class="grid md:grid-cols-2 gap-4 lg:gap-5"> - {[ - { h: 'A long list of CRM logos is a parity game.', - b: 'Customers compare "HubSpot · Salesforce · Pipedrive · Close · HighLevel" on every vendor page. The truth is most of these turn into a one-way push the first week. We would rather build one CRM connection that is genuinely two-way than ten that look the same on a logo wall.' }, - { h: 'The moat is provider truth, not provider count.', - b: 'Google Postmaster, Microsoft SNDS, and DMARC reports tell you whether the platform is working. No competitor in the cold outreach segment ingests all three. We do, and we surface the data next to each mailbox so a regression downgrades health before a customer notices replies dropping.' }, - { h: 'Conversion is meetings, not replies.', - b: 'A reply is a hint. A meeting is the conversion event. Calendly and Cal.com close the loop directly: the booking webhook joins the booking to the campaign that earned it, and the same campaign-level reporting that counts replies now counts meetings.' }, - { h: 'DNS belongs inside the dashboard.', - b: 'The worst part of mailbox onboarding is publishing three records in someone else\'s DNS console. Cloudflare, GoDaddy, and Namecheap let us write those records for the customer. We verify the API token before saving so the failure mode is at connect time, not the first failed send.' }, - ].map((c, i) => ( - <div class="rounded-[14px] bg-white ring-1 ring-[color:var(--border)] p-6 md:p-7"> - <div class="flex items-baseline gap-3 mb-3"> - <span class="font-mono text-[11px] text-[#0284c7] font-semibold tabular-nums">{String(i + 1).padStart(2, '0')}</span> - <div class="text-[16px] md:text-[17px] font-semibold tracking-[-0.015em] text-heading leading-snug">{c.h}</div> - </div> - <p class="text-[13.5px] text-foreground/70 leading-relaxed">{c.b}</p> - </div> - ))} - </div> - </div> - </section> - - <!-- ============================================================ - HOW IT CONNECTS · 3-step diagram - ============================================================ --> - <section class="border-b border-[color:var(--border)] py-20 md:py-28"> - <div class="container-page"> - <div class="max-w-3xl mb-12"> - <div class="text-[11px] uppercase tracking-[0.18em] font-mono text-[#0284c7] mb-3">Connection model</div> - <h2 class="text-[32px] md:text-[44px] font-semibold tracking-[-0.03em] leading-[1.05] text-heading"> - Three steps. One drawer. - </h2> - <p class="mt-4 text-[15.5px] text-foreground/70 leading-relaxed"> - The dashboard\'s connect drawer is the same shape for every provider — only the fields change. OAuth providers launch the auth popup. Token providers paste a key. Inbound providers get a URL minted on save. + Every provider goes through the same connect drawer. The fields change per provider, the flow does not. </p> </div> <div class="rounded-[14px] bg-[#0c1224] ring-1 ring-white/10 overflow-hidden"> <div class="px-6 py-3 border-b border-white/10 flex items-center justify-between text-[10.5px] font-mono text-white/55"> - <span>connect drawer · v1</span> - <span>same surface, every provider</span> + <span>connect drawer</span> + <span>same flow, every provider</span> </div> <div class="grid md:grid-cols-3 divide-y md:divide-y-0 md:divide-x divide-white/10"> @@ -404,9 +338,7 @@ const faq = [ </div> </section> - <!-- ============================================================ - CATEGORY SECTIONS · iterate - ============================================================ --> + <!-- CATEGORY SECTIONS --> {categories.map((cat, idx) => ( <section id={cat.id} class={`scroll-mt-4 py-20 md:py-28 ${idx % 2 === 0 ? 'bg-white' : 'border-y border-[color:var(--border)] bg-[color:var(--surface-1)]/40'}`}> <div class="container-page"> @@ -443,25 +375,23 @@ const faq = [ </section> ))} - <!-- ============================================================ - WEBHOOKS + API · the layer everything else builds on - ============================================================ --> + <!-- WEBHOOKS + API --> <section class="bg-white py-20 md:py-28"> <div class="container-page"> <div class="grid lg:grid-cols-[1fr_1.8fr] gap-12 lg:gap-20 items-start"> <div class="lg:sticky lg:top-24" style="align-self: start;"> - <div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground font-mono mb-3">Webhooks + API</div> + <div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground font-mono mb-3">Webhooks & API</div> <h2 class="text-[28px] md:text-[40px] font-semibold tracking-[-0.025em] leading-[1.06] text-heading"> - One signed stream. Everything else builds on it. + Build your own with a signed event stream. </h2> <p class="mt-4 text-[15px] text-foreground/70 leading-relaxed"> - The catalog is convenience. The webhook surface is the API. Eighteen event types, HMAC-SHA256 signed in the Stripe format, retry queue with exponential backoff, full delivery history per endpoint. If a provider is not in the catalog yet, your own subscriber can do anything any of the listed integrations does. + Eighteen event types, signed with HMAC-SHA256 in the same format Stripe uses, retried with capped exponential backoff, and recorded with full delivery history per endpoint. Subscribe to all events or filter by type. </p> <ul class="mt-6 space-y-2 text-[13.5px] text-foreground/80"> <li class="flex items-start gap-2"><Icon name="check" size={13} class="text-[#0284c7] mt-1 shrink-0" /><span><span class="font-mono">X-Warmbly-Signature: t=<unix>,v1=<hex></span> on every POST.</span></li> - <li class="flex items-start gap-2"><Icon name="check" size={13} class="text-[#0284c7] mt-1 shrink-0" /><span>Per-endpoint event-type filter. Subscribe selectively.</span></li> - <li class="flex items-start gap-2"><Icon name="check" size={13} class="text-[#0284c7] mt-1 shrink-0" /><span>Up to 8 attempts with capped exponential backoff. Full audit trail.</span></li> - <li class="flex items-start gap-2"><Icon name="check" size={13} class="text-[#0284c7] mt-1 shrink-0" /><span>Secret rotation invalidates the old secret immediately.</span></li> + <li class="flex items-start gap-2"><Icon name="check" size={13} class="text-[#0284c7] mt-1 shrink-0" /><span>Per-endpoint filter by event type.</span></li> + <li class="flex items-start gap-2"><Icon name="check" size={13} class="text-[#0284c7] mt-1 shrink-0" /><span>Up to 8 attempts, exponential backoff, full audit trail.</span></li> + <li class="flex items-start gap-2"><Icon name="check" size={13} class="text-[#0284c7] mt-1 shrink-0" /><span>Rotating a secret invalidates the old one immediately.</span></li> </ul> </div> @@ -469,7 +399,7 @@ const faq = [ <!-- Code block --> <div class="rounded-[14px] bg-[#0c1224] ring-1 ring-white/10 overflow-hidden"> <div class="px-5 py-2.5 border-b border-white/10 flex items-center justify-between text-[10.5px] font-mono text-white/55"> - <span>POST · your webhook endpoint</span> + <span>POST your webhook endpoint</span> <span>application/json</span> </div> <pre class="px-5 py-4 text-[12px] leading-relaxed text-white/85 font-mono whitespace-pre overflow-x-auto"><code>{`{ @@ -509,18 +439,16 @@ const faq = [ </div> </section> - <!-- ============================================================ - SECURITY · trust strip - ============================================================ --> + <!-- SECURITY --> <section class="border-y border-[color:var(--border)] bg-[color:var(--surface-1)]/40 py-20 md:py-24"> <div class="container-page"> <div class="max-w-2xl mb-10"> <div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground font-mono mb-3">Security</div> <h2 class="text-[28px] md:text-[40px] font-semibold tracking-[-0.025em] leading-[1.06] text-heading"> - Credentials sit behind the same envelope as your mailbox tokens. + How credentials are stored. </h2> <p class="mt-4 text-[15px] text-foreground/70 leading-relaxed"> - Integrations do not get a different security story than the rest of the platform — the OAuth tokens you connected your Gmail with already round-trip through KMS-wrapped per-user DEKs. Integration tokens follow the same path. + Integration credentials use the same encryption envelope as mailbox OAuth tokens. Per-user data encryption keys are wrapped by AWS KMS, and plaintext keys are never persisted. </p> </div> @@ -536,19 +464,14 @@ const faq = [ </div> </section> - <!-- ============================================================ - BY THE NUMBERS - ============================================================ --> + <!-- BY THE NUMBERS --> <section class="bg-white py-16 md:py-20"> <div class="container-page"> <div class="max-w-2xl mb-10"> - <div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground font-mono mb-3">By the numbers</div> + <div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground font-mono mb-3">At a glance</div> <h2 class="text-[26px] md:text-[34px] font-semibold tracking-[-0.025em] leading-[1.06] text-heading"> - Shape of the surface, in one strip. + What the catalog covers. </h2> - <p class="mt-3 text-[14px] text-foreground/65 leading-relaxed"> - Real values from the codebase. The catalog is short for the reason in the stance section above. - </p> </div> <div class="grid grid-cols-2 md:grid-cols-4 gap-px bg-[color:var(--border)] rounded-[14px] overflow-hidden ring-1 ring-[color:var(--border)]"> @@ -566,18 +489,16 @@ const faq = [ </div> </section> - <!-- ============================================================ - FAQ - ============================================================ --> + <!-- FAQ --> <section class="bg-[color:var(--surface-1)]/40 border-y border-[color:var(--border)] py-20 md:py-24"> <div class="container-page grid lg:grid-cols-[1fr_1.8fr] gap-12 lg:gap-20 items-start"> <div class="lg:sticky lg:top-24" style="align-self: start;"> - <div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground font-mono mb-3">Integrations FAQ</div> + <div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground font-mono mb-3">FAQ</div> <h2 class="text-[28px] md:text-[36px] font-semibold tracking-[-0.025em] leading-[1.08] text-heading"> - Five questions worth asking. + Common questions. </h2> <p class="mt-4 text-[14.5px] text-foreground/70 leading-relaxed"> - More depth in the <a class="text-[#0369a1] hover:text-[#075985] font-medium" href="/developers/">developer docs</a>. + More detail in the <a class="text-[#0369a1] hover:text-[#075985] font-medium" href="/developers/">developer docs</a>. </p> </div> @@ -620,9 +541,9 @@ const faq = [ </section> <CTA - title="Wire it up. See what your stack already knew." - description="One drawer, three steps, no service desk ticket. Or read the developer docs and build your own." - primaryLabel="Connect a provider" + title="Get started with Warmbly." + description="Connect your providers, or read the developer docs to build a custom integration." + primaryLabel="Get started" primaryHref="https://app.warmbly.com/register" secondaryLabel="See the API" secondaryHref="/developers/" From 8d3b6b5d056e203a178e176a957f39f829cb715d Mon Sep 17 00:00:00 2001 From: Matt <meszmatew@gmail.com> Date: Thu, 28 May 2026 08:53:39 +0200 Subject: [PATCH 08/13] refactor(integration): drop Postmaster, SNDS, DMARC, DNS providers The deliverability-data and DNS-write integrations were over-engineered for the cold-email segment. Postmaster and SNDS require sending volume our base typically does not hit, and no comparable cold-email tool exposes DMARC ingestion or native DNS writes. Replaces the catalog with the standard set: HubSpot, Salesforce, Pipedrive, Close, Zapier, Make, n8n, Slack, Discord, Calendly, Cal.com, Google Sheets. Removes dmarc_reports, dmarc_record_rows, postmaster_snapshots, and dns_verifications tables from the migration. Deletes dmarc.go, dns.go, cloudflare.go, postmaster.go from the integration package. Prunes the matching repository methods and HTTP handlers. --- internal/api/handler/integration.go | 198 +------------ internal/api/routes.go | 10 +- internal/app/integration/catalog.go | 149 ++++++---- internal/app/integration/cloudflare.go | 189 ------------ internal/app/integration/dmarc.go | 136 --------- internal/app/integration/dns.go | 156 ---------- internal/app/integration/postmaster.go | 280 ------------------ internal/app/integration/service.go | 105 +++---- .../migrations/000044_integrations.down.sql | 4 - .../db/migrations/000044_integrations.up.sql | 167 ++--------- internal/models/integration.go | 137 +++------ internal/repository/pg_integration.go | 269 +---------------- 12 files changed, 221 insertions(+), 1579 deletions(-) delete mode 100644 internal/app/integration/cloudflare.go delete mode 100644 internal/app/integration/dmarc.go delete mode 100644 internal/app/integration/dns.go delete mode 100644 internal/app/integration/postmaster.go diff --git a/internal/api/handler/integration.go b/internal/api/handler/integration.go index 28ca45db..b45629a3 100644 --- a/internal/api/handler/integration.go +++ b/internal/api/handler/integration.go @@ -14,7 +14,7 @@ import ( ) // ListIntegrationCatalog returns the static metadata for every integration -// Warmbly supports — used by the dashboard to render the "available +// Warmbly supports. The dashboard uses this to render the "available // integrations" grid even when nothing is connected yet. func (h *Handler) ListIntegrationCatalog(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ @@ -22,8 +22,8 @@ func (h *Handler) ListIntegrationCatalog(c *gin.Context) { }) } -// ListIntegrationConnections returns this org's connection rows. Plain -// status snapshot — no secrets, no encrypted config. +// ListIntegrationConnections returns this org's connection rows. +// No secrets, no encrypted config. func (h *Handler) ListIntegrationConnections(c *gin.Context) { orgID, ok := requireOrgID(c) if !ok { @@ -38,17 +38,17 @@ func (h *Handler) ListIntegrationConnections(c *gin.Context) { } // integrationConnectPayload is the create-connection request body. The -// `config` map is per-provider — see integration.buildDisplayFields for -// which keys are recognized per provider. +// `config` map is per-provider; see integration.buildDisplayFields for +// which keys are recognized. type integrationConnectPayload struct { Provider string `json:"provider"` Label string `json:"label"` Config map[string]any `json:"config"` } -// ConnectIntegration creates / updates a connection. For inbound-webhook -// providers (Calendly, Cal.com, DMARC) the response includes the URL the -// user pastes into the provider — visible exactly once. +// ConnectIntegration creates or updates a connection. For inbound-webhook +// providers (Calendly, Cal.com) the response includes the URL the user +// pastes into the provider, visible exactly once. func (h *Handler) ConnectIntegration(c *gin.Context) { orgID, ok := requireOrgID(c) if !ok { @@ -65,22 +65,6 @@ func (h *Handler) ConnectIntegration(c *gin.Context) { return } - // For Cloudflare connections, verify the API token before persisting. - // The user types the token, hits "connect" — and learns immediately - // whether it's valid. The same pattern can be extended to GoDaddy / - // Namecheap once their client wrappers land. - if provider == models.IntegrationCloudflare { - token, _ := p.Config["api_token"].(string) - if token == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "api_token is required"}) - return - } - if err := integration.NewCloudflareClient(token).VerifyToken(c.Request.Context()); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - } - conn, err := h.IntegrationService.Connect(c.Request.Context(), orgID, provider, p.Label, p.Config) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) @@ -90,7 +74,7 @@ func (h *Handler) ConnectIntegration(c *gin.Context) { } // DisconnectIntegration removes a connection row. Cascading FKs handle -// dependent data (bookings, reports) per the migration. +// dependent data (bookings) per the migration. func (h *Handler) DisconnectIntegration(c *gin.Context) { orgID, ok := requireOrgID(c) if !ok { @@ -108,12 +92,12 @@ func (h *Handler) DisconnectIntegration(c *gin.Context) { c.Status(http.StatusNoContent) } -// ─── Inbound webhooks ────────────────────────────────────────────────── +// Inbound webhooks // -// Per-provider inbound endpoints. All take a secret in the URL path — the -// secret was minted on connect and is unique per (org, provider). No -// org context comes from the auth middleware here because providers POST -// from their own infrastructure with no Warmbly bearer. +// Per-provider inbound endpoints. The secret in the URL path was minted on +// connect and is unique per (org, provider). No org context comes from +// the auth middleware here because providers POST from their own +// infrastructure with no Warmbly bearer. // InboundCalendly handles invitee.created webhooks. The secret in the URL // routes the event to the right org. @@ -126,8 +110,8 @@ func (h *Handler) InboundCalCom(c *gin.Context) { } // handleInboundBooking is shared between Calendly and Cal.com. The -// per-provider parsing logic differs (different JSON shapes) but the -// routing (secret → org → save booking → fire webhook) is identical. +// per-provider parsing logic differs but the routing (secret to org, save +// booking, fire webhook) is identical. func (h *Handler) handleInboundBooking(c *gin.Context, provider models.IntegrationProvider) { secret := strings.TrimSpace(c.Param("secret")) if secret == "" { @@ -186,68 +170,6 @@ func (h *Handler) handleInboundBooking(c *gin.Context, provider models.Integrati c.JSON(http.StatusOK, gin.H{"received": true}) } -// InboundDMARC accepts a single RUA XML report. Mailbox providers typically -// email these to a rua= address; the dashboard exposes a forwarder URL the -// user can either POST to directly or hook up to a mail-to-HTTP relay. -func (h *Handler) InboundDMARC(c *gin.Context) { - secret := strings.TrimSpace(c.Param("secret")) - if secret == "" { - c.JSON(http.StatusUnauthorized, gin.H{"error": "secret required"}) - return - } - conn, err := h.IntegrationService.Repo().GetConnectionByInboundSecret(c.Request.Context(), models.IntegrationDMARC, secret) - if err != nil || conn == nil { - c.JSON(http.StatusNotFound, gin.H{"error": "unknown secret"}) - return - } - body, err := io.ReadAll(io.LimitReader(c.Request.Body, 5<<20)) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "read body failed"}) - return - } - report, err := integration.IngestDMARCReport(c.Request.Context(), h.IntegrationService.Repo(), conn.OrganizationID, body) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"report_id": report.ID, "domain": report.Domain}) -} - -// ─── Reads for the dashboard ─────────────────────────────────────────── - -// ListDMARCReports surfaces ingested DMARC reports for the dashboard -// "deliverability" tab. -func (h *Handler) ListDMARCReports(c *gin.Context) { - orgID, ok := requireOrgID(c) - if !ok { - return - } - domain := c.Query("domain") - reports, err := h.IntegrationService.Repo().ListDMARCReports(c.Request.Context(), orgID, domain, 100) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "list failed"}) - return - } - c.JSON(http.StatusOK, gin.H{"reports": reports}) -} - -// ListPostmasterSnapshots surfaces Postmaster + SNDS rows. The dashboard -// uses this to render the deliverability trend graph. -func (h *Handler) ListPostmasterSnapshots(c *gin.Context) { - orgID, ok := requireOrgID(c) - if !ok { - return - } - source := c.Query("source") - target := c.Query("target") - rows, err := h.IntegrationService.Repo().ListPostmasterSnapshots(c.Request.Context(), orgID, source, target, 30) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "list failed"}) - return - } - c.JSON(http.StatusOK, gin.H{"snapshots": rows}) -} - // ListMeetingBookings surfaces booked meetings for the integrations page. func (h *Handler) ListMeetingBookings(c *gin.Context) { orgID, ok := requireOrgID(c) @@ -261,91 +183,3 @@ func (h *Handler) ListMeetingBookings(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{"bookings": rows}) } - -// ─── DNS verification ────────────────────────────────────────────────── - -type dnsVerifyPayload struct { - Domain string `json:"domain"` - DKIMSelector string `json:"dkim_selector"` - TrackingCNAME string `json:"tracking_cname"` -} - -// VerifyDNS runs a live SPF/DKIM/DMARC + tracking-CNAME check for a -// domain and persists the snapshot. Returns the verification row so the -// dashboard can render it without a second request. -func (h *Handler) VerifyDNS(c *gin.Context) { - orgID, ok := requireOrgID(c) - if !ok { - return - } - var p dnsVerifyPayload - if err := c.ShouldBindJSON(&p); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) - return - } - v, err := integration.VerifyDNS(c.Request.Context(), h.IntegrationService.Repo(), orgID, integration.DNSVerifyRequest{ - Domain: p.Domain, - DKIMSelector: p.DKIMSelector, - TrackingCNAME: p.TrackingCNAME, - }) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, v) -} - -// ListDNSVerifications returns the latest verification per domain. -func (h *Handler) ListDNSVerifications(c *gin.Context) { - orgID, ok := requireOrgID(c) - if !ok { - return - } - rows, err := h.IntegrationService.Repo().ListDNSVerifications(c.Request.Context(), orgID, 50) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "list failed"}) - return - } - c.JSON(http.StatusOK, gin.H{"verifications": rows}) -} - -// ApplyCloudflareRecords is the one-click "set up SPF/DKIM/DMARC on -// Cloudflare" endpoint. Uses the stored API token for the configured -// Cloudflare connection. -type applyDNSPayload struct { - ConnectionID uuid.UUID `json:"connection_id"` - Domain string `json:"domain"` - DKIMSelector string `json:"dkim_selector"` - DKIMPublicKey string `json:"dkim_public_key"` - APIToken string `json:"api_token"` // optional: override -} - -func (h *Handler) ApplyCloudflareRecords(c *gin.Context) { - orgID, ok := requireOrgID(c) - if !ok { - return - } - var p applyDNSPayload - if err := c.ShouldBindJSON(&p); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) - return - } - token := strings.TrimSpace(p.APIToken) - if token == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "api_token is required (or persist via Connect)"}) - return - } - client := integration.NewCloudflareClient(token) - records := integration.RecommendedRecords(p.Domain, p.DKIMSelector, p.DKIMPublicKey) - if err := client.ApplyRecords(c.Request.Context(), p.Domain, records); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - // Mark the connection as synced so the dashboard reflects the action. - if p.ConnectionID != uuid.Nil { - _ = h.IntegrationService.MarkSynced(c.Request.Context(), p.ConnectionID, models.IntegrationStatusConnected, - map[string]any{"last_action": "applied_records", "domain": p.Domain}, "") - } - _ = orgID // referenced for symmetry; future per-org rate limiting hooks - c.JSON(http.StatusOK, gin.H{"applied": len(records), "records": records}) -} diff --git a/internal/api/routes.go b/internal/api/routes.go index a70b4074..091f3bb2 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -32,11 +32,10 @@ func Run( r.POST("/webhooks/github/releases", h.GithubReleasesWebhook) // Public inbound webhooks for third-party integrations. Auth is the - // per-org secret embedded in the URL path — minted at connect time and + // per-org secret embedded in the URL path, minted at connect time and // rotatable from the dashboard. r.POST("/api/v1/integrations/inbound/calendly/:secret", h.InboundCalendly) r.POST("/api/v1/integrations/inbound/cal-com/:secret", h.InboundCalCom) - r.POST("/api/v1/integrations/inbound/dmarc/:secret", h.InboundDMARC) // Public OAuth-bouncer pages used by the mailbox onboarding popup. // The provider redirects here; the page postMessages the code/state @@ -333,14 +332,7 @@ func Run( integrations.GET("/connections", h.ListIntegrationConnections) integrations.POST("/connections", h.ConnectIntegration) integrations.DELETE("/connections/:id", h.DisconnectIntegration) - - integrations.GET("/dmarc/reports", h.ListDMARCReports) - integrations.GET("/postmaster/snapshots", h.ListPostmasterSnapshots) integrations.GET("/bookings", h.ListMeetingBookings) - - integrations.POST("/dns/verify", h.VerifyDNS) - integrations.GET("/dns/verifications", h.ListDNSVerifications) - integrations.POST("/dns/cloudflare/apply", h.ApplyCloudflareRecords) } // Warmup routing rules (org-scoped). Lets customers define diff --git a/internal/app/integration/catalog.go b/internal/app/integration/catalog.go index cb090334..a966735d 100644 --- a/internal/app/integration/catalog.go +++ b/internal/app/integration/catalog.go @@ -1,8 +1,8 @@ // Package integration owns the third-party integrations surface: catalog -// metadata, per-provider connect/disconnect, inbound webhook handling, -// scheduled pulls (Postmaster/SNDS), DMARC ingestion, and DNS verification. +// metadata, per-provider connect/disconnect, and inbound webhook handling +// for Calendly + Cal.com. // -// Per-provider files (calendly.go, dmarc.go, etc) each handle the +// Per-provider files (calendly.go, google_sheets.go) each handle the // provider-specific request/response shape. The shared service.go ties // them to the connections repo so the dashboard reads them uniformly. package integration @@ -10,85 +10,118 @@ package integration import "github.com/warmbly/warmbly/internal/models" // Catalog returns the static metadata for every integration the dashboard -// renders. Order is the catalog order. +// renders. Order is the catalog order users see. func Catalog() []models.IntegrationCatalogEntry { return []models.IntegrationCatalogEntry{ + // CRM + { + Provider: models.IntegrationHubSpot, + Name: "HubSpot", + Tagline: "Two-way sync for contacts and activities.", + Category: models.IntegrationCategoryCRM, + AuthMethod: "oauth", + DocsURL: "https://developers.hubspot.com/docs/api/overview", + }, + { + Provider: models.IntegrationSalesforce, + Name: "Salesforce", + Tagline: "Sync leads, contacts, and email activity.", + Category: models.IntegrationCategoryCRM, + AuthMethod: "oauth", + DocsURL: "https://developer.salesforce.com/docs", + }, + { + Provider: models.IntegrationPipedrive, + Name: "Pipedrive", + Tagline: "Persons, deals, and activity timeline.", + Category: models.IntegrationCategoryCRM, + AuthMethod: "oauth", + DocsURL: "https://developers.pipedrive.com", + }, + { + Provider: models.IntegrationClose, + Name: "Close", + Tagline: "Leads, contacts, and inbox activity.", + Category: models.IntegrationCategoryCRM, + AuthMethod: "api_key", + DocsURL: "https://developer.close.com", + }, + + // Automation + { + Provider: models.IntegrationZapier, + Name: "Zapier", + Tagline: "Triggers and actions across 8,000+ apps.", + Category: models.IntegrationCategoryAutomation, + AuthMethod: "api_key", + DocsURL: "https://zapier.com/apps", + }, + { + Provider: models.IntegrationMake, + Name: "Make", + Tagline: "Visual automation scenarios.", + Category: models.IntegrationCategoryAutomation, + AuthMethod: "api_key", + DocsURL: "https://www.make.com/en/integrations", + }, + { + Provider: models.IntegrationN8N, + Name: "n8n", + Tagline: "Self-hosted automation workflows.", + Category: models.IntegrationCategoryAutomation, + AuthMethod: "api_key", + DocsURL: "https://docs.n8n.io", + }, + + // Notifications + { + Provider: models.IntegrationSlack, + Name: "Slack", + Tagline: "Channels for positive replies, bounces, and meeting bookings.", + Category: models.IntegrationCategoryNotifications, + AuthMethod: "oauth", + DocsURL: "https://api.slack.com", + WebhookHint: "Incoming-webhook URL or OAuth app installation.", + }, + { + Provider: models.IntegrationDiscord, + Name: "Discord", + Tagline: "Webhook-based notifications to a server channel.", + Category: models.IntegrationCategoryNotifications, + AuthMethod: "webhook", + DocsURL: "https://discord.com/developers/docs/resources/webhook", + WebhookHint: "Paste a Discord channel webhook URL.", + }, + + // Meetings { Provider: models.IntegrationCalendly, Name: "Calendly", - Tagline: "Mark a campaign as converted when a recipient books a meeting.", + Tagline: "Attribute booked meetings to the campaign that surfaced the lead.", Category: models.IntegrationCategoryMeetings, AuthMethod: "webhook", DocsURL: "https://developer.calendly.com/api-docs/", - WebhookHint: "Calendly POSTs invitee.created and invitee.canceled here.", + WebhookHint: "Calendly POSTs invitee.created here.", }, { Provider: models.IntegrationCalCom, Name: "Cal.com", - Tagline: "Open-source meeting booking. Same conversion attribution as Calendly.", + Tagline: "Same attribution path, open-source booking edition.", Category: models.IntegrationCategoryMeetings, AuthMethod: "webhook", DocsURL: "https://cal.com/docs/core-features/webhooks", WebhookHint: "Cal.com POSTs BOOKING_CREATED events here.", }, + + // Data { Provider: models.IntegrationGoogleSheets, Name: "Google Sheets", - Tagline: "Pull leads from a sheet and push reply / bounce / booked events back to it.", + Tagline: "Pull leads from a sheet, push reply / bounce / booked events back.", Category: models.IntegrationCategoryData, AuthMethod: "oauth", DocsURL: "https://developers.google.com/sheets/api", BetaFlag: true, }, - { - Provider: models.IntegrationGooglePostmaster, - Name: "Google Postmaster", - Tagline: "Pull domain reputation + spam-rate signals straight from Google.", - Category: models.IntegrationCategoryDeliverability, - AuthMethod: "oauth", - DocsURL: "https://developers.google.com/gmail/postmaster", - }, - { - Provider: models.IntegrationMicrosoftSNDS, - Name: "Microsoft SNDS", - Tagline: "IP reputation + complaint rate for Outlook / Hotmail.", - Category: models.IntegrationCategoryDeliverability, - AuthMethod: "api_key", - DocsURL: "https://sendersupport.olc.protection.outlook.com/snds/", - }, - { - Provider: models.IntegrationDMARC, - Name: "DMARC reports", - Tagline: "Ingest aggregate (RUA) reports and flag misaligned senders.", - Category: models.IntegrationCategoryDeliverability, - AuthMethod: "webhook", - WebhookHint: "POST RUA XML reports here; one report per request.", - }, - { - Provider: models.IntegrationCloudflare, - Name: "Cloudflare", - Tagline: "One-click SPF / DKIM / DMARC + tracking-domain CNAME.", - Category: models.IntegrationCategoryDNS, - AuthMethod: "api_key", - DocsURL: "https://developers.cloudflare.com/api/", - }, - { - Provider: models.IntegrationGoDaddy, - Name: "GoDaddy", - Tagline: "Write SPF / DKIM / DMARC records from the dashboard.", - Category: models.IntegrationCategoryDNS, - AuthMethod: "api_key", - DocsURL: "https://developer.godaddy.com/doc/endpoint/domains", - BetaFlag: true, - }, - { - Provider: models.IntegrationNamecheap, - Name: "Namecheap", - Tagline: "Write SPF / DKIM / DMARC records from the dashboard.", - Category: models.IntegrationCategoryDNS, - AuthMethod: "api_key", - DocsURL: "https://www.namecheap.com/support/api/intro/", - BetaFlag: true, - }, } } diff --git a/internal/app/integration/cloudflare.go b/internal/app/integration/cloudflare.go deleted file mode 100644 index 48553606..00000000 --- a/internal/app/integration/cloudflare.go +++ /dev/null @@ -1,189 +0,0 @@ -package integration - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "strings" - "time" -) - -// CloudflareClient is a thin wrapper around the Cloudflare DNS API. We -// implement only the verbs we need: list zones, list records, create a -// TXT or CNAME record. The full SDK is overkill for SPF/DKIM/DMARC writes -// and pulling it in would compromise the worker-side build (CLAUDE.md: -// workers stay lean). -type CloudflareClient struct { - apiToken string - http *http.Client -} - -func NewCloudflareClient(apiToken string) *CloudflareClient { - return &CloudflareClient{ - apiToken: apiToken, - http: &http.Client{Timeout: 10 * time.Second}, - } -} - -// VerifyToken confirms the API token is alive and scoped to at least one -// DNS zone. Called by the connect flow before persisting credentials so -// the user sees an error in the dashboard, not a silent half-broken -// connection. -func (c *CloudflareClient) VerifyToken(ctx context.Context) error { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, - "https://api.cloudflare.com/client/v4/user/tokens/verify", nil) - if err != nil { - return err - } - req.Header.Set("Authorization", "Bearer "+c.apiToken) - resp, err := c.http.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) - return fmt.Errorf("cloudflare token verify: HTTP %d: %s", resp.StatusCode, string(body)) - } - return nil -} - -type cloudflareZone struct { - ID string `json:"id"` - Name string `json:"name"` -} - -type cloudflareZonesResponse struct { - Success bool `json:"success"` - Errors []cloudflareErr `json:"errors"` - Result []cloudflareZone `json:"result"` -} - -type cloudflareErr struct { - Code int `json:"code"` - Message string `json:"message"` -} - -// FindZone resolves the apex zone for a domain (e.g. "foo.bar.com" → -// "bar.com" zone). Cloudflare's API requires the zone ID for any record -// mutation, so we cannot skip this step. -func (c *CloudflareClient) FindZone(ctx context.Context, domain string) (string, string, error) { - apex := strings.ToLower(strings.TrimSpace(domain)) - if apex == "" { - return "", "", errors.New("domain is required") - } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, - "https://api.cloudflare.com/client/v4/zones?name="+apex, nil) - if err != nil { - return "", "", err - } - req.Header.Set("Authorization", "Bearer "+c.apiToken) - resp, err := c.http.Do(req) - if err != nil { - return "", "", err - } - defer resp.Body.Close() - var parsed cloudflareZonesResponse - if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil { - return "", "", err - } - if !parsed.Success || len(parsed.Result) == 0 { - return "", "", fmt.Errorf("zone not found for %s", apex) - } - return parsed.Result[0].ID, parsed.Result[0].Name, nil -} - -// DNSRecordInput is the per-record payload that ApplyRecords accepts. -type DNSRecordInput struct { - Type string `json:"type"` // "TXT" | "CNAME" - Name string `json:"name"` - Content string `json:"content"` - TTL int `json:"ttl"` // 1 means "auto" -} - -type cloudflareRecordResponse struct { - Success bool `json:"success"` - Errors []cloudflareErr `json:"errors"` -} - -// ApplyRecord creates (or updates) one DNS record. Cloudflare's API has -// no upsert primitive, so we list the zone's records, find a match by -// (type, name), and either PATCH or POST. -func (c *CloudflareClient) ApplyRecord(ctx context.Context, zoneID string, rec DNSRecordInput) error { - if rec.TTL == 0 { - rec.TTL = 1 - } - body, _ := json.Marshal(rec) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, - "https://api.cloudflare.com/client/v4/zones/"+zoneID+"/dns_records", - bytes.NewReader(body)) - if err != nil { - return err - } - req.Header.Set("Authorization", "Bearer "+c.apiToken) - req.Header.Set("Content-Type", "application/json") - resp, err := c.http.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusCreated { - return nil - } - respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) - return fmt.Errorf("cloudflare apply record: HTTP %d: %s", resp.StatusCode, string(respBody)) -} - -// ApplyRecords is the convenience entry the dashboard's "one-click setup" -// button calls. Looks up the zone for the domain and applies the three -// records (SPF, DKIM, DMARC) in sequence. Errors include the first record -// that failed so the dashboard can show actionable feedback. -func (c *CloudflareClient) ApplyRecords(ctx context.Context, domain string, records []DNSRecordInput) error { - zoneID, _, err := c.FindZone(ctx, domain) - if err != nil { - return err - } - for _, r := range records { - if err := c.ApplyRecord(ctx, zoneID, r); err != nil { - return fmt.Errorf("%s record: %w", r.Type, err) - } - } - return nil -} - -// RecommendedRecords returns the SPF/DKIM/DMARC records Warmbly recommends -// for a domain, given the user-supplied DKIM public key. We expose this -// from the integration package so the Cloudflare/GoDaddy/Namecheap -// implementations all share one source of truth. -func RecommendedRecords(domain string, dkimSelector, dkimPublicKey string) []DNSRecordInput { - domain = strings.ToLower(domain) - out := []DNSRecordInput{ - { - Type: "TXT", - Name: domain, - Content: "v=spf1 include:_spf.warmbly.com ~all", - }, - { - Type: "TXT", - Name: "_dmarc." + domain, - Content: "v=DMARC1; p=quarantine; rua=mailto:dmarc@" + domain + - "; ruf=mailto:dmarc@" + domain + "; pct=100; aspf=r; adkim=r", - }, - } - if dkimPublicKey != "" { - selector := dkimSelector - if selector == "" { - selector = "warmbly" - } - out = append(out, DNSRecordInput{ - Type: "TXT", - Name: selector + "._domainkey." + domain, - Content: "v=DKIM1; k=rsa; p=" + dkimPublicKey, - }) - } - return out -} diff --git a/internal/app/integration/dmarc.go b/internal/app/integration/dmarc.go deleted file mode 100644 index 4b353886..00000000 --- a/internal/app/integration/dmarc.go +++ /dev/null @@ -1,136 +0,0 @@ -package integration - -import ( - "context" - "encoding/xml" - "errors" - "fmt" - "strings" - "time" - - "github.com/google/uuid" - - "github.com/warmbly/warmbly/internal/models" - "github.com/warmbly/warmbly/internal/repository" -) - -// dmarcXML mirrors the RFC 7489 aggregate-report schema. Only the fields -// we actually display are decoded; the rest of the XML is dropped on the -// floor (no harm — the next provider's schema deviation would just add -// unused tags). -type dmarcXML struct { - XMLName xml.Name `xml:"feedback"` - - ReportMetadata struct { - OrgName string `xml:"org_name"` - Email string `xml:"email"` - ReportID string `xml:"report_id"` - DateRange struct { - Begin int64 `xml:"begin"` - End int64 `xml:"end"` - } `xml:"date_range"` - } `xml:"report_metadata"` - - PolicyPublished struct { - Domain string `xml:"domain"` - ADKIM string `xml:"adkim"` - ASPF string `xml:"aspf"` - P string `xml:"p"` - SP string `xml:"sp"` - PCT int `xml:"pct"` - } `xml:"policy_published"` - - Records []struct { - Row struct { - SourceIP string `xml:"source_ip"` - Count int64 `xml:"count"` - PolicyEvaluated struct { - Disposition string `xml:"disposition"` - DKIM string `xml:"dkim"` - SPF string `xml:"spf"` - } `xml:"policy_evaluated"` - } `xml:"row"` - Identifiers struct { - HeaderFrom string `xml:"header_from"` - } `xml:"identifiers"` - AuthResults struct { - DKIM []struct { - Domain string `xml:"domain"` - Result string `xml:"result"` - Selector string `xml:"selector"` - } `xml:"dkim"` - SPF []struct { - Domain string `xml:"domain"` - Result string `xml:"result"` - } `xml:"spf"` - } `xml:"auth_results"` - } `xml:"record"` -} - -// IngestDMARCReport parses one RUA XML report and persists it. Idempotent -// on (org, reporter, report_id). -func IngestDMARCReport( - ctx context.Context, - repo repository.IntegrationRepository, - orgID uuid.UUID, - body []byte, -) (*models.DMARCReport, error) { - trimmed := strings.TrimSpace(string(body)) - if trimmed == "" { - return nil, errors.New("empty DMARC report body") - } - - var x dmarcXML - dec := xml.NewDecoder(strings.NewReader(trimmed)) - // Disable external entity expansion — DMARC XML never references - // external entities, so refusing them is a free defence. - dec.Strict = true - if err := dec.Decode(&x); err != nil { - return nil, fmt.Errorf("parse dmarc xml: %w", err) - } - - if x.PolicyPublished.Domain == "" || x.ReportMetadata.ReportID == "" { - return nil, errors.New("dmarc report missing required fields") - } - - report := &models.DMARCReport{ - OrganizationID: orgID, - Domain: x.PolicyPublished.Domain, - ReporterOrg: x.ReportMetadata.OrgName, - ReportID: x.ReportMetadata.ReportID, - RangeStart: time.Unix(x.ReportMetadata.DateRange.Begin, 0).UTC(), - RangeEnd: time.Unix(x.ReportMetadata.DateRange.End, 0).UTC(), - } - - for _, rec := range x.Records { - row := models.DMARCRecordRow{ - SourceIP: rec.Row.SourceIP, - MessageCount: rec.Row.Count, - Disposition: rec.Row.PolicyEvaluated.Disposition, - SPFResult: rec.Row.PolicyEvaluated.SPF, - DKIMResult: rec.Row.PolicyEvaluated.DKIM, - HeaderFrom: rec.Identifiers.HeaderFrom, - } - if len(rec.AuthResults.SPF) > 0 { - row.SPFDomain = rec.AuthResults.SPF[0].Domain - } - if len(rec.AuthResults.DKIM) > 0 { - row.DKIMDomain = rec.AuthResults.DKIM[0].Domain - } - report.Rows = append(report.Rows, row) - - report.TotalMessages += rec.Row.Count - // "pass" semantics: both SPF and DKIM evaluated as pass. Lines up - // with the DMARC RFC's alignment definition. - if rec.Row.PolicyEvaluated.SPF == "pass" && rec.Row.PolicyEvaluated.DKIM == "pass" { - report.PassMessages += rec.Row.Count - } else { - report.FailMessages += rec.Row.Count - } - } - - if err := repo.UpsertDMARCReport(ctx, report); err != nil { - return nil, err - } - return report, nil -} diff --git a/internal/app/integration/dns.go b/internal/app/integration/dns.go deleted file mode 100644 index 6f4beb83..00000000 --- a/internal/app/integration/dns.go +++ /dev/null @@ -1,156 +0,0 @@ -package integration - -import ( - "context" - "encoding/json" - "errors" - "net" - "strings" - "time" - - "github.com/google/uuid" - - "github.com/warmbly/warmbly/internal/models" - "github.com/warmbly/warmbly/internal/repository" -) - -// DNSVerifyRequest is the dashboard's "check my domain" call. The caller -// must provide the domain; the DKIM selector and tracking CNAME are -// optional (defaults applied below). -type DNSVerifyRequest struct { - Domain string `json:"domain"` - DKIMSelector string `json:"dkim_selector,omitempty"` - TrackingCNAME string `json:"tracking_cname,omitempty"` -} - -// VerifyDNS resolves SPF, DKIM, DMARC, and the optional tracking CNAME -// and writes a verification row. Returns the verification so the -// dashboard can render it immediately without a second round-trip. -// -// Verification rules — same shape as Postmark / Mailgun's checkers: -// - SPF: TXT on the apex containing 'v=spf1'. -// - DKIM: TXT on `<selector>._domainkey.<domain>` containing 'k=rsa' or -// 'p=' — required for DKIM signing to work at all. -// - DMARC: TXT on `_dmarc.<domain>` starting 'v=DMARC1'. -// - Tracking: CNAME on `<tracking>` resolves to one of our known -// tracking hosts. -func VerifyDNS( - ctx context.Context, - repo repository.IntegrationRepository, - orgID uuid.UUID, - req DNSVerifyRequest, -) (*models.DNSVerification, error) { - domain := strings.TrimSpace(strings.ToLower(req.Domain)) - if domain == "" { - return nil, errors.New("domain is required") - } - - resolver := &net.Resolver{} - deadline, cancel := context.WithTimeout(ctx, 5*time.Second) - defer cancel() - - v := &models.DNSVerification{ - OrganizationID: orgID, - Domain: domain, - } - notes := map[string]string{} - - // SPF - if records, err := resolver.LookupTXT(deadline, domain); err == nil { - for _, rec := range records { - if strings.HasPrefix(strings.ToLower(rec), "v=spf1") { - v.SPFRecord = ptrStr(rec) - v.SPFOK = true - break - } - } - if !v.SPFOK { - notes["spf"] = "no v=spf1 TXT record found on " + domain - } - } else { - notes["spf"] = "spf lookup failed: " + err.Error() - } - - // DKIM - selector := strings.TrimSpace(req.DKIMSelector) - if selector == "" { - // Try the common defaults; first one that resolves wins. This - // matters because every provider uses its own selector convention. - for _, candidate := range []string{"warmbly", "google", "selector1", "default"} { - if records, err := resolver.LookupTXT(deadline, candidate+"._domainkey."+domain); err == nil && len(records) > 0 { - selector = candidate - v.DKIMRecord = ptrStr(joinTXT(records)) - v.DKIMSelector = ptrStr(candidate) - v.DKIMOK = looksLikeDKIM(*v.DKIMRecord) - break - } - } - if v.DKIMSelector == nil { - notes["dkim"] = "no DKIM selector resolves on common names; pass dkim_selector to override" - } - } else { - v.DKIMSelector = ptrStr(selector) - records, err := resolver.LookupTXT(deadline, selector+"._domainkey."+domain) - if err != nil { - notes["dkim"] = "dkim lookup failed: " + err.Error() - } else if len(records) == 0 { - notes["dkim"] = "dkim TXT empty on " + selector + "._domainkey." + domain - } else { - joined := joinTXT(records) - v.DKIMRecord = &joined - v.DKIMOK = looksLikeDKIM(joined) - } - } - - // DMARC - if records, err := resolver.LookupTXT(deadline, "_dmarc."+domain); err == nil { - for _, rec := range records { - if strings.HasPrefix(strings.ToUpper(rec), "V=DMARC1") { - v.DMARCRecord = ptrStr(rec) - v.DMARCOK = true - break - } - } - if !v.DMARCOK { - notes["dmarc"] = "no v=DMARC1 TXT record found at _dmarc." + domain - } - } else { - notes["dmarc"] = "dmarc lookup failed: " + err.Error() - } - - // Tracking domain CNAME (optional) - if t := strings.TrimSpace(req.TrackingCNAME); t != "" { - if cname, err := resolver.LookupCNAME(deadline, t); err == nil && cname != "" { - v.TrackingCNAME = ptrStr(strings.TrimSuffix(cname, ".")) - cn := strings.TrimSuffix(cname, ".") - v.TrackingOK = strings.HasSuffix(cn, "trk.warmbly.com") || - strings.HasSuffix(cn, "track.warmbly.com") - if !v.TrackingOK { - notes["tracking"] = "CNAME resolves to " + cn + " (expected *.warmbly.com)" - } - } else { - notes["tracking"] = "tracking CNAME does not resolve" - } - } - - notesJSON, _ := json.Marshal(notes) - v.Notes = notesJSON - - if err := repo.InsertDNSVerification(ctx, v); err != nil { - return nil, err - } - return v, nil -} - -func ptrStr(s string) *string { return &s } - -func joinTXT(records []string) string { - // Long TXT records arrive as multiple chunks; the DNS server joins - // them without delimiters. - return strings.Join(records, "") -} - -func looksLikeDKIM(rec string) bool { - lower := strings.ToLower(rec) - return strings.Contains(lower, "k=rsa") || strings.Contains(lower, "p=") -} diff --git a/internal/app/integration/postmaster.go b/internal/app/integration/postmaster.go deleted file mode 100644 index 7b51b6e3..00000000 --- a/internal/app/integration/postmaster.go +++ /dev/null @@ -1,280 +0,0 @@ -package integration - -import ( - "context" - "encoding/csv" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "strconv" - "strings" - "time" - - "github.com/google/uuid" - - "github.com/warmbly/warmbly/internal/models" - "github.com/warmbly/warmbly/internal/repository" -) - -// PostmasterClient calls Google's Gmail Postmaster Tools API. OAuth token -// management lives in the existing email-account OAuth path — workers -// (and this puller) consume short-lived access tokens minted by the -// backend's token service. This client takes a ready-to-use bearer. -type PostmasterClient struct { - bearerToken string - http *http.Client -} - -func NewPostmasterClient(bearerToken string) *PostmasterClient { - return &PostmasterClient{ - bearerToken: bearerToken, - http: &http.Client{Timeout: 15 * time.Second}, - } -} - -// PullDomainTrafficStats fetches Google Postmaster's daily traffic stats -// for a domain and persists one PostmasterSnapshot per day. Idempotent -// on (org, source='google_postmaster', target=domain, snapshot_date). -func (c *PostmasterClient) PullDomainTrafficStats( - ctx context.Context, - repo repository.IntegrationRepository, - orgID uuid.UUID, - domain string, - daysBack int, -) (int, error) { - if daysBack <= 0 || daysBack > 90 { - daysBack = 30 - } - endpoint := fmt.Sprintf( - "https://gmailpostmastertools.googleapis.com/v1/domains/%s/trafficStats?pageSize=%d", - domain, daysBack, - ) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) - if err != nil { - return 0, err - } - req.Header.Set("Authorization", "Bearer "+c.bearerToken) - resp, err := c.http.Do(req) - if err != nil { - return 0, err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) - return 0, fmt.Errorf("postmaster trafficStats HTTP %d: %s", resp.StatusCode, string(body)) - } - - var parsed struct { - TrafficStats []struct { - Name string `json:"name"` - UserReportedSpamRatio float64 `json:"userReportedSpamRatio"` - IPReputations []struct { - Reputation string `json:"reputation"` - } `json:"ipReputations"` - DomainReputation string `json:"domainReputation"` - InboundEncryptionRatio float64 `json:"inboundEncryptionRatio"` - SPFSuccessRatio float64 `json:"spfSuccessRatio"` - DKIMSuccessRatio float64 `json:"dkimSuccessRatio"` - DMARCSuccessRatio float64 `json:"dmarcSuccessRatio"` - } `json:"trafficStats"` - } - if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil { - return 0, err - } - - count := 0 - for _, ts := range parsed.TrafficStats { - date, ok := parsePostmasterDate(ts.Name) - if !ok { - continue - } - spfPct := ts.SPFSuccessRatio * 100 - dkimPct := ts.DKIMSuccessRatio * 100 - dmarcPct := ts.DMARCSuccessRatio * 100 - spamPct := ts.UserReportedSpamRatio * 100 - raw, _ := json.Marshal(ts) - - domainRep := ts.DomainReputation - ipRep := "" - if len(ts.IPReputations) > 0 { - ipRep = ts.IPReputations[0].Reputation - } - snap := &models.PostmasterSnapshot{ - OrganizationID: orgID, - Source: "google_postmaster", - Target: domain, - SnapshotDate: date, - SpamRatePct: &spamPct, - DomainReputation: &domainRep, - SPFSuccessPct: &spfPct, - DKIMSuccessPct: &dkimPct, - DMARCSuccessPct: &dmarcPct, - RawPayload: raw, - } - if ipRep != "" { - snap.IPReputation = &ipRep - } - if err := repo.UpsertPostmasterSnapshot(ctx, snap); err != nil { - return count, err - } - count++ - } - return count, nil -} - -// parsePostmasterDate extracts the YYYYMMDD from a name like -// "domains/example.com/trafficStats/20260315". -func parsePostmasterDate(name string) (time.Time, bool) { - parts := strings.Split(name, "/") - if len(parts) == 0 { - return time.Time{}, false - } - last := parts[len(parts)-1] - if len(last) != 8 { - return time.Time{}, false - } - t, err := time.Parse("20060102", last) - if err != nil { - return time.Time{}, false - } - return t, true -} - -// SNDSClient pulls Microsoft Smart Network Data Services reports. SNDS -// uses a long-lived per-IP "data access key" rather than OAuth, so the -// shape is simpler than Postmaster: GET a CSV, parse, persist. -type SNDSClient struct { - dataAccessKey string - http *http.Client -} - -func NewSNDSClient(dataAccessKey string) *SNDSClient { - return &SNDSClient{ - dataAccessKey: dataAccessKey, - http: &http.Client{Timeout: 15 * time.Second}, - } -} - -// PullIPReputation fetches the SNDS automated-data CSV for the configured -// IP range and persists one PostmasterSnapshot per (IP, date). The SNDS -// CSV is documented at https://sendersupport.olc.protection.outlook.com/snds/auto.aspx -// — columns: IP, activity-start, activity-end, RCPT-commands, data-commands, -// message-recipients, filter-result, complaint-rate-bucket, trap-message-period, -// trap-hits, sample-HELO, sample-from. -func (c *SNDSClient) PullIPReputation( - ctx context.Context, - repo repository.IntegrationRepository, - orgID uuid.UUID, -) (int, error) { - if c.dataAccessKey == "" { - return 0, errors.New("SNDS data access key is empty") - } - url := "https://sendersupport.olc.protection.outlook.com/snds/automated.aspx?key=" + c.dataAccessKey - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return 0, err - } - resp, err := c.http.Do(req) - if err != nil { - return 0, err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return 0, fmt.Errorf("SNDS HTTP %d", resp.StatusCode) - } - - r := csv.NewReader(resp.Body) - r.FieldsPerRecord = -1 - count := 0 - for { - row, err := r.Read() - if err == io.EOF { - break - } - if err != nil { - return count, err - } - if len(row) < 8 { - continue - } - ip := strings.TrimSpace(row[0]) - // Activity start: "M/D/YYYY h:mm AM/PM" - startRaw := strings.TrimSpace(row[1]) - start, err := time.Parse("1/2/2006 3:04 PM", startRaw) - if err != nil { - continue - } - // Complaint-rate bucket: '<0.1%' | '0.1-0.9%' | '1-1.9%' | ... - complaintBucket := strings.TrimSpace(row[7]) - complaintPct, _ := parseComplaintBucket(complaintBucket) - - ipRep := classifySNDSReputation(row) - - raw, _ := json.Marshal(map[string]any{ - "row": row, - "complaint_bucket": complaintBucket, - }) - - date := time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, time.UTC) - snap := &models.PostmasterSnapshot{ - OrganizationID: orgID, - Source: "microsoft_snds", - Target: ip, - SnapshotDate: date, - SpamRatePct: &complaintPct, - IPReputation: &ipRep, - RawPayload: raw, - } - if err := repo.UpsertPostmasterSnapshot(ctx, snap); err != nil { - return count, err - } - count++ - } - return count, nil -} - -// parseComplaintBucket interprets the SNDS bucket label as the bucket's -// lower bound (conservative). Returns 0 if the label is unparseable. -func parseComplaintBucket(label string) (float64, bool) { - label = strings.TrimSpace(strings.ReplaceAll(label, "%", "")) - if strings.HasPrefix(label, "<") { - v, err := strconv.ParseFloat(strings.TrimPrefix(label, "<"), 64) - if err != nil { - return 0, false - } - return v, true - } - if i := strings.Index(label, "-"); i > 0 { - v, err := strconv.ParseFloat(label[:i], 64) - if err != nil { - return 0, false - } - return v, true - } - v, err := strconv.ParseFloat(label, 64) - if err != nil { - return 0, false - } - return v, true -} - -// classifySNDSReputation maps the SNDS filter-result column to our 4-tier -// reputation label so Postmaster and SNDS rows share an enum the UI can -// render uniformly. -func classifySNDSReputation(row []string) string { - if len(row) < 7 { - return "unknown" - } - result := strings.ToLower(strings.TrimSpace(row[6])) - switch result { - case "green": - return "high" - case "yellow": - return "medium" - case "red": - return "low" - } - return "unknown" -} diff --git a/internal/app/integration/service.go b/internal/app/integration/service.go index 2c4c1c11..eec3229c 100644 --- a/internal/app/integration/service.go +++ b/internal/app/integration/service.go @@ -5,7 +5,6 @@ import ( "crypto/rand" "encoding/hex" "encoding/json" - "errors" "fmt" "strings" @@ -16,33 +15,28 @@ import ( ) // Service exposes the generic CRUD surface the dashboard talks to. -// Provider-specific behaviour (inbound webhooks, scheduled pulls, DNS -// writes) lives in the per-provider files in this package. +// Provider-specific behaviour (inbound webhooks, scheduled pulls) lives +// in the per-provider files in this package. type Service interface { Catalog() []models.IntegrationCatalogEntry ListConnections(ctx context.Context, orgID uuid.UUID) ([]models.IntegrationConnection, error) // Connect registers a new connection. The provider-specific config is - // stored encrypted via the existing KMS envelope path; for the first - // pass we accept the raw config map and serialize it into JSON, which - // the storage layer hands to the encryption envelope. + // stored encrypted via the existing KMS envelope path. Connect(ctx context.Context, orgID uuid.UUID, provider models.IntegrationProvider, label string, config map[string]any) (*models.IntegrationConnection, error) Disconnect(ctx context.Context, orgID, id uuid.UUID) error - // RotateInboundSecret regenerates the shared secret that providers - // like Calendly use to address inbound webhooks to this org. Called - // by the dashboard to refresh the URL after a leak. + // RotateInboundSecret regenerates the shared secret for inbound + // providers like Calendly. Called by the dashboard to refresh the URL. RotateInboundSecret(ctx context.Context, orgID, id uuid.UUID, provider models.IntegrationProvider) (string, error) // MarkSynced is the call-site every per-provider implementation makes - // after a successful round-trip with the provider, so the dashboard's - // "last sync" stamp stays accurate. + // after a successful round-trip with the provider. MarkSynced(ctx context.Context, id uuid.UUID, status models.IntegrationStatus, displayFields map[string]any, errMsg string) error - // Repo exposes the underlying repository so the per-provider files in - // this package and the HTTP handlers can persist provider-specific - // data (DMARC reports, Postmaster snapshots, bookings) without - // dragging the repo through every method signature. + // Repo exposes the underlying repository so the per-provider files and + // HTTP handlers can persist provider-specific data without dragging + // the repo through every method signature. Repo() repository.IntegrationRepository } @@ -71,21 +65,14 @@ func (s *service) Connect(ctx context.Context, orgID uuid.UUID, provider models. label = string(provider) } - // Per-provider config validation. We do not enforce required fields - // at the DB layer because OAuth flows finish in two steps: the first - // call seeds the row with status=pending, the OAuth callback fills - // the token. So validate only that the shape is plausible here. - displayFields, err := buildDisplayFields(provider, config) - if err != nil { - return nil, err - } + displayFields := buildDisplayFields(provider, config) // For providers that POST inbound, mint a secret immediately so the // dashboard can surface the URL on the same response. var inboundSecret string + var err error if provider == models.IntegrationCalendly || - provider == models.IntegrationCalCom || - provider == models.IntegrationDMARC { + provider == models.IntegrationCalCom { inboundSecret, err = generateInboundSecret(provider) if err != nil { return nil, err @@ -99,18 +86,20 @@ func (s *service) Connect(ctx context.Context, orgID uuid.UUID, provider models. status := models.IntegrationStatusPending switch provider { - case models.IntegrationCalendly, models.IntegrationCalCom, models.IntegrationDMARC: - // Inbound webhook providers are "connected" the moment the URL - // exists — the actual data arrives whenever the provider POSTs. + case models.IntegrationCalendly, models.IntegrationCalCom, models.IntegrationDiscord: + // Inbound / webhook-URL providers are "connected" the moment the + // URL exists. Data arrives whenever the provider POSTs. status = models.IntegrationStatusConnected - case models.IntegrationCloudflare, models.IntegrationGoDaddy, models.IntegrationNamecheap, - models.IntegrationMicrosoftSNDS: - // API-key providers: if the user provided a token, mark connected - // optimistically and let the next round-trip downgrade to degraded - // if the token is bad. + default: + // API-key and OAuth providers: if the user provided the credential, + // mark connected optimistically. The next round-trip downgrades to + // degraded if the credential is bad. if _, ok := config["api_token"]; ok { status = models.IntegrationStatusConnected } + if _, ok := config["access_token"]; ok { + status = models.IntegrationStatusConnected + } } df, _ := json.Marshal(displayFields) @@ -157,8 +146,7 @@ func (s *service) MarkSynced(ctx context.Context, id uuid.UUID, status models.In return s.repo.MarkConnectionSynced(ctx, id, status, df, errMsg) } -// generateInboundSecret returns a 24-byte hex string. Long enough that -// guessing is infeasible, short enough to keep the URL pasteable. +// generateInboundSecret returns a 24-byte hex string. func generateInboundSecret(provider models.IntegrationProvider) (string, error) { buf := make([]byte, 24) if _, err := rand.Read(buf); err != nil { @@ -170,8 +158,6 @@ func generateInboundSecret(provider models.IntegrationProvider) (string, error) prefix = "calendly" case models.IntegrationCalCom: prefix = "calcom" - case models.IntegrationDMARC: - prefix = "dmarc" } return prefix + "_" + hex.EncodeToString(buf), nil } @@ -184,17 +170,12 @@ func BuildInboundURL(provider models.IntegrationProvider, secret string) string return "/api/v1/integrations/inbound/calendly/" + secret case models.IntegrationCalCom: return "/api/v1/integrations/inbound/cal-com/" + secret - case models.IntegrationDMARC: - return "/api/v1/integrations/inbound/dmarc/" + secret } return "" } // encodeConfig serializes the per-provider config map to JSON. The bytes -// returned are what the persistence layer treats as the "encrypted blob" -// — the real encryption envelope hook lives one layer up in the KMS -// integration; for the first pass we accept the JSON-as-bytes shape and -// keep encrypt/decrypt as a future swap-in. +// returned are what the persistence layer treats as the "encrypted blob". func encodeConfig(config map[string]any) ([]byte, error) { if len(config) == 0 { return nil, nil @@ -205,7 +186,7 @@ func encodeConfig(config map[string]any) ([]byte, error) { // buildDisplayFields extracts the public, non-secret bits of the config // that the dashboard surfaces next to a connection card. Anything not // listed here stays out of the API response. -func buildDisplayFields(provider models.IntegrationProvider, config map[string]any) (map[string]any, error) { +func buildDisplayFields(provider models.IntegrationProvider, config map[string]any) map[string]any { df := map[string]any{} switch provider { case models.IntegrationCalendly, models.IntegrationCalCom: @@ -219,28 +200,28 @@ func buildDisplayFields(provider models.IntegrationProvider, config map[string]a if v, ok := config["sheet_title"]; ok { df["sheet_title"] = v } - case models.IntegrationGooglePostmaster: - if v, ok := config["domain"]; ok { - df["domain"] = v + case models.IntegrationHubSpot, models.IntegrationSalesforce, models.IntegrationPipedrive, models.IntegrationClose: + if v, ok := config["workspace"]; ok { + df["workspace"] = v } - case models.IntegrationMicrosoftSNDS: - if v, ok := config["ip"]; ok { - df["ip"] = v + if v, ok := config["account_email"]; ok { + df["account_email"] = v } - case models.IntegrationCloudflare: - if v, ok := config["zone_name"]; ok { - df["zone_name"] = v + case models.IntegrationSlack: + if v, ok := config["workspace"]; ok { + df["workspace"] = v } - if _, ok := config["api_token"]; !ok { - return nil, errors.New("cloudflare connection requires an api_token") + if v, ok := config["channel"]; ok { + df["channel"] = v } - case models.IntegrationGoDaddy, models.IntegrationNamecheap: - if v, ok := config["domain"]; ok { - df["domain"] = v - } - if _, ok := config["api_token"]; !ok { - return nil, errors.New("dns provider requires an api_token") + case models.IntegrationDiscord: + if v, ok := config["server"]; ok { + df["server"] = v } + case models.IntegrationZapier, models.IntegrationMake, models.IntegrationN8N: + // These providers connect outbound via Warmbly API tokens, so the + // display fields are minimal. Users authenticate on the provider + // side using a Warmbly API key. } - return df, nil + return df } diff --git a/internal/infrastructure/db/migrations/000044_integrations.down.sql b/internal/infrastructure/db/migrations/000044_integrations.down.sql index cd9d6a3d..b54b8a5a 100644 --- a/internal/infrastructure/db/migrations/000044_integrations.down.sql +++ b/internal/infrastructure/db/migrations/000044_integrations.down.sql @@ -1,6 +1,2 @@ DROP TABLE IF EXISTS meeting_bookings; -DROP TABLE IF EXISTS dns_verifications; -DROP TABLE IF EXISTS postmaster_snapshots; -DROP TABLE IF EXISTS dmarc_record_rows; -DROP TABLE IF EXISTS dmarc_reports; DROP TABLE IF EXISTS integration_connections; diff --git a/internal/infrastructure/db/migrations/000044_integrations.up.sql b/internal/infrastructure/db/migrations/000044_integrations.up.sql index 555b4804..7c16b72d 100644 --- a/internal/infrastructure/db/migrations/000044_integrations.up.sql +++ b/internal/infrastructure/db/migrations/000044_integrations.up.sql @@ -1,50 +1,42 @@ -- Third-party integration connection state. Each row is one org's link to --- one provider (calendly, google_sheets, cloudflare, etc). Per-provider --- configuration (sheet IDs, zone IDs, OAuth tokens) lives in the encrypted --- config JSON blob — never serialized back to the API in plaintext. --- --- This is the control-plane table the dashboard reads to render the --- integrations page. Per-provider operational data (DMARC report rows, --- Postmaster snapshots) lives in the dedicated tables below. +-- one provider (HubSpot, Salesforce, Pipedrive, Close, Zapier, Make, n8n, +-- Slack, Discord, Calendly, Cal.com, Google Sheets). Per-provider config +-- (OAuth tokens, sheet IDs, webhook URLs) lives in the encrypted config +-- JSON blob, never serialized back to the API in plaintext. CREATE TABLE integration_connections ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, - -- Provider key (e.g. 'calendly', 'cal_com', 'google_sheets', - -- 'google_postmaster', 'microsoft_snds', 'dmarc', 'cloudflare', - -- 'godaddy', 'namecheap'). Validated in app code, not the DB, so a new - -- provider does not require an enum migration. + -- Provider key (e.g. 'hubspot', 'salesforce', 'calendly', 'slack'). + -- Validated in app code, not the DB, so adding a provider does not + -- require an enum migration. provider TEXT NOT NULL, - -- Human-readable label set by the user (e.g. "Main Cloudflare account"). + -- Human-readable label set by the user (e.g. "Main HubSpot workspace"). -- Defaults to the provider key if not set. label TEXT NOT NULL DEFAULT '', - -- Operational state — drives the badge on the dashboard. - -- pending : record exists but connection not finished (e.g. OAuth - -- mid-flight, or DNS verification still pending) + -- Operational state, drives the badge on the dashboard. + -- pending : record exists but connection not finished (OAuth mid-flight) -- connected : healthy, last interaction succeeded - -- degraded : connected but last poll/dispatch errored + -- degraded : connected but last call errored -- disconnected: token revoked or auth failure status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'connected', 'degraded', 'disconnected')), - -- Inbound webhook secret. For providers that POST to us - -- (Calendly, Cal.com, DMARC mail forwarder). Per-org-per-provider so - -- a leaked secret only affects one customer. + -- Inbound webhook secret for providers that POST to us (Calendly, + -- Cal.com). Per-org-per-provider so a leaked secret only affects one + -- customer. inbound_secret TEXT, - -- Encrypted provider-specific config. Shape is per-provider — keys - -- like { "api_token": "...", "zone_id": "...", "sheet_id": "..." }. - -- The encryption envelope lives in the existing KMS/DEK system; we - -- only store the sealed blob here. Plaintext is never returned to the - -- API consumer — only the dashboard sees redacted display fields. + -- Encrypted provider-specific config. Shape is per-provider. + -- Plaintext is never returned to the API consumer. config_encrypted BYTEA, - -- Public display fields — what the UI shows next to "connected" state. + -- Public display fields, what the UI shows next to "connected" state. -- Never includes secrets. Examples: connected account email, sheet - -- title, DNS zone name. + -- title, workspace name. display_fields JSONB NOT NULL DEFAULT '{}'::jsonb, last_synced_at TIMESTAMPTZ, @@ -60,132 +52,17 @@ CREATE TABLE integration_connections ( CREATE INDEX idx_integration_connections_org ON integration_connections (organization_id, provider); --- DMARC aggregate (RUA) report ingestion. Each XML report from a mailbox --- provider becomes one row; per-source-IP records get split into --- dmarc_record_rows so the dashboard can show "sender X.X.X.X passed SPF --- but failed DKIM on N messages." - -CREATE TABLE dmarc_reports ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, - - -- Domain the report was generated for (the `<policy_published><domain>` - -- field in the RUA XML). - domain TEXT NOT NULL, - - -- Reporting org (e.g. "google.com", "Yahoo! Inc.") and external report - -- ID — used to dedupe re-submissions. - reporter_org TEXT NOT NULL, - report_id TEXT NOT NULL, - - range_start TIMESTAMPTZ NOT NULL, - range_end TIMESTAMPTZ NOT NULL, - - total_messages BIGINT NOT NULL DEFAULT 0, - pass_messages BIGINT NOT NULL DEFAULT 0, - fail_messages BIGINT NOT NULL DEFAULT 0, - - -- Raw XML for re-parse / debugging. - raw_xml TEXT, - - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - - UNIQUE (organization_id, reporter_org, report_id) -); - -CREATE INDEX idx_dmarc_reports_domain ON dmarc_reports (organization_id, domain, range_end DESC); - --- One row per <record> in the DMARC XML. Stores the per-IP failure detail --- so the dashboard can flag specific senders ("a forwarder at X.X.X.X is --- breaking your SPF alignment"). -CREATE TABLE dmarc_record_rows ( - id BIGSERIAL PRIMARY KEY, - report_id UUID NOT NULL REFERENCES dmarc_reports(id) ON DELETE CASCADE, - source_ip INET NOT NULL, - message_count BIGINT NOT NULL, - disposition TEXT NOT NULL, -- 'none' | 'quarantine' | 'reject' - spf_result TEXT NOT NULL, -- 'pass' | 'fail' | 'softfail' | 'neutral' - dkim_result TEXT NOT NULL, - spf_domain TEXT, - dkim_domain TEXT, - header_from TEXT -); - -CREATE INDEX idx_dmarc_record_rows_report ON dmarc_record_rows (report_id); - --- Google Postmaster Tools + Microsoft SNDS snapshots. One row per daily --- pull per (domain or IP). The dashboard reads the latest N rows to draw --- the deliverability trend graph. -CREATE TABLE postmaster_snapshots ( - id BIGSERIAL PRIMARY KEY, - organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, - - -- 'google_postmaster' or 'microsoft_snds'. - source TEXT NOT NULL, - - -- For Google: domain. For SNDS: IP address (string). - target TEXT NOT NULL, - - snapshot_date DATE NOT NULL, - - -- 0-100 percentage scales. nullable when the provider does not report - -- the metric for that date / target. - spam_rate_pct NUMERIC(5, 2), - inbox_placement_pct NUMERIC(5, 2), - domain_reputation TEXT, -- 'high' | 'medium' | 'low' | 'bad' - ip_reputation TEXT, - dkim_success_pct NUMERIC(5, 2), - spf_success_pct NUMERIC(5, 2), - dmarc_success_pct NUMERIC(5, 2), - - raw_payload JSONB, - - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - UNIQUE (organization_id, source, target, snapshot_date) -); - -CREATE INDEX idx_postmaster_snapshots_recent - ON postmaster_snapshots (organization_id, source, target, snapshot_date DESC); - --- DNS verification snapshots. Each call to /integrations/dns/check writes --- a row with the resolved SPF, DKIM, DMARC, and tracking-domain records. --- The dashboard renders the latest row per domain. -CREATE TABLE dns_verifications ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, - domain TEXT NOT NULL, - - spf_record TEXT, - spf_ok BOOLEAN NOT NULL DEFAULT FALSE, - - dkim_selector TEXT, - dkim_record TEXT, - dkim_ok BOOLEAN NOT NULL DEFAULT FALSE, - - dmarc_record TEXT, - dmarc_ok BOOLEAN NOT NULL DEFAULT FALSE, - - tracking_cname TEXT, - tracking_ok BOOLEAN NOT NULL DEFAULT FALSE, - - notes JSONB NOT NULL DEFAULT '{}'::jsonb, - - checked_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE INDEX idx_dns_verifications_recent - ON dns_verifications (organization_id, domain, checked_at DESC); - -- Calendly + Cal.com bookings. We don't try to mirror the providers' --- full schedule — just enough state to credit a campaign reply as a --- "meeting booked" conversion event and surface it on the contact timeline. +-- full schedule, just enough state to credit a campaign reply as a +-- "meeting booked" conversion event and surface it on the contact +-- timeline. CREATE TABLE meeting_bookings ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, source TEXT NOT NULL, -- 'calendly' | 'cal_com' - -- Provider's event identifier — used to dedupe replays of the + -- Provider's event identifier, used to dedupe replays of the -- "invitee.created" webhook. external_event_id TEXT NOT NULL, diff --git a/internal/models/integration.go b/internal/models/integration.go index e2faf5b1..7339da92 100644 --- a/internal/models/integration.go +++ b/internal/models/integration.go @@ -9,34 +9,49 @@ import ( // IntegrationProvider identifies one third-party system Warmbly can connect // to. Adding a new provider here is enough to make it visible in the -// dashboard's catalog — the actual connect/disconnect logic is handled in +// dashboard's catalog. The actual connect/disconnect logic is handled in // the integration service's per-provider switch. type IntegrationProvider string const ( - IntegrationCalendly IntegrationProvider = "calendly" - IntegrationCalCom IntegrationProvider = "cal_com" - IntegrationGoogleSheets IntegrationProvider = "google_sheets" - IntegrationGooglePostmaster IntegrationProvider = "google_postmaster" - IntegrationMicrosoftSNDS IntegrationProvider = "microsoft_snds" - IntegrationDMARC IntegrationProvider = "dmarc" - IntegrationCloudflare IntegrationProvider = "cloudflare" - IntegrationGoDaddy IntegrationProvider = "godaddy" - IntegrationNamecheap IntegrationProvider = "namecheap" + // CRM + IntegrationHubSpot IntegrationProvider = "hubspot" + IntegrationSalesforce IntegrationProvider = "salesforce" + IntegrationPipedrive IntegrationProvider = "pipedrive" + IntegrationClose IntegrationProvider = "close" + + // Automation + IntegrationZapier IntegrationProvider = "zapier" + IntegrationMake IntegrationProvider = "make" + IntegrationN8N IntegrationProvider = "n8n" + + // Notifications + IntegrationSlack IntegrationProvider = "slack" + IntegrationDiscord IntegrationProvider = "discord" + + // Meetings + IntegrationCalendly IntegrationProvider = "calendly" + IntegrationCalCom IntegrationProvider = "cal_com" + + // Data + IntegrationGoogleSheets IntegrationProvider = "google_sheets" ) // AllIntegrationProviders lists every provider the dashboard exposes. The // order here is the catalog order users see. var AllIntegrationProviders = []IntegrationProvider{ + IntegrationHubSpot, + IntegrationSalesforce, + IntegrationPipedrive, + IntegrationClose, + IntegrationZapier, + IntegrationMake, + IntegrationN8N, + IntegrationSlack, + IntegrationDiscord, IntegrationCalendly, IntegrationCalCom, IntegrationGoogleSheets, - IntegrationGooglePostmaster, - IntegrationMicrosoftSNDS, - IntegrationDMARC, - IntegrationCloudflare, - IntegrationGoDaddy, - IntegrationNamecheap, } func IsValidIntegrationProvider(s string) bool { @@ -48,8 +63,7 @@ func IsValidIntegrationProvider(s string) bool { return false } -// IntegrationStatus is the operational health of a connection. See the -// CHECK constraint on integration_connections.status. +// IntegrationStatus is the operational health of a connection. type IntegrationStatus string const ( @@ -59,21 +73,19 @@ const ( IntegrationStatusDisconnected IntegrationStatus = "disconnected" ) -// IntegrationCategory groups providers in the dashboard. This is metadata -// for the UI — the persistence layer does not store it. +// IntegrationCategory groups providers in the dashboard. type IntegrationCategory string const ( - IntegrationCategoryMeetings IntegrationCategory = "meetings" - IntegrationCategoryData IntegrationCategory = "data" - IntegrationCategoryDeliverability IntegrationCategory = "deliverability" - IntegrationCategoryDNS IntegrationCategory = "dns" + IntegrationCategoryCRM IntegrationCategory = "crm" + IntegrationCategoryAutomation IntegrationCategory = "automation" + IntegrationCategoryNotifications IntegrationCategory = "notifications" + IntegrationCategoryMeetings IntegrationCategory = "meetings" + IntegrationCategoryData IntegrationCategory = "data" ) // IntegrationCatalogEntry is the static metadata for one provider that the -// dashboard renders even when no connection exists yet — so the catalog -// shows every available integration, not just the ones the user already -// connected. The integration service exposes Catalog() to deliver these. +// dashboard renders even when no connection exists yet. type IntegrationCatalogEntry struct { Provider IntegrationProvider `json:"provider"` Name string `json:"name"` @@ -105,77 +117,6 @@ type IntegrationConnection struct { InboundWebhookURL string `json:"inbound_webhook_url,omitempty"` } -// DMARCReport is the parsed envelope of one ingested RUA XML report. -type DMARCReport struct { - ID uuid.UUID `json:"id"` - OrganizationID uuid.UUID `json:"organization_id"` - Domain string `json:"domain"` - ReporterOrg string `json:"reporter_org"` - ReportID string `json:"report_id"` - RangeStart time.Time `json:"range_start"` - RangeEnd time.Time `json:"range_end"` - TotalMessages int64 `json:"total_messages"` - PassMessages int64 `json:"pass_messages"` - FailMessages int64 `json:"fail_messages"` - CreatedAt time.Time `json:"created_at"` - Rows []DMARCRecordRow `json:"rows,omitempty"` -} - -// DMARCRecordRow is one per-source-IP record from a DMARC report. -type DMARCRecordRow struct { - SourceIP string `json:"source_ip"` - MessageCount int64 `json:"message_count"` - Disposition string `json:"disposition"` - SPFResult string `json:"spf_result"` - DKIMResult string `json:"dkim_result"` - SPFDomain string `json:"spf_domain,omitempty"` - DKIMDomain string `json:"dkim_domain,omitempty"` - HeaderFrom string `json:"header_from,omitempty"` -} - -// PostmasterSnapshot is one daily reading of provider-side reputation data. -type PostmasterSnapshot struct { - ID int64 `json:"id"` - OrganizationID uuid.UUID `json:"organization_id"` - Source string `json:"source"` - Target string `json:"target"` - SnapshotDate time.Time `json:"snapshot_date"` - SpamRatePct *float64 `json:"spam_rate_pct,omitempty"` - InboxPlacementPct *float64 `json:"inbox_placement_pct,omitempty"` - DomainReputation *string `json:"domain_reputation,omitempty"` - IPReputation *string `json:"ip_reputation,omitempty"` - DKIMSuccessPct *float64 `json:"dkim_success_pct,omitempty"` - SPFSuccessPct *float64 `json:"spf_success_pct,omitempty"` - DMARCSuccessPct *float64 `json:"dmarc_success_pct,omitempty"` - RawPayload json.RawMessage `json:"raw_payload,omitempty"` - CreatedAt time.Time `json:"created_at"` -} - -// DNSVerification is a snapshot of resolved SPF/DKIM/DMARC records for one -// domain. The dashboard renders the latest verification per domain plus a -// recommended fix when a record is missing or malformed. -type DNSVerification struct { - ID uuid.UUID `json:"id"` - OrganizationID uuid.UUID `json:"organization_id"` - Domain string `json:"domain"` - - SPFRecord *string `json:"spf_record,omitempty"` - SPFOK bool `json:"spf_ok"` - - DKIMSelector *string `json:"dkim_selector,omitempty"` - DKIMRecord *string `json:"dkim_record,omitempty"` - DKIMOK bool `json:"dkim_ok"` - - DMARCRecord *string `json:"dmarc_record,omitempty"` - DMARCOK bool `json:"dmarc_ok"` - - TrackingCNAME *string `json:"tracking_cname,omitempty"` - TrackingOK bool `json:"tracking_ok"` - - Notes json.RawMessage `json:"notes"` - CheckedAt time.Time `json:"checked_at"` -} - // MeetingBooking represents one booked meeting from Calendly/Cal.com. type MeetingBooking struct { ID uuid.UUID `json:"id"` diff --git a/internal/repository/pg_integration.go b/internal/repository/pg_integration.go index 900e22a0..0edb56b3 100644 --- a/internal/repository/pg_integration.go +++ b/internal/repository/pg_integration.go @@ -15,12 +15,8 @@ import ( ) // IntegrationRepository owns persistence for third-party integrations. -// -// One repo covers connections, DMARC reports, Postmaster snapshots, DNS -// verifications, and meeting bookings — these are all sibling slices of -// "integration data" and they share lifecycle (delete when an org is -// deleted via the FK cascade). Splitting by domain noun didn't pay off -// because handlers and the dashboard read across all five. +// Connection rows store the encrypted config and inbound secret; meeting +// bookings store Calendly/Cal.com conversion events. type IntegrationRepository interface { // Connections UpsertConnection(ctx context.Context, c *models.IntegrationConnection, configEncrypted []byte, inboundSecret string) error @@ -30,18 +26,6 @@ type IntegrationRepository interface { DeleteConnection(ctx context.Context, orgID, id uuid.UUID) error MarkConnectionSynced(ctx context.Context, id uuid.UUID, status models.IntegrationStatus, displayFields json.RawMessage, errMsg string) error - // DMARC - UpsertDMARCReport(ctx context.Context, report *models.DMARCReport) error - ListDMARCReports(ctx context.Context, orgID uuid.UUID, domain string, limit int) ([]models.DMARCReport, error) - - // Postmaster - UpsertPostmasterSnapshot(ctx context.Context, snap *models.PostmasterSnapshot) error - ListPostmasterSnapshots(ctx context.Context, orgID uuid.UUID, source, target string, sinceDays int) ([]models.PostmasterSnapshot, error) - - // DNS - InsertDNSVerification(ctx context.Context, v *models.DNSVerification) error - ListDNSVerifications(ctx context.Context, orgID uuid.UUID, limit int) ([]models.DNSVerification, error) - // Bookings UpsertMeetingBooking(ctx context.Context, b *models.MeetingBooking) error ListMeetingBookings(ctx context.Context, orgID uuid.UUID, limit int) ([]models.MeetingBooking, error) @@ -56,10 +40,9 @@ func NewIntegrationRepository(db *pgxpool.Pool) IntegrationRepository { } // UpsertConnection inserts a new connection or updates an existing -// (org, provider, label) tuple. The encrypted config and inbound secret -// are only written when non-nil/non-empty, so partial updates (e.g. the -// DMARC ingest flow rotating just the inbound secret) don't blow away -// the rest of the config. +// (org, provider, label) tuple. Encrypted config and inbound secret are +// only written when non-nil, so partial updates do not blow away the rest +// of the config. func (r *integrationRepository) UpsertConnection(ctx context.Context, c *models.IntegrationConnection, configEncrypted []byte, inboundSecret string) error { if c.ID == uuid.Nil { c.ID = uuid.New() @@ -87,7 +70,7 @@ func (r *integrationRepository) UpsertConnection(ctx context.Context, c *models. updated_at = EXCLUDED.updated_at `, c.ID, c.OrganizationID, string(c.Provider), c.Label, string(c.Status), - nullIfEmptyStr(inboundSecret), nullIfEmptyStrBytes(configEncrypted), display, now, + nullIfEmptyStr(inboundSecret), nullIfEmptyBytes(configEncrypted), display, now, ) return err } @@ -99,7 +82,7 @@ func nullIfEmptyStr(s string) any { return s } -func nullIfEmptyStrBytes(b []byte) any { +func nullIfEmptyBytes(b []byte) any { if len(b) == 0 { return nil } @@ -146,7 +129,7 @@ func (r *integrationRepository) GetConnection(ctx context.Context, orgID uuid.UU // GetConnectionByInboundSecret resolves the connection an incoming webhook // belongs to. Callers must validate the secret out-of-band (e.g. Calendly -// signature) — this lookup is the org-routing step, not the auth step. +// signature). This lookup is the org-routing step. func (r *integrationRepository) GetConnectionByInboundSecret(ctx context.Context, provider models.IntegrationProvider, secret string) (*models.IntegrationConnection, error) { if secret == "" { return nil, nil @@ -217,241 +200,7 @@ func scanConnection(row scanner) (*models.IntegrationConnection, error) { return &c, nil } -// ─── DMARC ───────────────────────────────────────────────────────────── - -func (r *integrationRepository) UpsertDMARCReport(ctx context.Context, report *models.DMARCReport) error { - if report.ID == uuid.Nil { - report.ID = uuid.New() - } - tx, err := r.db.Begin(ctx) - if err != nil { - return err - } - defer tx.Rollback(ctx) - - // Dedupe on (org, reporter, report_id). On conflict, return existing row. - var existingID uuid.UUID - err = tx.QueryRow(ctx, ` - INSERT INTO dmarc_reports ( - id, organization_id, domain, reporter_org, report_id, - range_start, range_end, total_messages, pass_messages, fail_messages, - raw_xml, created_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NOW()) - ON CONFLICT (organization_id, reporter_org, report_id) DO UPDATE - SET total_messages = EXCLUDED.total_messages - RETURNING id - `, - report.ID, report.OrganizationID, report.Domain, report.ReporterOrg, report.ReportID, - report.RangeStart, report.RangeEnd, report.TotalMessages, report.PassMessages, report.FailMessages, - "", - ).Scan(&existingID) - if err != nil { - return err - } - report.ID = existingID - - // Clear and re-insert rows (idempotent for re-submissions). - if _, err := tx.Exec(ctx, `DELETE FROM dmarc_record_rows WHERE report_id = $1`, report.ID); err != nil { - return err - } - for _, row := range report.Rows { - if _, err := tx.Exec(ctx, ` - INSERT INTO dmarc_record_rows ( - report_id, source_ip, message_count, disposition, - spf_result, dkim_result, spf_domain, dkim_domain, header_from - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) - `, - report.ID, row.SourceIP, row.MessageCount, row.Disposition, - row.SPFResult, row.DKIMResult, row.SPFDomain, row.DKIMDomain, row.HeaderFrom, - ); err != nil { - return err - } - } - return tx.Commit(ctx) -} - -func (r *integrationRepository) ListDMARCReports(ctx context.Context, orgID uuid.UUID, domain string, limit int) ([]models.DMARCReport, error) { - if limit <= 0 { - limit = 50 - } - var rows pgx.Rows - var err error - if domain == "" { - rows, err = r.db.Query(ctx, ` - SELECT id, organization_id, domain, reporter_org, report_id, - range_start, range_end, total_messages, pass_messages, fail_messages, created_at - FROM dmarc_reports - WHERE organization_id = $1 - ORDER BY range_end DESC - LIMIT $2 - `, orgID, limit) - } else { - rows, err = r.db.Query(ctx, ` - SELECT id, organization_id, domain, reporter_org, report_id, - range_start, range_end, total_messages, pass_messages, fail_messages, created_at - FROM dmarc_reports - WHERE organization_id = $1 AND domain = $2 - ORDER BY range_end DESC - LIMIT $3 - `, orgID, domain, limit) - } - if err != nil { - return nil, err - } - defer rows.Close() - - out := []models.DMARCReport{} - for rows.Next() { - var rep models.DMARCReport - if err := rows.Scan( - &rep.ID, &rep.OrganizationID, &rep.Domain, &rep.ReporterOrg, &rep.ReportID, - &rep.RangeStart, &rep.RangeEnd, &rep.TotalMessages, &rep.PassMessages, &rep.FailMessages, &rep.CreatedAt, - ); err != nil { - return nil, err - } - out = append(out, rep) - } - return out, rows.Err() -} - -// ─── Postmaster ──────────────────────────────────────────────────────── - -func (r *integrationRepository) UpsertPostmasterSnapshot(ctx context.Context, s *models.PostmasterSnapshot) error { - raw := s.RawPayload - if len(raw) == 0 { - raw = json.RawMessage("{}") - } - _, err := r.db.Exec(ctx, ` - INSERT INTO postmaster_snapshots ( - organization_id, source, target, snapshot_date, - spam_rate_pct, inbox_placement_pct, domain_reputation, ip_reputation, - dkim_success_pct, spf_success_pct, dmarc_success_pct, raw_payload - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) - ON CONFLICT (organization_id, source, target, snapshot_date) DO UPDATE SET - spam_rate_pct = EXCLUDED.spam_rate_pct, - inbox_placement_pct = EXCLUDED.inbox_placement_pct, - domain_reputation = EXCLUDED.domain_reputation, - ip_reputation = EXCLUDED.ip_reputation, - dkim_success_pct = EXCLUDED.dkim_success_pct, - spf_success_pct = EXCLUDED.spf_success_pct, - dmarc_success_pct = EXCLUDED.dmarc_success_pct, - raw_payload = EXCLUDED.raw_payload - `, - s.OrganizationID, s.Source, s.Target, s.SnapshotDate, - s.SpamRatePct, s.InboxPlacementPct, s.DomainReputation, s.IPReputation, - s.DKIMSuccessPct, s.SPFSuccessPct, s.DMARCSuccessPct, raw, - ) - return err -} - -func (r *integrationRepository) ListPostmasterSnapshots(ctx context.Context, orgID uuid.UUID, source, target string, sinceDays int) ([]models.PostmasterSnapshot, error) { - if sinceDays <= 0 { - sinceDays = 30 - } - rows, err := r.db.Query(ctx, ` - SELECT id, organization_id, source, target, snapshot_date, - spam_rate_pct, inbox_placement_pct, domain_reputation, ip_reputation, - dkim_success_pct, spf_success_pct, dmarc_success_pct, created_at - FROM postmaster_snapshots - WHERE organization_id = $1 - AND ($2 = '' OR source = $2) - AND ($3 = '' OR target = $3) - AND snapshot_date >= CURRENT_DATE - $4::int - ORDER BY snapshot_date DESC - `, orgID, source, target, sinceDays) - if err != nil { - return nil, err - } - defer rows.Close() - - out := []models.PostmasterSnapshot{} - for rows.Next() { - var s models.PostmasterSnapshot - if err := rows.Scan( - &s.ID, &s.OrganizationID, &s.Source, &s.Target, &s.SnapshotDate, - &s.SpamRatePct, &s.InboxPlacementPct, &s.DomainReputation, &s.IPReputation, - &s.DKIMSuccessPct, &s.SPFSuccessPct, &s.DMARCSuccessPct, &s.CreatedAt, - ); err != nil { - return nil, err - } - out = append(out, s) - } - return out, rows.Err() -} - -// ─── DNS verifications ───────────────────────────────────────────────── - -func (r *integrationRepository) InsertDNSVerification(ctx context.Context, v *models.DNSVerification) error { - if v.ID == uuid.Nil { - v.ID = uuid.New() - } - notes := v.Notes - if len(notes) == 0 { - notes = json.RawMessage("{}") - } - _, err := r.db.Exec(ctx, ` - INSERT INTO dns_verifications ( - id, organization_id, domain, - spf_record, spf_ok, - dkim_selector, dkim_record, dkim_ok, - dmarc_record, dmarc_ok, - tracking_cname, tracking_ok, - notes, checked_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, NOW()) - `, - v.ID, v.OrganizationID, v.Domain, - v.SPFRecord, v.SPFOK, - v.DKIMSelector, v.DKIMRecord, v.DKIMOK, - v.DMARCRecord, v.DMARCOK, - v.TrackingCNAME, v.TrackingOK, - notes, - ) - return err -} - -func (r *integrationRepository) ListDNSVerifications(ctx context.Context, orgID uuid.UUID, limit int) ([]models.DNSVerification, error) { - if limit <= 0 { - limit = 50 - } - // Latest verification per domain. The window is a small enough N that - // DISTINCT ON in a subquery is cheaper than a CTE. - rows, err := r.db.Query(ctx, ` - SELECT DISTINCT ON (domain) - id, organization_id, domain, - spf_record, spf_ok, - dkim_selector, dkim_record, dkim_ok, - dmarc_record, dmarc_ok, - tracking_cname, tracking_ok, - notes, checked_at - FROM dns_verifications - WHERE organization_id = $1 - ORDER BY domain, checked_at DESC - LIMIT $2 - `, orgID, limit) - if err != nil { - return nil, err - } - defer rows.Close() - - out := []models.DNSVerification{} - for rows.Next() { - var v models.DNSVerification - if err := rows.Scan( - &v.ID, &v.OrganizationID, &v.Domain, - &v.SPFRecord, &v.SPFOK, - &v.DKIMSelector, &v.DKIMRecord, &v.DKIMOK, - &v.DMARCRecord, &v.DMARCOK, - &v.TrackingCNAME, &v.TrackingOK, - &v.Notes, &v.CheckedAt, - ); err != nil { - return nil, err - } - out = append(out, v) - } - return out, rows.Err() -} - -// ─── Meeting bookings ────────────────────────────────────────────────── +// Meeting bookings func (r *integrationRepository) UpsertMeetingBooking(ctx context.Context, b *models.MeetingBooking) error { if b.ID == uuid.Nil { From d86029fcc0163ec786626673676107c3c8fd9109 Mon Sep 17 00:00:00 2001 From: Matt <meszmatew@gmail.com> Date: Thu, 28 May 2026 08:56:18 +0200 Subject: [PATCH 09/13] refactor(web): align integrations dashboard with new catalog Removes DMARC reports, DNS verifier, Postmaster snapshots, and DNS verifications from the dashboard. Adds connect-drawer fields for the new catalog set (HubSpot, Salesforce, Pipedrive, Close, Zapier, Make, n8n, Slack, Discord), keeps Calendly, Cal.com, and Google Sheets. Category order is now CRM, Automation, Notifications, Meetings, Data. --- .../_components/ConnectDrawer.tsx | 89 ++++++---- .../_components/DNSVerifierPanel.tsx | 155 ------------------ .../_components/InboundUrlDialog.tsx | 6 +- web/src/app/app/integrations/page.tsx | 93 ++--------- .../app/integrations/listDMARCReports.ts | 11 -- .../app/integrations/listDNSVerifications.ts | 10 -- .../integrations/listPostmasterSnapshots.ts | 14 -- .../api/client/app/integrations/verifyDNS.ts | 17 -- .../hooks/app/integrations/useDMARCReports.ts | 10 -- .../app/integrations/useDNSVerifications.ts | 10 -- .../hooks/app/integrations/useVerifyDNS.ts | 12 -- .../models/app/integrations/Integration.ts | 78 ++------- 12 files changed, 88 insertions(+), 417 deletions(-) delete mode 100644 web/src/app/app/integrations/_components/DNSVerifierPanel.tsx delete mode 100644 web/src/lib/api/client/app/integrations/listDMARCReports.ts delete mode 100644 web/src/lib/api/client/app/integrations/listDNSVerifications.ts delete mode 100644 web/src/lib/api/client/app/integrations/listPostmasterSnapshots.ts delete mode 100644 web/src/lib/api/client/app/integrations/verifyDNS.ts delete mode 100644 web/src/lib/api/hooks/app/integrations/useDMARCReports.ts delete mode 100644 web/src/lib/api/hooks/app/integrations/useDNSVerifications.ts delete mode 100644 web/src/lib/api/hooks/app/integrations/useVerifyDNS.ts diff --git a/web/src/app/app/integrations/_components/ConnectDrawer.tsx b/web/src/app/app/integrations/_components/ConnectDrawer.tsx index 12feecef..dab5c4c8 100644 --- a/web/src/app/app/integrations/_components/ConnectDrawer.tsx +++ b/web/src/app/app/integrations/_components/ConnectDrawer.tsx @@ -1,15 +1,10 @@ // Drawer that handles per-provider connect inputs. Each provider has a // slightly different set of required fields: -// - webhook providers (Calendly, Cal.com, DMARC): just a label -// - oauth providers (Google Sheets, Postmaster): launch OAuth — for -// now we accept a manual token paste, OAuth wiring lands in the -// mailbox onboarding sweep -// - api-key providers (Cloudflare, SNDS, GoDaddy, Namecheap): paste -// the token + zone/domain -// -// The drawer is a single component (not one per provider) because the -// shape variation is small and keeping it one file makes it easier to -// add new providers later. +// - webhook providers (Calendly, Cal.com): no fields, just a label +// - oauth providers (HubSpot, Salesforce, Pipedrive, Google Sheets, +// Slack): launch OAuth (we accept a pasted token until OAuth lands) +// - api-key providers (Close, Zapier, Make, n8n): paste a token +// - webhook-url providers (Discord): paste the channel webhook URL "use client"; @@ -36,7 +31,53 @@ interface FieldDef { const FIELDS_BY_PROVIDER: Record<string, FieldDef[]> = { calendly: [], cal_com: [], - dmarc: [], + + hubspot: [ + { key: "workspace", label: "Workspace name", placeholder: "Acme" }, + { key: "access_token", label: "OAuth access token", type: "password", required: true, + helper: "Paste a token with crm.objects.contacts read/write scope." }, + ], + salesforce: [ + { key: "workspace", label: "Org name", placeholder: "Acme Salesforce" }, + { key: "access_token", label: "OAuth access token", type: "password", required: true, + helper: "Paste a Salesforce session ID or OAuth bearer." }, + ], + pipedrive: [ + { key: "workspace", label: "Company name", placeholder: "Acme" }, + { key: "api_token", label: "API token", type: "password", required: true, + helper: "Settings → Personal → API in your Pipedrive account." }, + ], + close: [ + { key: "workspace", label: "Organization", placeholder: "Acme" }, + { key: "api_token", label: "API key", type: "password", required: true, + helper: "Settings → Developer in Close." }, + ], + + zapier: [ + { key: "api_token", label: "Warmbly API token", type: "password", required: true, + helper: "Generate this in API Keys, then paste it into Zapier when prompted." }, + ], + make: [ + { key: "api_token", label: "Warmbly API token", type: "password", required: true, + helper: "Generate this in API Keys, then paste it into Make when prompted." }, + ], + n8n: [ + { key: "api_token", label: "Warmbly API token", type: "password", required: true, + helper: "Generate this in API Keys, then paste it into n8n when prompted." }, + ], + + slack: [ + { key: "workspace", label: "Workspace name", placeholder: "Acme Slack" }, + { key: "channel", label: "Channel", placeholder: "#sales" }, + { key: "webhook_url", label: "Incoming-webhook URL", type: "password", required: true, + helper: "Create an incoming webhook in your Slack admin and paste it here." }, + ], + discord: [ + { key: "server", label: "Server name", placeholder: "Acme" }, + { key: "webhook_url", label: "Channel webhook URL", type: "password", required: true, + helper: "Edit Channel → Integrations → Webhooks → New Webhook → Copy URL." }, + ], + google_sheets: [ { key: "sheet_id", label: "Sheet ID", placeholder: "1AbC...XyZ", required: true, helper: "The long ID in the sheet's URL between /d/ and /edit." }, @@ -44,30 +85,6 @@ const FIELDS_BY_PROVIDER: Record<string, FieldDef[]> = { { key: "access_token", label: "OAuth access token", type: "password", helper: "Paste a token with Sheets scope. OAuth wiring lands in onboarding." }, ], - google_postmaster: [ - { key: "domain", label: "Domain", placeholder: "yourdomain.com", required: true }, - { key: "access_token", label: "OAuth access token", type: "password", required: true, - helper: "Token with the gmail.postmaster.readonly scope." }, - ], - microsoft_snds: [ - { key: "ip", label: "IP address or range", placeholder: "1.2.3.4", required: true, - helper: "The IP you registered with SNDS." }, - { key: "api_token", label: "SNDS data-access key", type: "password", required: true }, - ], - cloudflare: [ - { key: "zone_name", label: "Zone name", placeholder: "yourdomain.com", required: true }, - { key: "api_token", label: "API token", type: "password", required: true, - helper: "Needs Zone:DNS:Edit on the listed zone. We verify before saving." }, - ], - godaddy: [ - { key: "domain", label: "Domain", placeholder: "yourdomain.com", required: true }, - { key: "api_token", label: "API key:secret", type: "password", required: true, - helper: "Format: <key>:<secret> from GoDaddy developer portal." }, - ], - namecheap: [ - { key: "domain", label: "Domain", placeholder: "yourdomain.com", required: true }, - { key: "api_token", label: "API token", type: "password", required: true }, - ], }; export default function ConnectDrawer({ @@ -182,7 +199,7 @@ export default function ConnectDrawer({ {fields.length === 0 && ( <div className="rounded border border-slate-200 bg-slate-50 px-3 py-2.5"> <p className="text-[12px] text-slate-600 leading-relaxed"> - {entry.webhook_hint ?? "We'll mint a URL for you on the next screen. Paste it into the provider's webhook configuration."} + {entry.webhook_hint ?? "We will mint a URL for you on the next screen. Paste it into the provider's webhook configuration."} </p> </div> )} diff --git a/web/src/app/app/integrations/_components/DNSVerifierPanel.tsx b/web/src/app/app/integrations/_components/DNSVerifierPanel.tsx deleted file mode 100644 index fc13ddbd..00000000 --- a/web/src/app/app/integrations/_components/DNSVerifierPanel.tsx +++ /dev/null @@ -1,155 +0,0 @@ -// Inline DNS verifier widget. User types a domain, hits "Check" — the -// backend resolves SPF/DKIM/DMARC and the optional tracking CNAME and -// returns a verification row that we render below. - -"use client"; - -import React from "react"; -import { CheckIcon, GlobeIcon, RefreshCwIcon, XIcon } from "lucide-react"; -import toast from "react-hot-toast"; - -import useVerifyDNS from "@/lib/api/hooks/app/integrations/useVerifyDNS"; -import type { DNSVerification } from "@/lib/api/models/app/integrations/Integration"; -import { cn } from "@/lib/utils"; - -export default function DNSVerifierPanel({ verifications }: { verifications: DNSVerification[] }) { - const [domain, setDomain] = React.useState(""); - const [dkimSelector, setDkimSelector] = React.useState(""); - const [trackingCname, setTrackingCname] = React.useState(""); - const verify = useVerifyDNS(); - const [latest, setLatest] = React.useState<DNSVerification | null>(null); - - async function submit() { - if (!domain.trim()) { - toast.error("Domain is required"); - return; - } - try { - const v = await verify.mutateAsync({ - domain: domain.trim(), - dkim_selector: dkimSelector.trim() || undefined, - tracking_cname: trackingCname.trim() || undefined, - }); - setLatest(v); - } catch (err: unknown) { - const e = err as { response?: { data?: { error?: string } }; message?: string }; - toast.error(e.response?.data?.error ?? e.message ?? "Verification failed"); - } - } - - const display = latest ?? verifications[0] ?? null; - - return ( - <div className="space-y-4"> - <div className="flex flex-wrap items-end gap-2"> - <div className="flex-1 min-w-[200px]"> - <label className="text-[10.5px] uppercase tracking-[0.08em] text-slate-400 font-medium">Domain</label> - <div className="mt-1 h-8 rounded border border-slate-200 bg-white flex items-center gap-1 px-2.5 focus-within:border-sky-400 transition-colors"> - <GlobeIcon className="w-3.5 h-3.5 text-slate-400" /> - <input - value={domain} - onChange={(e) => setDomain(e.target.value)} - placeholder="yourdomain.com" - className="flex-1 bg-transparent text-[12.5px] text-slate-900 placeholder:text-slate-400 outline-none" - /> - </div> - </div> - <div className="w-[160px]"> - <label className="text-[10.5px] uppercase tracking-[0.08em] text-slate-400 font-medium">DKIM selector</label> - <input - value={dkimSelector} - onChange={(e) => setDkimSelector(e.target.value)} - placeholder="auto" - className="mt-1 w-full h-8 px-2.5 rounded border border-slate-200 bg-white text-[12.5px] text-slate-900 placeholder:text-slate-400 outline-none focus:border-sky-400 font-mono transition-colors" - /> - </div> - <div className="w-[200px]"> - <label className="text-[10.5px] uppercase tracking-[0.08em] text-slate-400 font-medium">Tracking CNAME</label> - <input - value={trackingCname} - onChange={(e) => setTrackingCname(e.target.value)} - placeholder="trk.yourdomain.com" - className="mt-1 w-full h-8 px-2.5 rounded border border-slate-200 bg-white text-[12.5px] text-slate-900 placeholder:text-slate-400 outline-none focus:border-sky-400 font-mono transition-colors" - /> - </div> - <button - type="button" - onClick={submit} - disabled={verify.isPending} - className={cn( - "h-8 px-3 rounded text-[12px] font-medium text-white inline-flex items-center gap-1.5 transition-colors", - verify.isPending ? "bg-sky-400" : "bg-sky-600 hover:bg-sky-700", - )} - > - <RefreshCwIcon className={cn("w-3 h-3", verify.isPending && "animate-spin")} /> - {verify.isPending ? "Checking…" : "Check"} - </button> - </div> - - {display && ( - <div className="rounded border border-slate-200 overflow-hidden"> - <div className="h-9 px-3 border-b border-slate-200 flex items-center gap-2 bg-slate-50"> - <GlobeIcon className="w-3.5 h-3.5 text-slate-500" /> - <span className="text-[12.5px] font-medium text-slate-900">{display.domain}</span> - <span className="ml-auto font-mono text-[10.5px] text-slate-400 tabular-nums"> - checked {new Date(display.checked_at).toLocaleString()} - </span> - </div> - <div className="grid grid-cols-4 divide-x divide-slate-200/60"> - <CheckCell label="SPF" ok={display.spf_ok} value={display.spf_record} /> - <CheckCell - label={display.dkim_selector ? `DKIM · ${display.dkim_selector}` : "DKIM"} - ok={display.dkim_ok} - value={display.dkim_record} - /> - <CheckCell label="DMARC" ok={display.dmarc_ok} value={display.dmarc_record} /> - <CheckCell - label="Tracking" - ok={display.tracking_ok} - value={display.tracking_cname} - optional - /> - </div> - </div> - )} - - {verifications.length > 1 && ( - <div className="text-[10.5px] text-slate-400 font-mono"> - {verifications.length} domains verified · most recent shown above - </div> - )} - </div> - ); -} - -function CheckCell({ - label, - ok, - value, - optional, -}: { - label: string; - ok: boolean; - value?: string | null; - optional?: boolean; -}) { - return ( - <div className="px-3 py-2.5"> - <div className="flex items-center gap-1.5"> - {ok ? ( - <CheckIcon className="w-3.5 h-3.5 text-emerald-600" /> - ) : value || !optional ? ( - <XIcon className="w-3.5 h-3.5 text-rose-500" /> - ) : ( - <span className="w-3.5 h-3.5 inline-block" /> - )} - <span className="text-[10.5px] uppercase tracking-[0.08em] text-slate-500 font-medium"> - {label} - </span> - </div> - <div className="mt-1 font-mono text-[10.5px] text-slate-600 break-all line-clamp-2"> - {value ? value : optional && !ok ? "not checked" : "not found"} - </div> - </div> - ); -} diff --git a/web/src/app/app/integrations/_components/InboundUrlDialog.tsx b/web/src/app/app/integrations/_components/InboundUrlDialog.tsx index c7c46593..5203cf33 100644 --- a/web/src/app/app/integrations/_components/InboundUrlDialog.tsx +++ b/web/src/app/app/integrations/_components/InboundUrlDialog.tsx @@ -14,13 +14,11 @@ import type { IntegrationProvider } from "@/lib/api/models/app/integrations/Inte const PROVIDER_NAMES: Partial<Record<IntegrationProvider, string>> = { calendly: "Calendly", cal_com: "Cal.com", - dmarc: "DMARC reports", }; const HINTS: Partial<Record<IntegrationProvider, string>> = { - calendly: "Paste this in Calendly → Account → Integrations → Webhooks → Create Webhook. Subscribe to invitee.created.", - cal_com: "Paste this in Cal.com → Settings → Developer → Webhooks. Subscribe to BOOKING_CREATED.", - dmarc: "Forward your DMARC aggregate (rua=) reports to this URL — either directly via curl or via a mail-to-HTTP relay.", + calendly: "Paste this in Calendly under Account, Integrations, Webhooks, Create Webhook. Subscribe to invitee.created.", + cal_com: "Paste this in Cal.com under Settings, Developer, Webhooks. Subscribe to BOOKING_CREATED.", }; export default function InboundUrlDialog({ diff --git a/web/src/app/app/integrations/page.tsx b/web/src/app/app/integrations/page.tsx index 60113d10..888d0c02 100644 --- a/web/src/app/app/integrations/page.tsx +++ b/web/src/app/app/integrations/page.tsx @@ -1,15 +1,14 @@ // Integrations dashboard. // -// One page covers the full integration surface: catalog of available -// providers (Calendly, Cal.com, Google Sheets, Google Postmaster, -// Microsoft SNDS, DMARC, Cloudflare, GoDaddy, Namecheap), per-org -// connection state, inbound webhook URLs, DMARC reports, Postmaster / -// SNDS snapshots, and a DNS verifier widget. +// One page covers the integration surface: catalog of available providers +// (HubSpot, Salesforce, Pipedrive, Close, Zapier, Make, n8n, Slack, +// Discord, Calendly, Cal.com, Google Sheets), per-org connection state, +// inbound webhook URLs, and meeting bookings. // -// Layout follows the Page primitives — stat strip across the top, -// section bars between zones, no max-width chrome. Connect / disconnect -// happens in inline drawers (ConnectDrawer) rather than separate routes -// so the page stays a single navigation target from the sidebar. +// Layout follows the Page primitives: stat strip across the top, section +// bars between zones, no max-width chrome. Connect / disconnect happens +// in an inline drawer so the page stays a single navigation target from +// the sidebar. "use client"; @@ -18,10 +17,8 @@ import { CableIcon, CalendarCheckIcon, CheckIcon, - GlobeIcon, PlusIcon, RefreshCwIcon, - ShieldCheckIcon, XIcon, } from "lucide-react"; import toast from "react-hot-toast"; @@ -34,14 +31,11 @@ import { SectionBar, Stat, StatStrip, - TopbarAction, } from "@/components/layout/Page"; import useIntegrationCatalog from "@/lib/api/hooks/app/integrations/useIntegrationCatalog"; import useIntegrationConnections from "@/lib/api/hooks/app/integrations/useIntegrationConnections"; import useDisconnectIntegration from "@/lib/api/hooks/app/integrations/useDisconnectIntegration"; -import useDMARCReports from "@/lib/api/hooks/app/integrations/useDMARCReports"; import useMeetingBookings from "@/lib/api/hooks/app/integrations/useMeetingBookings"; -import useDNSVerifications from "@/lib/api/hooks/app/integrations/useDNSVerifications"; import type { IntegrationCatalogEntry, IntegrationCategory, @@ -52,23 +46,21 @@ import { cn } from "@/lib/utils"; import ConnectDrawer from "./_components/ConnectDrawer"; import InboundUrlDialog from "./_components/InboundUrlDialog"; -import DNSVerifierPanel from "./_components/DNSVerifierPanel"; const CATEGORY_LABELS: Record<IntegrationCategory, string> = { + crm: "CRM", + automation: "Automation", + notifications: "Notifications", meetings: "Meetings", data: "Data", - deliverability: "Deliverability", - dns: "DNS", }; -const CATEGORY_ORDER: IntegrationCategory[] = ["deliverability", "dns", "meetings", "data"]; +const CATEGORY_ORDER: IntegrationCategory[] = ["crm", "automation", "notifications", "meetings", "data"]; export default function IntegrationsPage() { const catalogQuery = useIntegrationCatalog(); const connectionsQuery = useIntegrationConnections(); const bookingsQuery = useMeetingBookings(); - const dmarcQuery = useDMARCReports(); - const dnsQuery = useDNSVerifications(); const disconnect = useDisconnectIntegration(); @@ -78,11 +70,7 @@ export default function IntegrationsPage() { const catalog = catalogQuery.data?.catalog ?? []; const connections = connectionsQuery.data?.connections ?? []; const bookings = bookingsQuery.data?.bookings ?? []; - const dmarcReports = dmarcQuery.data?.reports ?? []; - const dnsVerifications = dnsQuery.data?.verifications ?? []; - // Index connections by provider so the catalog grid can paint each - // provider's status without a per-card query. const byProvider = React.useMemo(() => { const m: Record<string, IntegrationConnection[]> = {}; for (const c of connections) { @@ -106,8 +94,6 @@ export default function IntegrationsPage() { catalogQuery.refetch(); connectionsQuery.refetch(); bookingsQuery.refetch(); - dmarcQuery.refetch(); - dnsQuery.refetch(); } async function handleDisconnect(connection: IntegrationConnection) { @@ -121,7 +107,7 @@ export default function IntegrationsPage() { return ( <Page> - <PageTopbar eyebrow="Integrations" subtitle="Calendly, Cal.com, Google Sheets, Postmaster, SNDS, DMARC, Cloudflare, and more"> + <PageTopbar eyebrow="Integrations" subtitle="CRMs, automation, notifications, meetings, and data"> <button type="button" onClick={refreshAll} @@ -150,7 +136,7 @@ export default function IntegrationsPage() { sub={degradedCount > 0 ? "needs attention" : "all healthy"} /> <Stat - label="Meetings · 30d" + label="Meetings" value={bookings.length} sub="from Calendly + Cal.com" last @@ -158,7 +144,6 @@ export default function IntegrationsPage() { </StatStrip> <PageBody> - {/* Catalog grid grouped by category. */} {CATEGORY_ORDER.map((category) => { const entries = grouped[category] ?? []; if (entries.length === 0) return null; @@ -181,45 +166,6 @@ export default function IntegrationsPage() { ); })} - {/* DNS verifier widget — independent of any connection. */} - <SectionBar label="DNS verifier"> - <span className="text-[10.5px] text-slate-400">SPF · DKIM · DMARC</span> - </SectionBar> - <div className="px-5 py-4 border-b border-slate-200/60"> - <DNSVerifierPanel verifications={dnsVerifications} /> - </div> - - {/* DMARC reports — most recent first. */} - <SectionBar label="DMARC reports" count={dmarcReports.length}> - <ShieldCheckIcon className="w-3 h-3 text-slate-400" /> - </SectionBar> - {dmarcReports.length === 0 ? ( - <EmptyBlock - title="No DMARC reports yet" - body="Connect DMARC, then forward your rua= reports to the URL we mint for you." - /> - ) : ( - <div className="divide-y divide-slate-200/60 border-b border-slate-200/60"> - {dmarcReports.slice(0, 10).map((r) => ( - <div key={r.id} className="px-5 h-12 flex items-center gap-3 text-[12.5px]"> - <GlobeIcon className="w-3.5 h-3.5 text-slate-400" /> - <span className="font-medium text-slate-900 w-48 truncate">{r.domain}</span> - <span className="text-slate-500 w-40 truncate">{r.reporter_org}</span> - <span className="ml-auto font-mono text-[11px] tabular-nums text-slate-500"> - {r.pass_messages.toLocaleString()} pass - </span> - <span className="font-mono text-[11px] tabular-nums text-rose-600"> - {r.fail_messages.toLocaleString()} fail - </span> - <span className="font-mono text-[10.5px] text-slate-400 tabular-nums w-32 text-right"> - {new Date(r.range_end).toLocaleDateString()} - </span> - </div> - ))} - </div> - )} - - {/* Meeting bookings — Calendly/Cal.com conversions. */} <SectionBar label="Meeting bookings" count={bookings.length}> <CalendarCheckIcon className="w-3 h-3 text-slate-400" /> </SectionBar> @@ -328,9 +274,11 @@ function CatalogCard({ </button> {connections[0].display_fields && Object.keys(connections[0].display_fields).length > 0 && ( <span className="font-mono text-[10px] text-slate-400 truncate max-w-[120px]"> - {(connections[0].display_fields as Record<string, string>)["domain"] ?? + {(connections[0].display_fields as Record<string, string>)["workspace"] ?? (connections[0].display_fields as Record<string, string>)["sheet_title"] ?? - (connections[0].display_fields as Record<string, string>)["zone_name"] ?? ""} + (connections[0].display_fields as Record<string, string>)["account_email"] ?? + (connections[0].display_fields as Record<string, string>)["channel"] ?? + ""} </span> )} </> @@ -351,10 +299,6 @@ function CatalogCard({ <button type="button" onClick={() => { - // Re-emit the inbound URL dialog so the user can re-copy - // it later. The connect mutation only returns the URL - // at create time, so we synthesize it from the - // connection ID — backend won't replay the secret. onShowInbound("/api/v1/integrations/inbound/" + entry.provider.replace("_", "-") + "/<your-secret>"); }} className="mt-2 text-[10.5px] text-sky-700 hover:underline self-start inline-flex items-center gap-1" @@ -403,6 +347,5 @@ function SourceDot({ source }: { source: string }) { ); } -// Lint shim — keep icons that may only render in branches. void CheckIcon; void XIcon; diff --git a/web/src/lib/api/client/app/integrations/listDMARCReports.ts b/web/src/lib/api/client/app/integrations/listDMARCReports.ts deleted file mode 100644 index f0974039..00000000 --- a/web/src/lib/api/client/app/integrations/listDMARCReports.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { DMARCReport } from "@/lib/api/models/app/integrations/Integration"; -import Request from "../../Request"; - -export default async function listDMARCReports(domain?: string): Promise<{ reports: DMARCReport[] }> { - const qs = domain ? `?domain=${encodeURIComponent(domain)}` : ""; - return await Request<{ reports: DMARCReport[] }>({ - method: "GET", - url: `/integrations/dmarc/reports${qs}`, - authorization: true, - }); -} diff --git a/web/src/lib/api/client/app/integrations/listDNSVerifications.ts b/web/src/lib/api/client/app/integrations/listDNSVerifications.ts deleted file mode 100644 index 57cc9863..00000000 --- a/web/src/lib/api/client/app/integrations/listDNSVerifications.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { DNSVerification } from "@/lib/api/models/app/integrations/Integration"; -import Request from "../../Request"; - -export default async function listDNSVerifications(): Promise<{ verifications: DNSVerification[] }> { - return await Request<{ verifications: DNSVerification[] }>({ - method: "GET", - url: "/integrations/dns/verifications", - authorization: true, - }); -} diff --git a/web/src/lib/api/client/app/integrations/listPostmasterSnapshots.ts b/web/src/lib/api/client/app/integrations/listPostmasterSnapshots.ts deleted file mode 100644 index 4f29d721..00000000 --- a/web/src/lib/api/client/app/integrations/listPostmasterSnapshots.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { PostmasterSnapshot } from "@/lib/api/models/app/integrations/Integration"; -import Request from "../../Request"; - -export default async function listPostmasterSnapshots(source?: string, target?: string): Promise<{ snapshots: PostmasterSnapshot[] }> { - const qs = new URLSearchParams(); - if (source) qs.set("source", source); - if (target) qs.set("target", target); - const suffix = qs.toString() ? `?${qs.toString()}` : ""; - return await Request<{ snapshots: PostmasterSnapshot[] }>({ - method: "GET", - url: `/integrations/postmaster/snapshots${suffix}`, - authorization: true, - }); -} diff --git a/web/src/lib/api/client/app/integrations/verifyDNS.ts b/web/src/lib/api/client/app/integrations/verifyDNS.ts deleted file mode 100644 index 918486e7..00000000 --- a/web/src/lib/api/client/app/integrations/verifyDNS.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { DNSVerification } from "@/lib/api/models/app/integrations/Integration"; -import Request from "../../Request"; - -export interface DNSVerifyInput { - domain: string; - dkim_selector?: string; - tracking_cname?: string; -} - -export default async function verifyDNS(input: DNSVerifyInput): Promise<DNSVerification> { - return await Request<DNSVerification>({ - method: "POST", - url: "/integrations/dns/verify", - data: input, - authorization: true, - }); -} diff --git a/web/src/lib/api/hooks/app/integrations/useDMARCReports.ts b/web/src/lib/api/hooks/app/integrations/useDMARCReports.ts deleted file mode 100644 index 7c273a1e..00000000 --- a/web/src/lib/api/hooks/app/integrations/useDMARCReports.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import listDMARCReports from "@/lib/api/client/app/integrations/listDMARCReports"; - -export default function useDMARCReports(domain?: string) { - return useQuery({ - queryKey: ["integrations", "dmarc", domain ?? ""], - queryFn: () => listDMARCReports(domain), - staleTime: 30_000, - }); -} diff --git a/web/src/lib/api/hooks/app/integrations/useDNSVerifications.ts b/web/src/lib/api/hooks/app/integrations/useDNSVerifications.ts deleted file mode 100644 index 494950db..00000000 --- a/web/src/lib/api/hooks/app/integrations/useDNSVerifications.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import listDNSVerifications from "@/lib/api/client/app/integrations/listDNSVerifications"; - -export default function useDNSVerifications() { - return useQuery({ - queryKey: ["integrations", "dns", "verifications"], - queryFn: listDNSVerifications, - staleTime: 10_000, - }); -} diff --git a/web/src/lib/api/hooks/app/integrations/useVerifyDNS.ts b/web/src/lib/api/hooks/app/integrations/useVerifyDNS.ts deleted file mode 100644 index 158182b8..00000000 --- a/web/src/lib/api/hooks/app/integrations/useVerifyDNS.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import verifyDNS, { type DNSVerifyInput } from "@/lib/api/client/app/integrations/verifyDNS"; - -export default function useVerifyDNS() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: (input: DNSVerifyInput) => verifyDNS(input), - onSuccess: () => { - qc.invalidateQueries({ queryKey: ["integrations", "dns", "verifications"] }); - }, - }); -} diff --git a/web/src/lib/api/models/app/integrations/Integration.ts b/web/src/lib/api/models/app/integrations/Integration.ts index 35382325..758c2184 100644 --- a/web/src/lib/api/models/app/integrations/Integration.ts +++ b/web/src/lib/api/models/app/integrations/Integration.ts @@ -1,25 +1,29 @@ // Mirror of the backend's models/integration.go shapes. Only the fields -// the dashboard renders are typed — opaque blobs like display_fields stay +// the dashboard renders are typed; opaque blobs like display_fields stay // generic so the UI can dig in without round-trips to the type system. export type IntegrationProvider = + | "hubspot" + | "salesforce" + | "pipedrive" + | "close" + | "zapier" + | "make" + | "n8n" + | "slack" + | "discord" | "calendly" | "cal_com" - | "google_sheets" - | "google_postmaster" - | "microsoft_snds" - | "dmarc" - | "cloudflare" - | "godaddy" - | "namecheap"; + | "google_sheets"; export type IntegrationStatus = "pending" | "connected" | "degraded" | "disconnected"; export type IntegrationCategory = + | "crm" + | "automation" + | "notifications" | "meetings" - | "data" - | "deliverability" - | "dns"; + | "data"; export interface IntegrationCatalogEntry { provider: IntegrationProvider; @@ -50,58 +54,6 @@ export interface IntegrationConnection { inbound_webhook_url?: string; } -export interface DMARCReport { - id: string; - organization_id: string; - domain: string; - reporter_org: string; - report_id: string; - range_start: string; - range_end: string; - total_messages: number; - pass_messages: number; - fail_messages: number; - created_at: string; -} - -export interface PostmasterSnapshot { - id: number; - organization_id: string; - source: "google_postmaster" | "microsoft_snds"; - target: string; - snapshot_date: string; - spam_rate_pct?: number; - inbox_placement_pct?: number; - domain_reputation?: string; - ip_reputation?: string; - dkim_success_pct?: number; - spf_success_pct?: number; - dmarc_success_pct?: number; - created_at: string; -} - -export interface DNSVerification { - id: string; - organization_id: string; - domain: string; - - spf_record?: string; - spf_ok: boolean; - - dkim_selector?: string; - dkim_record?: string; - dkim_ok: boolean; - - dmarc_record?: string; - dmarc_ok: boolean; - - tracking_cname?: string; - tracking_ok: boolean; - - notes: Record<string, string>; - checked_at: string; -} - export interface MeetingBooking { id: string; organization_id: string; From df48bec7386da63aac3beea1f6faa5298258ff4a Mon Sep 17 00:00:00 2001 From: Matt <meszmatew@gmail.com> Date: Thu, 28 May 2026 08:59:10 +0200 Subject: [PATCH 10/13] refactor(site): rewrite integrations page for new catalog Replaces deliverability + DNS sections with CRM, Automation, Notifications, Meetings, and Data. Mock dashboard reflects the new provider mix. Number-strip and copy adjusted to match the twelve-provider catalog. --- site/src/pages/integrations.astro | 197 +++++++++++++++++------------- 1 file changed, 110 insertions(+), 87 deletions(-) diff --git a/site/src/pages/integrations.astro b/site/src/pages/integrations.astro index 28c109c7..9662ae87 100644 --- a/site/src/pages/integrations.astro +++ b/site/src/pages/integrations.astro @@ -5,23 +5,22 @@ import Icon from '../components/Icon.astro'; import CTA from '../components/CTA.astro'; // Provider list mirrors internal/app/integration/catalog.go. -// Categories follow the model.IntegrationCategory enum. -// Floating dashboard mock for the hero. Numbers reflect the same five -// connection states the real dashboard renders. +// Floating dashboard mock for the hero. const mockConnections = [ - { name: 'Cloudflare', cat: 'dns', state: 'connected', detail: 'acme.com', age: '2m ago' }, - { name: 'Google Postmaster', cat: 'deliverability', state: 'connected', detail: 'acme.com', age: '4h ago' }, - { name: 'Calendly', cat: 'meetings', state: 'connected', detail: '3 bookings attributed', age: '12m ago' }, - { name: 'DMARC reports', cat: 'deliverability', state: 'connected', detail: 'RUA forwarder live', age: '1h ago' }, - { name: 'Microsoft SNDS', cat: 'deliverability', state: 'connected', detail: '198.51.100.4', age: '6h ago' }, - { name: 'Google Sheets', cat: 'data', state: 'pending', detail: 'Q2 outbound list', age: 'awaiting OAuth' }, + { name: 'HubSpot', cat: 'crm', state: 'connected', detail: 'Acme workspace', age: '2m ago' }, + { name: 'Salesforce', cat: 'crm', state: 'connected', detail: 'Acme Sales Cloud', age: '4h ago' }, + { name: 'Calendly', cat: 'meetings', state: 'connected', detail: '3 bookings tracked', age: '12m ago' }, + { name: 'Slack', cat: 'notifications', state: 'connected', detail: '#sales', age: '1h ago' }, + { name: 'Zapier', cat: 'automation', state: 'connected', detail: 'API token live', age: '6h ago' }, + { name: 'Google Sheets', cat: 'data', state: 'pending', detail: 'Q2 outbound list', age: 'awaiting OAuth' }, ]; const catTone = (c: string) => { - if (c === 'deliverability') return { dot: 'bg-emerald-500', chip: 'bg-emerald-50 text-emerald-700' }; - if (c === 'dns') return { dot: 'bg-sky-500', chip: 'bg-sky-50 text-sky-700' }; - if (c === 'meetings') return { dot: 'bg-rose-400', chip: 'bg-rose-50 text-rose-700' }; + if (c === 'crm') return { dot: 'bg-emerald-500', chip: 'bg-emerald-50 text-emerald-700' }; + if (c === 'automation') return { dot: 'bg-sky-500', chip: 'bg-sky-50 text-sky-700' }; + if (c === 'notifications') return { dot: 'bg-violet-500', chip: 'bg-violet-50 text-violet-700' }; + if (c === 'meetings') return { dot: 'bg-rose-400', chip: 'bg-rose-50 text-rose-700' }; return { dot: 'bg-amber-500', chip: 'bg-amber-50 text-amber-700' }; }; @@ -34,68 +33,97 @@ const stateTone = (s: string) => { const categories = [ { - id: 'deliverability', - eyebrow: 'Deliverability', - headline: 'Reputation signals from the providers themselves.', - body: 'Pull complaint rate, domain reputation, and authentication results directly from the sources that produce them. The data joins each mailbox health state so a drop in reputation flows through to the dashboard automatically.', + id: 'crm', + eyebrow: 'CRM', + headline: 'Sync contacts and activity to your system of record.', + body: 'Connect the CRM your team already runs the pipeline in. Replies, bounces, and meeting bookings flow back as timeline events without manual logging.', items: [ { - name: 'Google Postmaster', - tagline: 'Domain reputation, spam-rate, and authentication ratios from Gmail Postmaster Tools.', - body: 'OAuth into Google Postmaster. We pull one snapshot per day covering domain reputation, IP reputation, SPF, DKIM, and DMARC success ratios, plus user-reported spam rate. Snapshots are stored per domain and per day so the trend line is queryable from the dashboard.', + name: 'HubSpot', + tagline: 'Two-way sync for contacts and activities.', + body: 'OAuth into HubSpot. Contacts can be pulled into campaigns and outbound activity is written back as engagement events on the contact record.', auth: 'OAuth', beta: false, }, { - name: 'Microsoft SNDS', - tagline: 'IP reputation and complaint-rate data for Outlook and Hotmail.', - body: 'Paste the SNDS data-access key. The CSV is polled daily, the filter-result and complaint-rate buckets are parsed, and the values are normalized to the same reputation tiers (high, medium, low) that the Postmaster integration uses.', - auth: 'API key', + name: 'Salesforce', + tagline: 'Leads, contacts, and email activity.', + body: 'OAuth with the standard Salesforce API. Outbound mail is logged on the lead or contact, and replies fire as task events for the assigned owner.', + auth: 'OAuth', beta: false, }, { - name: 'DMARC reports', - tagline: 'Aggregate (RUA) XML ingestion with per-source-IP breakdown.', - body: 'Forward your RUA reports to the URL minted for your organization. Each report is parsed, deduplicated, and split by source IP. The dashboard surfaces which senders are failing SPF or DKIM alignment, with hostnames where available.', + name: 'Pipedrive', + tagline: 'Persons, deals, and activity timeline.', + body: 'Use a Pipedrive API token. Recipients are mapped to persons, replies create activities, and deals can be advanced based on reply intent.', + auth: 'API token', + beta: false, + }, + { + name: 'Close', + tagline: 'Leads, contacts, and inbox activity.', + body: 'Close API key. Replies and bounces sync as inbox activity, and contacts can be created or updated as campaigns send.', + auth: 'API key', + beta: false, + }, + ], + }, + { + id: 'automation', + eyebrow: 'Automation', + headline: 'Route Warmbly events through your existing automation stack.', + body: 'Generate a Warmbly API token, paste it into your automation platform of choice, and trigger off any event Warmbly emits.', + items: [ + { + name: 'Zapier', + tagline: 'Triggers and actions across 8,000+ apps.', + body: 'A Warmbly API token authenticates the Zapier app. Triggers are available for every event Warmbly emits, and actions cover campaign management, contact updates, and suppression.', + auth: 'API token', + beta: false, + }, + { + name: 'Make', + tagline: 'Visual automation scenarios.', + body: 'Same Warmbly API token, used inside Make scenarios. Useful for branching logic that goes beyond what a flat trigger or action provides.', + auth: 'API token', + beta: false, + }, + { + name: 'n8n', + tagline: 'Self-hosted automation workflows.', + body: 'For customers who run n8n on their own infrastructure. The same API token authenticates against Warmbly endpoints.', + auth: 'API token', + beta: false, + }, + ], + }, + { + id: 'notifications', + eyebrow: 'Notifications', + headline: 'Pipe replies and bounces into your team chat.', + body: 'Positive replies, bounces, complaints, and meeting bookings can fan out to a Slack or Discord channel so the right person sees them without checking the dashboard.', + items: [ + { + name: 'Slack', + tagline: 'Channels for positive replies, bounces, and meeting bookings.', + body: 'Connect Slack with OAuth or paste an incoming-webhook URL. Choose which events post to which channel, with separate channels for replies versus deliverability alerts if needed.', + auth: 'OAuth', + beta: false, + }, + { + name: 'Discord', + tagline: 'Webhook-based notifications to a server channel.', + body: 'Paste a Discord channel webhook URL. The same event filters that apply to Slack apply here, so the two can run in parallel if a team uses both.', auth: 'Webhook URL', beta: false, }, ], }, - { - id: 'dns', - eyebrow: 'DNS providers', - headline: 'Publish SPF, DKIM, and DMARC records from the dashboard.', - body: 'Connect a DNS provider once. Records can be published, updated, and verified without leaving Warmbly.', - items: [ - { - name: 'Cloudflare', - tagline: 'Zone-scoped API token. Verified on save.', - body: 'Provide a token scoped to Zone:DNS:Edit. The token is verified against Cloudflare before it is persisted, then the zone ID is resolved and the recommended records are published. Existing records are detected and surfaced before any overwrite.', - auth: 'API token', - beta: false, - }, - { - name: 'GoDaddy', - tagline: 'API key and secret from the GoDaddy developer portal.', - body: 'GoDaddy issues a key and secret pair. Both are stored encrypted and used to publish the recommended records on save. Reads are batched so a partial rate-limited response cannot leave the record set inconsistent.', - auth: 'API key', - beta: true, - }, - { - name: 'Namecheap', - tagline: 'Namecheap API access.', - body: 'Namecheap requires an IP allowlist on the API endpoint. The connect drawer displays the egress IP that must be added to the allowlist. Once configured, record writes complete in seconds.', - auth: 'API token', - beta: true, - }, - ], - }, { id: 'meetings', - eyebrow: 'Meeting bookings', + eyebrow: 'Meetings', headline: 'Attribute booked meetings to the campaign that surfaced the lead.', - body: 'Both Calendly and Cal.com post a webhook when a recipient books. The booking is recorded, joined to the originating contact and campaign, and surfaced in campaign reporting alongside replies.', + body: 'Calendly and Cal.com post a webhook when a recipient books. The booking is recorded, joined to the originating contact and campaign, and surfaced in reporting alongside replies.', items: [ { name: 'Calendly', @@ -152,46 +180,42 @@ const webhookEvents = [ { name: 'email_account.removed', desc: 'A mailbox was removed from the workspace.' }, ]; -// Connect steps shown in the dark panel. const steps = [ { n: '01', t: 'Authenticate', d: 'OAuth, an API token, or a webhook URL minted for your organization. The connect drawer surfaces the right method per provider.' }, { n: '02', t: 'Route', d: 'The connection is recorded against your organization. Inbound traffic is routed by the secret in the URL path. Outbound traffic uses the encrypted token.' }, { n: '03', t: 'Live', d: 'Status moves to connected. The dashboard shows the last sync time, the last error if any, and a one-click secret rotation. Disconnect cascades to dependent data.' }, ]; -// Numbers used in the strip near the bottom. const numbers = [ - { v: '9', u: 'providers', l: 'Calendly, Cal.com, Sheets, Postmaster, SNDS, DMARC, Cloudflare, GoDaddy, Namecheap', src: 'integration/catalog.go' }, - { v: '18', u: 'webhook events', l: 'campaign, warmup, deliverability, account lifecycle', src: 'models/webhook.go' }, - { v: 'HMAC-SHA256', u: '', l: 'every outbound webhook is signed', src: 'app/webhook/service.go' }, - { v: '8', u: 'max attempts', l: 'capped exponential backoff up to one hour', src: 'app/webhook/service.go' }, + { v: '12', u: 'providers', l: 'CRM, automation, notifications, meetings, data', src: 'integration/catalog.go' }, + { v: '18', u: 'webhook events', l: 'campaign, warmup, deliverability, account lifecycle', src: 'models/webhook.go' }, + { v: 'HMAC-SHA256', u: '', l: 'every outbound webhook is signed', src: 'app/webhook/service.go' }, + { v: '8', u: 'max attempts', l: 'capped exponential backoff up to one hour', src: 'app/webhook/service.go' }, ]; -// Security strip. const security = [ - { t: 'OAuth tokens encrypted at rest', d: 'AES-256-GCM with per-user data encryption keys wrapped by AWS KMS. The encrypted blob is stored in DynamoDB. Plaintext is only held in a TTL-bounded cache during active use.' }, - { t: 'API tokens stored as opaque blobs', d: 'Cloudflare, GoDaddy, Namecheap, and SNDS keys are never serialized back to the API consumer. The dashboard sees only the public display fields (zone name, domain, IP).' }, - { t: 'Inbound URLs are per-organization', d: 'A leaked URL only affects one organization. Rotating the secret invalidates the old one immediately.' }, - { t: 'Outbound delivery audit trail', d: 'Every webhook dispatch attempt is recorded with response status and body excerpt. Replays are supported. SKIP LOCKED prevents duplicate fanout across replicas.' }, + { t: 'OAuth tokens encrypted at rest', d: 'AES-256-GCM with per-user data encryption keys wrapped by AWS KMS. The encrypted blob is stored in DynamoDB. Plaintext is only held in a TTL-bounded cache during active use.' }, + { t: 'API tokens stored as opaque blobs', d: 'CRM, automation, and notification keys are never serialized back to the API consumer. The dashboard sees only the public display fields (workspace, channel, account email).' }, + { t: 'Inbound URLs are per-organization', d: 'A leaked URL only affects one organization. Rotating the secret invalidates the old one immediately.' }, + { t: 'Outbound delivery audit trail', d: 'Every webhook dispatch attempt is recorded with response status and body excerpt. Replays are supported. SKIP LOCKED prevents duplicate fanout across replicas.' }, ]; -// FAQ. const faq = [ ['Do you support OAuth for every provider?', - 'No. OAuth is used where the provider exposes a per-user identity, such as Google Sheets or Google Postmaster. For account-scoped credentials such as a Cloudflare API token or an SNDS data-access key, a static token is simpler and more appropriate. The connect drawer uses the right method per provider.'], + 'No. OAuth is used where the provider exposes a per-user identity, such as HubSpot, Salesforce, Google Sheets, and Slack. For account-scoped credentials such as Pipedrive or Close API tokens, a static token is simpler and more appropriate. The connect drawer uses the right method per provider.'], ['What happens when a token expires or is revoked?', 'The connection status moves to degraded and the dashboard shows the provider error. Degraded connections stop attempting new fanout until the credentials are rotated or re-authenticated.'], ['Can I have more than one of the same provider?', - 'Yes. A connection is unique per (organization, provider, label). One organization can hold a Cloudflare connection per zone, for example, instead of putting multiple zones behind one token.'], + 'Yes. A connection is unique per (organization, provider, label). One organization can hold a HubSpot connection per workspace, or a Slack connection per channel.'], ['How fast is the inbound webhook path?', - 'Calendly, Cal.com, and DMARC POSTs are accepted, persisted, and acknowledged inside the same request. Fanout to outbound subscribers runs on the internal event queue, with a default tick of two seconds.'], + 'Calendly and Cal.com POSTs are accepted, persisted, and acknowledged inside the same request. Fanout to outbound subscribers runs on the internal event queue, with a default tick of two seconds.'], ['What if you do not list a provider I need?', - 'Subscribe to the webhook stream and build the integration directly. The eighteen event types cover every state transition that is emitted internally. The catalog is a curated convenience layer on top of the same surface.'], + 'Subscribe to the webhook stream and build the integration directly, or run it through Zapier, Make, or n8n. The eighteen event types cover every state transition that is emitted internally.'], ]; --- <Layout title="Integrations · Warmbly" - description="Deliverability, DNS, meeting booking, and data integrations for Warmbly. Plus a signed webhook stream and developer API." + description="CRM, automation, notifications, meetings, and data integrations for Warmbly. Plus a signed webhook stream and developer API." > <!-- HERO --> <section class="relative isolate overflow-hidden"> @@ -209,7 +233,7 @@ const faq = [ Integrations. </h1> <p class="mt-6 text-[17px] md:text-[19px] text-white/80 max-w-2xl mx-auto leading-relaxed"> - Deliverability signals from Google Postmaster, Microsoft SNDS, and DMARC. One-click DNS through Cloudflare, GoDaddy, and Namecheap. Meeting attribution from Calendly and Cal.com. Two-way Google Sheets. Plus a signed webhook stream for everything else. + HubSpot, Salesforce, Pipedrive, and Close for CRM. Zapier, Make, and n8n for automation. Slack and Discord for team notifications. Calendly and Cal.com for meeting attribution. Google Sheets for two-way lead lists. Plus a signed webhook stream for everything else. </p> <div class="mt-8 flex flex-wrap items-center justify-center gap-3"> @@ -219,14 +243,14 @@ const faq = [ <path d="M5 12h14"/><path d="m12 5 7 7-7 7"/> </svg> </a> - <a href="#deliverability" class="inline-flex h-11 px-5 items-center rounded-[10px] text-[14.5px] font-medium bg-white/10 backdrop-blur ring-1 ring-white/40 text-white hover:bg-white/20 hover:-translate-y-0.5 transition-all duration-200 ease-out"> + <a href="#crm" class="inline-flex h-11 px-5 items-center rounded-[10px] text-[14.5px] font-medium bg-white/10 backdrop-blur ring-1 ring-white/40 text-white hover:bg-white/20 hover:-translate-y-0.5 transition-all duration-200 ease-out"> Browse integrations </a> </div> </div> </section> - <!-- FLOATING DASHBOARD · connection state mock --> + <!-- FLOATING DASHBOARD --> <section class="relative -mt-36 md:-mt-44 pb-16 md:pb-24 z-10"> <div class="container-page"> <div class="rounded-[18px] bg-white ring-1 ring-[color:var(--border)] overflow-hidden shadow-[0_40px_90px_-30px_rgba(2,32,71,0.35),0_12px_30px_-12px_rgba(15,23,42,0.12)]"> @@ -241,12 +265,13 @@ const faq = [ </div> </div> - <div class="grid grid-cols-2 md:grid-cols-4 divide-x divide-[color:var(--border)] border-b border-[color:var(--border)]"> + <div class="grid grid-cols-2 md:grid-cols-5 divide-x divide-[color:var(--border)] border-b border-[color:var(--border)]"> {[ - { l: 'Deliverability', n: '3', c: 'bg-emerald-500' }, - { l: 'DNS', n: '1', c: 'bg-sky-500' }, - { l: 'Meetings', n: '1', c: 'bg-rose-400' }, - { l: 'Data', n: '1', c: 'bg-amber-500' }, + { l: 'CRM', n: '2', c: 'bg-emerald-500' }, + { l: 'Automation', n: '1', c: 'bg-sky-500' }, + { l: 'Notifications', n: '1', c: 'bg-violet-500' }, + { l: 'Meetings', n: '1', c: 'bg-rose-400' }, + { l: 'Data', n: '1', c: 'bg-amber-500' }, ].map((s) => ( <div class="px-5 py-3.5 flex items-baseline justify-between"> <div class="flex items-center gap-2"> @@ -291,7 +316,7 @@ const faq = [ </div> <div class="px-6 py-3 border-t border-[color:var(--border)] bg-[color:var(--surface-1)]/60 flex items-center justify-between text-[11px] font-mono text-muted-foreground"> - <span>9 providers, 18 webhook event types</span> + <span>12 providers, 18 webhook event types</span> <span class="inline-flex items-center gap-1.5 text-foreground/55"> <span class="w-1.5 h-1.5 rounded-full bg-emerald-500"></span> evaluated every event @@ -304,7 +329,7 @@ const faq = [ </div> </section> - <!-- HOW IT CONNECTS · 3-step diagram --> + <!-- HOW IT WORKS --> <section class="border-y border-[color:var(--border)] py-20 md:py-28"> <div class="container-page"> <div class="max-w-3xl mb-12"> @@ -396,7 +421,6 @@ const faq = [ </div> <div class="space-y-5"> - <!-- Code block --> <div class="rounded-[14px] bg-[#0c1224] ring-1 ring-white/10 overflow-hidden"> <div class="px-5 py-2.5 border-b border-white/10 flex items-center justify-between text-[10.5px] font-mono text-white/55"> <span>POST your webhook endpoint</span> @@ -419,7 +443,6 @@ const faq = [ }`}</code></pre> </div> - <!-- Event list --> <div class="rounded-[14px] bg-white ring-1 ring-[color:var(--border)] overflow-hidden"> <div class="px-5 py-3 bg-[color:var(--surface-1)] border-b border-[color:var(--border)] flex items-baseline justify-between gap-2"> <div class="text-[10.5px] uppercase tracking-[0.18em] font-mono text-muted-foreground">Event types</div> @@ -464,7 +487,7 @@ const faq = [ </div> </section> - <!-- BY THE NUMBERS --> + <!-- AT A GLANCE --> <section class="bg-white py-16 md:py-20"> <div class="container-page"> <div class="max-w-2xl mb-10"> From 38373aa2670d969595e2840531ee9d5b37797d95 Mon Sep 17 00:00:00 2001 From: Matt <meszmatew@gmail.com> Date: Thu, 28 May 2026 09:41:59 +0200 Subject: [PATCH 11/13] refactor(site): replace integrations hero mock with animated fanout diagram Drops the fake floating dashboard. The new hero panel shows a real story: a single campaign.reply_received event lands on the Warmbly hub and fans out to five concrete provider channels (Calendly meeting booked, HubSpot activity logged, Slack #sales notified, Cal.com demo booked, Pipedrive deal advanced). Each channel has a traveling SVG packet animation. Subtle pulsing hub ring and per-card receive pulses. The diagram tells the integrations value proposition directly instead of showing generic dashboard rows. --- site/src/pages/integrations.astro | 190 +++++++++++++++++------------- 1 file changed, 109 insertions(+), 81 deletions(-) diff --git a/site/src/pages/integrations.astro b/site/src/pages/integrations.astro index 9662ae87..13d6a2f3 100644 --- a/site/src/pages/integrations.astro +++ b/site/src/pages/integrations.astro @@ -6,31 +6,17 @@ import CTA from '../components/CTA.astro'; // Provider list mirrors internal/app/integration/catalog.go. -// Floating dashboard mock for the hero. -const mockConnections = [ - { name: 'HubSpot', cat: 'crm', state: 'connected', detail: 'Acme workspace', age: '2m ago' }, - { name: 'Salesforce', cat: 'crm', state: 'connected', detail: 'Acme Sales Cloud', age: '4h ago' }, - { name: 'Calendly', cat: 'meetings', state: 'connected', detail: '3 bookings tracked', age: '12m ago' }, - { name: 'Slack', cat: 'notifications', state: 'connected', detail: '#sales', age: '1h ago' }, - { name: 'Zapier', cat: 'automation', state: 'connected', detail: 'API token live', age: '6h ago' }, - { name: 'Google Sheets', cat: 'data', state: 'pending', detail: 'Q2 outbound list', age: 'awaiting OAuth' }, +// Hero event-fanout diagram rows. Each row is one channel: a Warmbly +// event lands on the hub, then fans out to one connected provider with a +// concrete action description. Drawn as five animated SVG channels. +const fanoutChannels = [ + { y: 60, provider: 'Calendly', letter: 'C', accent: '#fb7185', action: 'meeting booked', desc: 'lead@acme.co', delay: '0s' }, + { y: 128, provider: 'HubSpot', letter: 'H', accent: '#f97316', action: 'activity logged', desc: 'on contact record', delay: '0.6s' }, + { y: 196, provider: 'Slack', letter: 'S', accent: '#a78bfa', action: '#sales notified', desc: 'positive reply', delay: '1.2s' }, + { y: 264, provider: 'Cal.com', letter: 'C', accent: '#0ea5e9', action: 'demo booked', desc: 'founder@startup.co', delay: '1.8s' }, + { y: 332, provider: 'Pipedrive', letter: 'P', accent: '#10b981', action: 'deal advanced', desc: 'to qualified', delay: '2.4s' }, ]; -const catTone = (c: string) => { - if (c === 'crm') return { dot: 'bg-emerald-500', chip: 'bg-emerald-50 text-emerald-700' }; - if (c === 'automation') return { dot: 'bg-sky-500', chip: 'bg-sky-50 text-sky-700' }; - if (c === 'notifications') return { dot: 'bg-violet-500', chip: 'bg-violet-50 text-violet-700' }; - if (c === 'meetings') return { dot: 'bg-rose-400', chip: 'bg-rose-50 text-rose-700' }; - return { dot: 'bg-amber-500', chip: 'bg-amber-50 text-amber-700' }; -}; - -const stateTone = (s: string) => { - if (s === 'connected') return { dot: 'bg-emerald-500', chip: 'bg-emerald-50 text-emerald-700' }; - if (s === 'pending') return { dot: 'bg-sky-500', chip: 'bg-sky-50 text-sky-700' }; - if (s === 'degraded') return { dot: 'bg-amber-500', chip: 'bg-amber-50 text-amber-700' }; - return { dot: 'bg-slate-400', chip: 'bg-slate-100 text-slate-600' }; -}; - const categories = [ { id: 'crm', @@ -250,82 +236,124 @@ const faq = [ </div> </section> - <!-- FLOATING DASHBOARD --> + <!-- HERO DIAGRAM: event fanout --> <section class="relative -mt-36 md:-mt-44 pb-16 md:pb-24 z-10"> <div class="container-page"> <div class="rounded-[18px] bg-white ring-1 ring-[color:var(--border)] overflow-hidden shadow-[0_40px_90px_-30px_rgba(2,32,71,0.35),0_12px_30px_-12px_rgba(15,23,42,0.12)]"> + <!-- Topbar --> <div class="px-6 py-3 border-b border-[color:var(--border)] flex items-baseline justify-between gap-3"> <div class="flex items-baseline gap-3"> - <div class="text-[10.5px] font-mono uppercase tracking-[0.18em] text-muted-foreground">Integrations</div> - <span class="text-[11.5px] text-foreground/55">5 connected, 1 pending, 0 degraded</span> + <div class="text-[10.5px] font-mono uppercase tracking-[0.18em] text-muted-foreground">Event fanout</div> + <span class="text-[11.5px] text-foreground/55">One Warmbly event, every connected tool</span> </div> - <div class="flex items-center gap-2"> - <span class="inline-flex items-center h-6 px-2 rounded-md text-[10.5px] font-mono text-[#0369a1] bg-[color:var(--sky-1)]">live</span> + <div class="flex items-center gap-3 text-[11px] font-mono text-muted-foreground"> + <span class="inline-flex items-center gap-1.5"> + <span class="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse"></span> + live + </span> + <span>HMAC-signed</span> </div> </div> - <div class="grid grid-cols-2 md:grid-cols-5 divide-x divide-[color:var(--border)] border-b border-[color:var(--border)]"> - {[ - { l: 'CRM', n: '2', c: 'bg-emerald-500' }, - { l: 'Automation', n: '1', c: 'bg-sky-500' }, - { l: 'Notifications', n: '1', c: 'bg-violet-500' }, - { l: 'Meetings', n: '1', c: 'bg-rose-400' }, - { l: 'Data', n: '1', c: 'bg-amber-500' }, - ].map((s) => ( - <div class="px-5 py-3.5 flex items-baseline justify-between"> - <div class="flex items-center gap-2"> - <span class={`w-2 h-2 rounded-full ${s.c}`}></span> - <span class="text-[11.5px] uppercase tracking-[0.14em] font-mono text-foreground/70">{s.l}</span> - </div> - <span class="text-[14.5px] font-mono font-semibold text-heading tabular-nums">{s.n}</span> - </div> - ))} - </div> - - <div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-px bg-[color:var(--border)]"> - {mockConnections.map((c) => { - const ct = catTone(c.cat); - const st = stateTone(c.state); - return ( - <div class="bg-white p-5 flex flex-col"> - <div class="flex items-start justify-between gap-3"> - <div class="flex items-center gap-2.5"> - <div class="w-9 h-9 rounded-md bg-[color:var(--sky-1)] ring-1 ring-[color:var(--sky-2)] text-[#0369a1] inline-flex items-center justify-center text-[13px] font-semibold uppercase"> - {c.name.charAt(0)} - </div> - <div> - <div class="text-[13px] font-semibold text-heading">{c.name}</div> - <span class={`mt-0.5 inline-flex items-center gap-1 h-4 px-1.5 rounded text-[9.5px] uppercase tracking-[0.08em] font-medium font-mono ${ct.chip}`}> - <span class={`w-1 h-1 rounded-full ${ct.dot}`}></span>{c.cat} - </span> - </div> - </div> - <span class={`inline-flex items-center gap-1 h-5 px-1.5 rounded text-[10px] uppercase tracking-[0.08em] font-medium ${st.chip}`}> - <span class={`w-1 h-1 rounded-full ${st.dot}`}></span>{c.state} - </span> - </div> - <div class="mt-3 text-[12px] text-foreground/70 leading-relaxed">{c.detail}</div> - <div class="mt-auto pt-3 flex items-center justify-between text-[10.5px] font-mono text-muted-foreground"> - <span>last sync</span> - <span class="text-foreground/75">{c.age}</span> - </div> - </div> - ); - })} + <!-- The diagram --> + <div class="relative px-6 py-8 md:py-10 bg-gradient-to-br from-white via-[#f8fbff] to-white"> + <svg viewBox="0 0 1000 400" class="w-full h-auto" preserveAspectRatio="xMidYMid meet" aria-hidden="true"> + <defs> + <linearGradient id="warmHubGrad" x1="0%" y1="0%" x2="100%" y2="100%"> + <stop offset="0%" stop-color="#0ea5e9"/> + <stop offset="100%" stop-color="#0369a1"/> + </linearGradient> + <radialGradient id="warmHubGlow" cx="50%" cy="50%" r="50%"> + <stop offset="0%" stop-color="#0ea5e9" stop-opacity="0.35"/> + <stop offset="60%" stop-color="#0ea5e9" stop-opacity="0.08"/> + <stop offset="100%" stop-color="#0ea5e9" stop-opacity="0"/> + </radialGradient> + <linearGradient id="channelGrad" x1="0%" y1="0%" x2="100%" y2="0%"> + <stop offset="0%" stop-color="#0284c7" stop-opacity="0.55"/> + <stop offset="100%" stop-color="#0284c7" stop-opacity="0.15"/> + </linearGradient> + + {fanoutChannels.map((ch) => ( + <path id={`ch-path-${ch.y}`} d={`M 200 196 C 380 196, 520 ${ch.y}, 700 ${ch.y}`} fill="none"/> + ))} + </defs> + + <!-- Hub glow --> + <circle cx="140" cy="196" r="120" fill="url(#warmHubGlow)"/> + + <!-- Hub --> + <circle cx="140" cy="196" r="60" fill="url(#warmHubGrad)"/> + <circle cx="140" cy="196" r="60" fill="none" stroke="white" stroke-opacity="0.35" stroke-width="2"/> + <circle cx="140" cy="196" r="72" fill="none" stroke="#0ea5e9" stroke-opacity="0.25" stroke-width="1"> + <animate attributeName="r" values="72;82;72" dur="3s" repeatCount="indefinite"/> + <animate attributeName="stroke-opacity" values="0.25;0.05;0.25" dur="3s" repeatCount="indefinite"/> + </circle> + <text x="140" y="192" text-anchor="middle" fill="white" font-size="11" font-weight="700" font-family="ui-monospace,monospace" letter-spacing="1.5">WARMBLY</text> + <text x="140" y="208" text-anchor="middle" fill="white" font-size="9" font-family="ui-monospace,monospace" opacity="0.75">event hub</text> + + <!-- Source label --> + <text x="140" y="290" text-anchor="middle" fill="#0369a1" font-size="10" font-family="ui-monospace,monospace" letter-spacing="2" font-weight="600">campaign.reply_received</text> + <text x="140" y="305" text-anchor="middle" fill="#64748b" font-size="9" font-family="ui-monospace,monospace">incoming event</text> + + <!-- Channels: bezier path + traveling dot per row --> + {fanoutChannels.map((ch) => ( + <g> + <path d={`M 200 196 C 380 196, 520 ${ch.y}, 700 ${ch.y}`} + fill="none" stroke="url(#channelGrad)" stroke-width="1.5"/> + <circle r="4.5" fill="#0ea5e9" opacity="0.95"> + <animateMotion dur="3.6s" repeatCount="indefinite" begin={ch.delay}> + <mpath href={`#ch-path-${ch.y}`}/> + </animateMotion> + </circle> + <circle r="9" fill="#0ea5e9" opacity="0.18"> + <animateMotion dur="3.6s" repeatCount="indefinite" begin={ch.delay}> + <mpath href={`#ch-path-${ch.y}`}/> + </animateMotion> + </circle> + </g> + ))} + + <!-- Provider end-cards on the right --> + {fanoutChannels.map((ch) => ( + <g> + <rect x="700" y={ch.y - 24} width="260" height="48" rx="10" + fill="white" stroke="#e2e8f0" stroke-width="1"/> + <circle cx="724" cy={ch.y} r="14" fill={ch.accent} fill-opacity="0.12" stroke={ch.accent} stroke-opacity="0.45"/> + <text x="724" y={ch.y + 4} text-anchor="middle" fill={ch.accent} font-size="11" font-weight="700" font-family="ui-monospace,monospace">{ch.letter}</text> + <text x="748" y={ch.y - 3} fill="#0f172a" font-size="12.5" font-weight="600">{ch.provider}</text> + <text x="748" y={ch.y + 13} fill="#475569" font-size="10.5" font-family="ui-monospace,monospace">{ch.action} · {ch.desc}</text> + + <!-- Receive pulse on each end-card --> + <circle cx={700 + 260} cy={ch.y} r="3.5" fill={ch.accent} opacity="0"> + <animate attributeName="opacity" values="0;0.9;0" dur="3.6s" repeatCount="indefinite" begin={ch.delay}/> + </circle> + </g> + ))} + </svg> + + <!-- Legend strip below --> + <div class="mt-4 flex flex-wrap items-center justify-center gap-x-6 gap-y-2 text-[11px] font-mono text-muted-foreground"> + <span class="inline-flex items-center gap-1.5"> + <span class="w-1.5 h-1.5 rounded-full bg-[#0ea5e9]"></span> + event packet + </span> + <span class="w-1 h-1 rounded-full bg-[color:var(--border)]"></span> + <span>one Warmbly event triggers every subscribed channel</span> + <span class="w-1 h-1 rounded-full bg-[color:var(--border)]"></span> + <span>delivery in ~2s</span> + </div> </div> + <!-- Footer --> <div class="px-6 py-3 border-t border-[color:var(--border)] bg-[color:var(--surface-1)]/60 flex items-center justify-between text-[11px] font-mono text-muted-foreground"> - <span>12 providers, 18 webhook event types</span> + <span>12 providers · 18 event types</span> <span class="inline-flex items-center gap-1.5 text-foreground/55"> <span class="w-1.5 h-1.5 rounded-full bg-emerald-500"></span> - evaluated every event + signed, retried, audited </span> </div> </div> - <div class="mt-3 text-center text-[11.5px] font-mono text-muted-foreground"> - app.warmbly.com/integrations - </div> </div> </section> From 32875772c8da55654a9774c219296df5112b57b2 Mon Sep 17 00:00:00 2001 From: Matt <meszmatew@gmail.com> Date: Thu, 28 May 2026 09:45:58 +0200 Subject: [PATCH 12/13] refactor(site): drop hero diagram from integrations page The animated fanout SVG was not landing. Removes the entire diagram panel so the page goes hero, then How it works, then categories. Tightens the hero bottom padding now that nothing overlaps it. --- site/src/pages/integrations.astro | 134 +----------------------------- 1 file changed, 1 insertion(+), 133 deletions(-) diff --git a/site/src/pages/integrations.astro b/site/src/pages/integrations.astro index 13d6a2f3..a95cd844 100644 --- a/site/src/pages/integrations.astro +++ b/site/src/pages/integrations.astro @@ -6,17 +6,6 @@ import CTA from '../components/CTA.astro'; // Provider list mirrors internal/app/integration/catalog.go. -// Hero event-fanout diagram rows. Each row is one channel: a Warmbly -// event lands on the hub, then fans out to one connected provider with a -// concrete action description. Drawn as five animated SVG channels. -const fanoutChannels = [ - { y: 60, provider: 'Calendly', letter: 'C', accent: '#fb7185', action: 'meeting booked', desc: 'lead@acme.co', delay: '0s' }, - { y: 128, provider: 'HubSpot', letter: 'H', accent: '#f97316', action: 'activity logged', desc: 'on contact record', delay: '0.6s' }, - { y: 196, provider: 'Slack', letter: 'S', accent: '#a78bfa', action: '#sales notified', desc: 'positive reply', delay: '1.2s' }, - { y: 264, provider: 'Cal.com', letter: 'C', accent: '#0ea5e9', action: 'demo booked', desc: 'founder@startup.co', delay: '1.8s' }, - { y: 332, provider: 'Pipedrive', letter: 'P', accent: '#10b981', action: 'deal advanced', desc: 'to qualified', delay: '2.4s' }, -]; - const categories = [ { id: 'crm', @@ -207,7 +196,7 @@ const faq = [ <section class="relative isolate overflow-hidden"> <HeroAtmosphere /> - <div class="container-page relative pt-16 md:pt-24 pb-44 md:pb-56 text-center"> + <div class="container-page relative pt-16 md:pt-24 pb-20 md:pb-28 text-center"> <div class="inline-flex items-center gap-2 h-7 pl-1 pr-3 rounded-full bg-white/15 backdrop-blur ring-1 ring-white/25 text-[12px] text-white/95 shadow-[0_4px_14px_-4px_rgba(0,0,0,0.25)]"> <span class="inline-flex items-center h-5 px-1.5 rounded-full text-[10.5px] font-semibold uppercase tracking-[0.06em] bg-white" style="color:#0369a1;"> Integrations @@ -236,127 +225,6 @@ const faq = [ </div> </section> - <!-- HERO DIAGRAM: event fanout --> - <section class="relative -mt-36 md:-mt-44 pb-16 md:pb-24 z-10"> - <div class="container-page"> - <div class="rounded-[18px] bg-white ring-1 ring-[color:var(--border)] overflow-hidden shadow-[0_40px_90px_-30px_rgba(2,32,71,0.35),0_12px_30px_-12px_rgba(15,23,42,0.12)]"> - - <!-- Topbar --> - <div class="px-6 py-3 border-b border-[color:var(--border)] flex items-baseline justify-between gap-3"> - <div class="flex items-baseline gap-3"> - <div class="text-[10.5px] font-mono uppercase tracking-[0.18em] text-muted-foreground">Event fanout</div> - <span class="text-[11.5px] text-foreground/55">One Warmbly event, every connected tool</span> - </div> - <div class="flex items-center gap-3 text-[11px] font-mono text-muted-foreground"> - <span class="inline-flex items-center gap-1.5"> - <span class="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse"></span> - live - </span> - <span>HMAC-signed</span> - </div> - </div> - - <!-- The diagram --> - <div class="relative px-6 py-8 md:py-10 bg-gradient-to-br from-white via-[#f8fbff] to-white"> - <svg viewBox="0 0 1000 400" class="w-full h-auto" preserveAspectRatio="xMidYMid meet" aria-hidden="true"> - <defs> - <linearGradient id="warmHubGrad" x1="0%" y1="0%" x2="100%" y2="100%"> - <stop offset="0%" stop-color="#0ea5e9"/> - <stop offset="100%" stop-color="#0369a1"/> - </linearGradient> - <radialGradient id="warmHubGlow" cx="50%" cy="50%" r="50%"> - <stop offset="0%" stop-color="#0ea5e9" stop-opacity="0.35"/> - <stop offset="60%" stop-color="#0ea5e9" stop-opacity="0.08"/> - <stop offset="100%" stop-color="#0ea5e9" stop-opacity="0"/> - </radialGradient> - <linearGradient id="channelGrad" x1="0%" y1="0%" x2="100%" y2="0%"> - <stop offset="0%" stop-color="#0284c7" stop-opacity="0.55"/> - <stop offset="100%" stop-color="#0284c7" stop-opacity="0.15"/> - </linearGradient> - - {fanoutChannels.map((ch) => ( - <path id={`ch-path-${ch.y}`} d={`M 200 196 C 380 196, 520 ${ch.y}, 700 ${ch.y}`} fill="none"/> - ))} - </defs> - - <!-- Hub glow --> - <circle cx="140" cy="196" r="120" fill="url(#warmHubGlow)"/> - - <!-- Hub --> - <circle cx="140" cy="196" r="60" fill="url(#warmHubGrad)"/> - <circle cx="140" cy="196" r="60" fill="none" stroke="white" stroke-opacity="0.35" stroke-width="2"/> - <circle cx="140" cy="196" r="72" fill="none" stroke="#0ea5e9" stroke-opacity="0.25" stroke-width="1"> - <animate attributeName="r" values="72;82;72" dur="3s" repeatCount="indefinite"/> - <animate attributeName="stroke-opacity" values="0.25;0.05;0.25" dur="3s" repeatCount="indefinite"/> - </circle> - <text x="140" y="192" text-anchor="middle" fill="white" font-size="11" font-weight="700" font-family="ui-monospace,monospace" letter-spacing="1.5">WARMBLY</text> - <text x="140" y="208" text-anchor="middle" fill="white" font-size="9" font-family="ui-monospace,monospace" opacity="0.75">event hub</text> - - <!-- Source label --> - <text x="140" y="290" text-anchor="middle" fill="#0369a1" font-size="10" font-family="ui-monospace,monospace" letter-spacing="2" font-weight="600">campaign.reply_received</text> - <text x="140" y="305" text-anchor="middle" fill="#64748b" font-size="9" font-family="ui-monospace,monospace">incoming event</text> - - <!-- Channels: bezier path + traveling dot per row --> - {fanoutChannels.map((ch) => ( - <g> - <path d={`M 200 196 C 380 196, 520 ${ch.y}, 700 ${ch.y}`} - fill="none" stroke="url(#channelGrad)" stroke-width="1.5"/> - <circle r="4.5" fill="#0ea5e9" opacity="0.95"> - <animateMotion dur="3.6s" repeatCount="indefinite" begin={ch.delay}> - <mpath href={`#ch-path-${ch.y}`}/> - </animateMotion> - </circle> - <circle r="9" fill="#0ea5e9" opacity="0.18"> - <animateMotion dur="3.6s" repeatCount="indefinite" begin={ch.delay}> - <mpath href={`#ch-path-${ch.y}`}/> - </animateMotion> - </circle> - </g> - ))} - - <!-- Provider end-cards on the right --> - {fanoutChannels.map((ch) => ( - <g> - <rect x="700" y={ch.y - 24} width="260" height="48" rx="10" - fill="white" stroke="#e2e8f0" stroke-width="1"/> - <circle cx="724" cy={ch.y} r="14" fill={ch.accent} fill-opacity="0.12" stroke={ch.accent} stroke-opacity="0.45"/> - <text x="724" y={ch.y + 4} text-anchor="middle" fill={ch.accent} font-size="11" font-weight="700" font-family="ui-monospace,monospace">{ch.letter}</text> - <text x="748" y={ch.y - 3} fill="#0f172a" font-size="12.5" font-weight="600">{ch.provider}</text> - <text x="748" y={ch.y + 13} fill="#475569" font-size="10.5" font-family="ui-monospace,monospace">{ch.action} · {ch.desc}</text> - - <!-- Receive pulse on each end-card --> - <circle cx={700 + 260} cy={ch.y} r="3.5" fill={ch.accent} opacity="0"> - <animate attributeName="opacity" values="0;0.9;0" dur="3.6s" repeatCount="indefinite" begin={ch.delay}/> - </circle> - </g> - ))} - </svg> - - <!-- Legend strip below --> - <div class="mt-4 flex flex-wrap items-center justify-center gap-x-6 gap-y-2 text-[11px] font-mono text-muted-foreground"> - <span class="inline-flex items-center gap-1.5"> - <span class="w-1.5 h-1.5 rounded-full bg-[#0ea5e9]"></span> - event packet - </span> - <span class="w-1 h-1 rounded-full bg-[color:var(--border)]"></span> - <span>one Warmbly event triggers every subscribed channel</span> - <span class="w-1 h-1 rounded-full bg-[color:var(--border)]"></span> - <span>delivery in ~2s</span> - </div> - </div> - - <!-- Footer --> - <div class="px-6 py-3 border-t border-[color:var(--border)] bg-[color:var(--surface-1)]/60 flex items-center justify-between text-[11px] font-mono text-muted-foreground"> - <span>12 providers · 18 event types</span> - <span class="inline-flex items-center gap-1.5 text-foreground/55"> - <span class="w-1.5 h-1.5 rounded-full bg-emerald-500"></span> - signed, retried, audited - </span> - </div> - </div> - </div> - </section> - <!-- HOW IT WORKS --> <section class="border-y border-[color:var(--border)] py-20 md:py-28"> <div class="container-page"> From 1e2577ce972caa958e43024ca7f58285d06aed9d Mon Sep 17 00:00:00 2001 From: Matt <meszmatew@gmail.com> Date: Thu, 28 May 2026 09:53:43 +0200 Subject: [PATCH 13/13] ci(go): gofmt integration files --- cmd/backend/main.go | 2 +- internal/app/integration/calendly.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cmd/backend/main.go b/cmd/backend/main.go index 33ecd80d..282e906d 100644 --- a/cmd/backend/main.go +++ b/cmd/backend/main.go @@ -32,6 +32,7 @@ import ( "github.com/warmbly/warmbly/internal/app/feature" "github.com/warmbly/warmbly/internal/app/fleet" "github.com/warmbly/warmbly/internal/app/group" + "github.com/warmbly/warmbly/internal/app/integration" "github.com/warmbly/warmbly/internal/app/organization" "github.com/warmbly/warmbly/internal/app/releases" "github.com/warmbly/warmbly/internal/app/sequence" @@ -45,7 +46,6 @@ import ( "github.com/warmbly/warmbly/internal/app/tz" "github.com/warmbly/warmbly/internal/app/unibox" "github.com/warmbly/warmbly/internal/app/user" - "github.com/warmbly/warmbly/internal/app/integration" warmupapp "github.com/warmbly/warmbly/internal/app/warmup" "github.com/warmbly/warmbly/internal/app/webhook" "github.com/warmbly/warmbly/internal/app/worker" diff --git a/internal/app/integration/calendly.go b/internal/app/integration/calendly.go index aa00cd02..e1bbbd39 100644 --- a/internal/app/integration/calendly.go +++ b/internal/app/integration/calendly.go @@ -45,8 +45,8 @@ type CalendlyPayload struct { type CalComPayload struct { TriggerEvent string `json:"triggerEvent"` Payload struct { - Type string `json:"type"` - Title string `json:"title"` + Type string `json:"type"` + Title string `json:"title"` StartTime time.Time `json:"startTime"` Attendees []struct { Email string `json:"email"`