Changelog

0.6.0 — SMS becomes a native app on live PitchPrfct messaging, dial email tab searches all mail

SMS graduates from a hidden three-panel canvas widget to a first-class native app — full-height inbox rail with search + All/Unread/Starred tabs, thread pane, service picker, and real-time messages via a HMAC-verified PitchPrfct webhook → Supabase Realtime pipeline. Dial's email tab now searches all mail (not just inbox), incoming email no longer auto-marks itself read on arrival, and dialer SMS sends reuse the conversation's existing number instead of thrashing the phone-numbers API.

What's new

SMS: native app + live PitchPrfct messaging

SMS graduates from the hidden three-panel canvas widget to a first-class native app (kind: 'native') that mounts the way Dial, Email, and Calendar do: opening SMS from the sidebar mounts a full-bleed SmsWorkspace — no canvas placement step, no widget to drag.

  • Full-height inbox rail (SmsInboxRail) layered over the header like the dialer's contact-card sidebar and drag-resizable. Debounced server-side search across name / phone / snippet, plus All / Unread / Starred tabs (server-side search / isRead / starred filters with an optimistic client mirror). Unread dots, hover star toggles, and layout-matched SkeletonRegion loading states.
  • Paginated conversations. 25 conversations per page via useInfiniteQuery, infinite-scroll with a Load More fallback — replacing the single 50-row pull that took 20 s+ on a heavy account.
  • Service picker — a shared smsProviders.jsx catalog (PitchPrfct active, Project88 coming soon) drives both this app and the dialer's SMS tab. TextDrip is removed.
  • The smsInboxWidget canvas widget shares the same internals and remains addable to any board via smsWidgets — so a canvas you already dropped the inbox on keeps rendering.

See SMS app and Dial → SMS tab.

Live messages: PitchPrfct → webhook → Supabase Realtime

The SMS surface no longer waits on a poll to see a new inbound message. Deliveries fan out end-to-end via a webhook pipeline that mirrors the Gmail push architecture:

  • PitchPrfct webhook → pitchprfct-webhook edge function → sms_events (migration 158) → Supabase Realtime → cache invalidation in useSmsRealtime.
  • HMAC-SHA256 verified on {timestamp}.{body} with a replay window, plus a per-integration URL token. Events are idempotent on delivery id; 30-day retention.
  • Auto-registration. The webhook registers itself the first time an operator opens the SMS surface, via a JWT-authed register action that resolves the user's vault API key and idempotently creates or adopts the PitchPrfct webhook (the message.* events). Verified in prod: real signed deliveries land in sms_events and drive the UI within ~1 s.
  • Polling demoted to fallback — thread 20 s, inbox 60 s. The dial widget's SMS tab mounts the realtime hook too, so it benefits from push updates on the same wire.

See PitchPrfct → Webhooks.

Performance: measured against a heavy account

  • Thread open skips the phone → contact lookup when the row already carries contactUuid (1 upstream call instead of 2), plus a 150 ms hover-intent prefetch so clicks paint from cache most of the time.
  • Paginated inbox — see above.
  • Transient upstream 5xx retry. PitchPrfct's /conversations intermittently hangs and recovers; one retry on 5xx / timeout (never on 401 / 429) removes the "loading forever" state. pitchprfct-api now logs the upstream 5xx path so the pattern is visible in edge logs.

Client / API corrections

While wiring live messages we also fixed three off-spec calls in the typed PitchPrfct client:

  • conversations.update now exists (PATCH {isRead}).
  • bulkUpdate moved to POST per the upstream OpenAPI spec.
  • Star is contact-level (PATCH /contacts/{uuid} {starred}) — it's a per-contact flag, not a per-conversation one.

Fixes

Dial → Email tab: search all mail, not just the inbox

The dial widget's Email tab was showing "No emails" for leads that had been emailed but hadn't replied. Root cause: the lead-participant search (from:X OR to:X) called the list API without a folder, and email-api's handleList defaulted to the inbox folder — labelIds=INBOX on Gmail, mailFolders/inbox on Graph. Every email sent to a lead lives in Sent, so leads who hadn't replied (the common dial case) showed an empty tab.

  • New folder mode "all". Gmail drops the label restriction (plain q= search covers all mail; spam/trash excluded by default), Graph queries /me/messages across folders. useDialEmailForLead passes folder: 'all'.
  • Graph $orderby + $search conflict fixed. Graph rejects the combination with a 400 — any Outlook account would have errored on this tab. With a query present, $orderby is now omitted (the client already re-sorts by timestamp).
  • List-URL construction extracted to pure helpers (buildGmailListUrl / buildMsListUrl) in email-api/lib.ts, with Deno tests pinning the folder scoping and $search / $orderby rules.

email-api deployed to prod as v18; deployed source is byte-identical to this branch.

See Dial → Email tab.

Inbox: incoming email no longer auto-marks itself read

Every arriving message was silently consumed as if the operator had opened it. Root cause: fetchEmails() auto-selected the newest email on every fetch, and the realtime email_events subscription refetches on every arrival. Auto-selection triggered the mark-read effects, so mail was marked read the moment it landed — app-wide, since EmailProvider mounts above every surface.

The email store now tracks selection source:

  • Only user clicks (setActiveEmailId, which every widget row uses) mark the message + its thread read.
  • Code-driven auto-selection never marks read.
  • Refetches preserve the current selection instead of hijacking it to the newest message, so the reader no longer jumps on arrivals.
  • Clicking the already-auto-selected row still consumes unread state — the selection nonce re-fires the effects.

See Inbox.

Dial SMS: send from the number the conversation lives on

Dial SMS sends had been failing since Aug 9 when PitchPrfct silently revoked GET /api/v1/phone-numbers access for existing API keys (403 Forbidden; all other endpoints unaffected). Every send resolved fromNumber through that endpoint first, so the POST /api/v1/messages never fired — the composer just showed "Tap to retry".

fromNumber resolution is now, in order:

  1. localStorage override — unchanged power-user pin.
  2. The number the conversation already lives on — new deriveConversationNumber reads the latest outbound message's fromNumber (else an inbound message's toNumber) from the already-fetched history. Zero extra round trips, and replies stay on the same thread on the lead's phone instead of hopping numbers between sends.
  3. Phone-numbers API (new leads only), hardened: pickSendingNumber now skips enabled: false rows (released numbers stay listed with disabledReason: "user_deleted") and prefers default-flagged, then warmed-up numbers. The live response has no isDefault field, so the old code always picked whatever row came back first.

Side effect: since contacts / messages endpoints still work with old keys, threads with any history send fine even before the API key is rotated — only first-texts to brand-new leads need the fresh key.

See PitchPrfct → Sending.

Billing realtime cleanup no longer crashes on unmount

Uncaught (in promise) TypeError: Cannot read properties of null (reading 'unsubscribe') fired from subscribeWithRetry cleanup in src/store/billing.jsx. The useEffect cleanup nulled the captured channel variable synchronously, before the async getSupabase().then((sb) => sb?.removeChannel(channel)) closure ran, so supabase-js received null and crashed. The channel is now captured in a local const before nulling. Audited every other removeChannel site — none reassigned before the async removal.

Workspace skeletons pulse and read as loading again

Email, Data, and Calendar workspace skeletons rendered static (no motion-safe:animate-pulse) because they used bare Skeleton leaves without the SkeletonRegion wrapper that owns the animation. Each root now uses <SkeletonRegion label="Loading <app>">, matching the existing dial / automation / SMS pattern. Adds the ARIA loading semantics (role="status", aria-busy, SR-only label) these three surfaces were missing.

Under the hood

  • sms_events and sms_webhook_state (migration 158, sms_realtime) — user-scoped realtime toast feed, unique per (integration_id, delivery_id), added to the supabase_realtime publication with 30-day retention.
  • pitchprfct-webhook (new, verify_jwt=false — token + HMAC is its auth) and pitchprfct-api (5xx logging) — both deployed to prod.
  • useSmsRealtime subscribes each open SMS surface to sms_events and drives targeted cache invalidations (conversation list, thread) — same pattern as the Gmail push wire.
  • smsProviders.jsx — shared catalog wired into both the SMS app's picker and the dialer SMS tab's picker.
  • SmsWorkspace / SmsInboxRail — the native surface shell plus the full-height inbox rail with tabs, search, pagination, and star toggles.
  • email-api v18 deployed — folder="all" mode, Gmail vs Graph URL construction pulled to pure helpers with unit tests.
  • src/store/email.jsx — selection source (user vs auto) is now first-class in the store; refetches preserve selection.

What's next

  • The Project88 SMS provider card lights up when the first-party SMS lane ships — same picker, same inbox, no data migration.
  • Microsoft Graph push notifications for real-time inbox on Outlook accounts (Gmail already lands via gmail-webhook).
  • Retire the poll fallback on the SMS surface once webhook uptime hits parity with Gmail push.

See SMS app, PitchPrfct integration, and Dial → SMS / Email tabs.

On this page