Skip to content

Plain (plain.com) integration: sync support threads as tasks #6470

Open

Description

@tobi110289

What problem are you trying to solve?

Teams that run customer support in Plain triage threads there, then re-type the actionable ones into Linear or Superset by hand before an agent can pick them up. Superset integrates Linear, GitHub, and Slack today, but there is no way to connect a Plain workspace, so support-driven work never reaches Superset tasks or agent workspaces.

Proposed solution

Add a Plain integration that mirrors the Linear integration's architecture (same shape as #1027 did for Slack):

  • Connect: per-org API key. Plain has no third-party OAuth, so /integrations/plain in the web app takes an API key (and optional webhook secret) instead of an OAuth redirect. Stored in integration_connections with provider plain; the Plain workspace id/name land in external_org_id/external_org_name.
  • Initial sync: backfill recent threads through Plain's GraphQL API into tasks (external_provider = 'plain'). Plain thread statuses (Todo / Snoozed / Done) become task_statuses rows; priorities map to the existing task priority enum; the thread permalink becomes external_url.
  • Webhook: POST /api/integrations/plain/webhook, HMAC-verified, keeps threads in sync on create/update/status change/delete.
  • Desktop: a Plain row in Settings → Integrations, and a "Plain" source tab in the Tasks view so support threads don't mix into the Linear tab.

Out of scope for a first PR: outbound sync (Superset → Plain) and replying to threads from Superset. Those can follow once the read path is in.

I have a PR for this ready to open that follows the structure above.

Workarounds you've tried

Driving Plain's API from agent tooling (an MCP server plus skills) outside the product. It works for one person, but gives an org no shared connection, no synced tasks, and nothing in the UI.

Activity

  1. github-actions commented on Aug 14, 2026

    @github-actions
    Contributor

    Summary

    This is a feature request (not a bug): add plain as a fourth integration provider so Plain support threads sync into tasks as read-only, with an API-key connect flow, an initial GraphQL backfill, an HMAC-verified webhook, and desktop surfaces in Settings → Integrations and the Tasks view. I read the Linear and Slack integrations to see how well the proposed shape fits the existing architecture. The overall plan maps cleanly onto what's already here — the read path is genuinely the same shape as Linear's — but four things in the current code don't generalize the way the proposal assumes, and they're worth settling before the PR lands rather than in review.

    Where this lands today

    The provider architecture is consistent across all three existing integrations, so the file map is predictable:

    Concern Linear (precedent)
    Provider enum packages/db/src/schema/enums.ts:26
    Connect / callback apps/api/src/app/api/integrations/linear/callback/route.ts
    Initial sync job apps/api/src/app/api/integrations/linear/jobs/initial-sync/route.ts
    Status backfill .../jobs/initial-sync/syncWorkflowStates.ts
    Webhook apps/api/src/app/api/integrations/linear/webhook/route.ts
    tRPC router packages/trpc/src/router/integration/linear/linear.ts
    Web settings page apps/web/src/app/(dashboard-legacy)/integrations/linear/page.tsx
    Desktop row .../settings/integrations/components/IntegrationsSettings/IntegrationsSettings.tsx:122

    Findings

    1. There is nowhere to put a per-org webhook secret, and config is the wrong place.

    Every existing webhook secret is a single app-level env var (LINEAR_WEBHOOK_SECRET, GH_WEBHOOK_SECRET, SLACK_SIGNING_SECRET — apps/api/src/env.ts:19-27), because all three providers are OAuth/app installs with one app-wide signing key. Plain's per-org key is the new case. The API key itself fits integrationConnections.accessToken (packages/db/src/schema/schema.ts:197, notNull, so it's a natural home), but the optional webhook secret has no column. The obvious-looking spot — the config jsonb at schema.ts:207 — leaks it: integration.list selects config: true and is gated by verifyOrgMembership, not admin (packages/trpc/src/router/integration/integration.ts:22-33), so every org member would receive it. Suggest a dedicated nullable webhookSecret column and keeping PlainConfig in packages/db/src/schema/types.ts non-secret. Note also that accessToken is stored plaintext today (no encryption helper exists in packages/), so an API key inherits that; consistent with current practice, but worth a deliberate call.

    2. The webhook verify-then-route order inverts.

    linear/webhook/route.ts:35 parses and verifies against the global secret first, then looks up connections by payload.organizationId (lines 37-44). With a per-connection secret you must read the workspace id out of an unverified body to find the secret, then verify — so the lookup has to treat that id strictly as a hint and fail closed when no active connection matches. The partial unique index integration_connections_slack_external_org_active_unique (schema.ts:220-224) is the precedent for guaranteeing one active connection per external workspace; Plain will want the same, otherwise "which secret do I verify against" is ambiguous. Also: webhookEvents.provider is the same integration_provider enum (packages/db/src/schema/ingest.ts:21), so it's covered by the enum migration, and the eventId dedupe key at webhook/route.ts:92 needs a Plain equivalent that's actually stable per delivery.

    3. Statuses are org-global, which makes Todo/Snoozed/Done more invasive than it looks.

    taskStatuses.type is the fixed vocabulary "backlog" | "unstarted" | "started" | "completed" | "canceled" (schema.ts:66) and rows are unique per (org, provider, externalId) — but they are not scoped per provider in the UI, so Linear and Plain statuses would land side by side in the same board columns. Snoozed has no clean member of that vocabulary. Two ordering hazards: Linear's initial sync deletes all externalProvider IS NULL statuses and remaps local tasks onto Linear ones (initial-sync/route.ts:114-123), and Linear's disconnect re-seeds defaults and deletes all linear statuses (linear.ts:50-118). Connecting/disconnecting Plain and Linear in either order needs to not clobber the other's statuses.

    Related: tasks.slug is unique per org (tasks_org_slug_unique) and Linear fills it with issue.identifier (webhook/route.ts:250). Plain threads have no human key, so Plain needs its own slug scheme that can't collide with Linear identifiers or with generateUniqueTaskSlug output from locally created tasks.

    4. The "Plain source tab" needs a server-side filter, not just a tab.

    TaskSource is "tasks" | "issues" (.../TasksTopBar/TasksTopBar.tsx:36), and buildTaskListFilters (packages/trpc/src/router/task/task.ts:319-330) has no externalProvider condition. Without adding one to taskListInputSchema and the filter builder, Plain threads land in the existing Tasks table mixed with Linear issues — exactly what the issue says it wants to avoid.

    One thing that does just work: outbound sync degrades safely. PROVIDER_ENDPOINTS in packages/trpc/src/lib/integrations/sync/tasks.ts:9-11 only maps linear and returns { skipped: true } for anything else, so a read-only first PR needs no change there.

    Reproduction / verification

    There's no bug to reproduce, so the useful bar for the PR is tests. apps/api/src/app/api/integrations/slack/events/route.test.ts is the existing precedent for webhook-route bun:test coverage. Worth covering:

    • a Plain webhook with a valid HMAC updates the task; a tampered body and a body naming an unknown/disconnected workspace both 401 without touching tasks
    • replaying the same delivery id twice yields one webhookEvents row and one task (the onConflictDoUpdate at webhook/route.ts:103-110)
    • integration.list called as a non-admin member returns no secret material for a Plain connection
    • connect Linear then Plain, and Plain then Linear, and assert both providers' statuses survive both orders and after disconnecting one
    • task.list with the Plain provider filter excludes Linear tasks and vice versa

    Suggested direction

    Land it as three PRs rather than one: (1) the enum migration plus the secret-storage column, generated through .agents/skills/db-migrations/SKILL.md — adding a value to integration_provider touches tasks, task_statuses, and ingest.webhook_events, and per AGENTS.md packages/db/drizzle/ shouldn't be hand-edited; (2) connect + backfill + webhook behind the tRPC router, with the connect mutation using verifyOrgAdmin like linear.disconnect and linear.updateConfig do (there's no OAuth callback to carry a signed state here, so the admin check is the only gate); (3) the desktop row and the provider-filtered tab. Settling the status-mapping question in (1) is what unblocks the rest.

    A maintainer should confirm the Plain-side specifics I couldn't verify from this repo — GraphQL pagination for the backfill, the webhook signature header and signing scheme, and whether the payload reliably carries a workspace id and a stable delivery id — since finding #2 depends on that last one.

  2. added 7 commits that reference this issue on Aug 17, 2026
    304290d
    b98e8db
    be6eab7
    030da14
    a37da45
    dd0a280
    80f4ccd
  3. added a commit that references this issue on Sep 10, 2026
    f5a93bd
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions