Skip to content
This repository was archived by the owner on Sep 15, 2026. It is now read-only.

Repository files navigation

Example Plain Attachments Portal

Minimal Next.js example that shows how to upload, send, and render image attachments in Plain chat and email threads from a headless customer portal.

This is the pattern to use when building a headless portal that needs two-way image support between customers and agents.

Demo only. There is no authentication. Anyone who can reach the app can list threads and send messages as the configured customer. Do not deploy this as-is.

Helpful reading first:

What this demonstrates

  1. createAttachmentUploadUrl → browser multipart upload to S3 → sendCustomerChat / replyToEmail with attachmentIds
  2. Polling a thread timeline for ChatEntry and EmailEntry attachments
  3. createAttachmentDownloadUrl, including virus-scan gating before rendering
  4. Recovering inline email images from markdownContent when EmailEntry.attachments is empty
  5. Listing a customer’s threads and creating a new chat thread from the UI

What to do differently in production

  • Authenticate users and derive the customer (and allowed threads) from your session — not from env vars shared by everyone
  • Scope every API route to the logged-in customer; validate attachment and thread ownership
  • Prefer webhooks or server-sent events over short polling for agent replies
  • Paginate long timelines

Prerequisites

  1. A Plain workspace with GraphQL API access (headless portal entitlement for sendCustomerChat)
  2. A machine user + API key with:
attachment:create,attachment:download,chat:create,customer:read,email:create,email:read,thread:create,thread:read,timeline:read
  1. An existing customer ID (c_…). Email reply testing also requires email to be enabled in the workspace.

Setup

cp .env.local.example .env.local
# Fill in PLAIN_API_KEY and PLAIN_CUSTOMER_ID
npm install
npm run dev

Open http://localhost:3000.

Optional env vars:

Variable Purpose
PLAIN_THREAD_ID Preselect a thread in the sidebar
PLAIN_API_URL Override the GraphQL endpoint if your workspace is not on the default UK host

Architecture

Browser                     Next.js API                     Plain                         S3
   |                             |                            |                            |
   |-- POST /upload-url -------->|-- createAttachmentUploadUrl ->|                            |
   |<-- form URL + fields -------|<-----------------------------|                            |
   |-- multipart POST file --------------------------------------------------------------->|
   |-- POST /messages/send ----->|-- sendCustomerChat / replyToEmail ---------------------->|
   |-- GET  /timeline ---------->|-- thread { timelineEntries } --------------------------->|
   |-- POST /download-url ------>|-- createAttachmentDownloadUrl -------------------------->|
   |<-- short-lived URL ---------|<-----------------------------|                            |
   |-- GET image <---------------------------------------------------------------------------|

All GraphQL calls stay on the server. The browser only talks to your Next.js routes (and to Plain’s presigned S3 upload/download URLs).

API routes

Route Plain operation
POST /api/attachments/upload-url createAttachmentUploadUrl
POST /api/attachments/download-url createAttachmentDownloadUrl
POST /api/messages/send sendCustomerChat or replyToEmail
GET /api/threads threads filtered by customer
POST /api/threads/create createThread + opening sendCustomerChat
GET /api/threads/[threadId]/timeline thread + timelineEntries

Inline email images

Email entries often return attachments: [] for pasted/inline images, with a text placeholder like [image: screenshot.png].

Plain still embeds the attachment ID in markdownContent:

![screenshot.png](data:application/json;base64,eyJhdHRhY2htZW50SWQiOiJhdHRfLi4uIn0=)

This example decodes that payload and then uses createAttachmentDownloadUrl as usual. See attachmentsFromEmailMarkdown in lib/plain.ts.

Project layout

app/api/          # BFF routes (API key never reaches the browser)
components/       # Thread list + chat UI
lib/attachments.ts  # Shared types + isImageAttachment (safe for client)
lib/plain.ts        # Server-only GraphQL client + timeline helpers

Troubleshooting

Symptom Likely cause
Mutation error naming a permission Add the missing scope to the API key
workspace_email_not_enabled Enable email in workspace settings before testing replyToEmail
Image stuck on “Scanning…” Wait for attachmentVirusScanResult: CLEAN
Warning about [image: …] with no ID markdownContent had no recoverable attachment ref

Security

  • Keep PLAIN_API_KEY in server env only (.env.local is gitignored)
  • Never commit real API keys or customer IDs

License

MIT

About

Minimal Next.js example for Plain chat/email attachments in a headless portal

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages