You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Plain (plain.com) integration: sync support threads as tasks #6470
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.
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:
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.
Activity
github-actions commentedon Aug 14, 2026
Summary
This is a feature request (not a bug): add
plainas a fourth integration provider so Plain support threads sync intotasksas 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:
packages/db/src/schema/enums.ts:26apps/api/src/app/api/integrations/linear/callback/route.tsapps/api/src/app/api/integrations/linear/jobs/initial-sync/route.ts.../jobs/initial-sync/syncWorkflowStates.tsapps/api/src/app/api/integrations/linear/webhook/route.tspackages/trpc/src/router/integration/linear/linear.tsapps/web/src/app/(dashboard-legacy)/integrations/linear/page.tsx.../settings/integrations/components/IntegrationsSettings/IntegrationsSettings.tsx:122Findings
1. There is nowhere to put a per-org webhook secret, and
configis 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 fitsintegrationConnections.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 — theconfigjsonb atschema.ts:207— leaks it:integration.listselectsconfig: trueand is gated byverifyOrgMembership, not admin (packages/trpc/src/router/integration/integration.ts:22-33), so every org member would receive it. Suggest a dedicated nullablewebhookSecretcolumn and keepingPlainConfiginpackages/db/src/schema/types.tsnon-secret. Note also thataccessTokenis stored plaintext today (no encryption helper exists inpackages/), 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:35parses and verifies against the global secret first, then looks up connections bypayload.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 indexintegration_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.provideris the sameintegration_providerenum (packages/db/src/schema/ingest.ts:21), so it's covered by the enum migration, and theeventIddedupe key atwebhook/route.ts:92needs 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.typeis 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 allexternalProvider IS NULLstatuses and remaps local tasks onto Linear ones (initial-sync/route.ts:114-123), and Linear'sdisconnectre-seeds defaults and deletes alllinearstatuses (linear.ts:50-118). Connecting/disconnecting Plain and Linear in either order needs to not clobber the other's statuses.Related:
tasks.slugis unique per org (tasks_org_slug_unique) and Linear fills it withissue.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 withgenerateUniqueTaskSlugoutput from locally created tasks.4. The "Plain source tab" needs a server-side filter, not just a tab.
TaskSourceis"tasks" | "issues"(.../TasksTopBar/TasksTopBar.tsx:36), andbuildTaskListFilters(packages/trpc/src/router/task/task.ts:319-330) has noexternalProvidercondition. Without adding one totaskListInputSchemaand 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_ENDPOINTSinpackages/trpc/src/lib/integrations/sync/tasks.ts:9-11only mapslinearand 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.tsis the existing precedent for webhook-routebun:testcoverage. Worth covering:taskswebhookEventsrow and one task (theonConflictDoUpdateatwebhook/route.ts:103-110)integration.listcalled as a non-admin member returns no secret material for a Plain connectiontask.listwith the Plain provider filter excludes Linear tasks and vice versaSuggested 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 tointegration_providertouchestasks,task_statuses, andingest.webhook_events, and per AGENTS.mdpackages/db/drizzle/shouldn't be hand-edited; (2) connect + backfill + webhook behind the tRPC router, with the connect mutation usingverifyOrgAdminlikelinear.disconnectandlinear.updateConfigdo (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.
feat(integrations): sync Plain support threads as tasks
feat(integrations): sync Plain support threads as tasks
feat(integrations): sync Plain support threads as tasks
feat(integrations): sync Plain support threads as tasks
feat(integrations): sync Plain support threads as tasks
feat(integrations): sync Plain support threads as tasks
feat(integrations): sync Plain support threads as tasks
feat(integrations): sync Plain support threads as tasks