Gmail integration (API v1)

Gmail Integration API (v1)

Guide for CLI tools and external integrators syncing Gmail with CustomDesigner / MioTees.

There are two related resources:

Resource Table Purpose
Thread context gmail_thread_contexts CRM cache: linked orders, clients, participant metadata for inbox UI
Email thread email_threads Full thread payload: subject, labels, participants, and all messages from Gmail

Use thread context when you need order/client matching. Use email threads when you are pushing mail content from the CLI into the database.


Prerequisites

  1. Developer API token — Settings → Developer → Generate (64 hex characters, shown once).
  2. Migration applied in Supabase:
    • migrations/20260604_gmail_thread_contexts.sql (thread context reads)
    • migrations/20260605_email_threads.sql (email thread upserts)
  3. Base URL
    • Production: https://<your-site>
    • Local: http://localhost:3000

All paths below are under /api/v1.


Authentication

Every request:

Authorization: Bearer <64_character_hex_token>
Content-Type: application/json
  • The token is scoped to one company (set when the token was created).
  • Do not send company_id in the body; the server assigns it from the token.
  • Errors use:
{
  "error": {
    "code": "unauthenticated",
    "message": "…",
    "details": null
  }
}

Common codes: unauthenticated, forbidden, invalid_request, not_found, rate_limited, internal_error.


1. Gmail thread context (read CRM cache)

GET /api/v1/gmail/threads/{legacyThreadId}

Returns a row from gmail_thread_contexts for a Gmail legacy thread id (the id your CLI/extension already uses).

Request

curl -sS "https://YOUR_SITE/api/v1/gmail/threads/LEGACY_THREAD_ID" \
  -H "Authorization: Bearer YOUR_TOKEN"
Parameter Location Required Description
legacyThreadId path yes Gmail thread id

No query parameters.

Response 200

{
  "thread_id": "…",
  "legacy_thread_id": "…",
  "sender_email": "customer@example.com",
  "client_id": "42",
  "client_data": { },
  "clients_data": [ ],
  "orders_data": [ ],
  "selected_order_id": "101",
  "participant_emails": [ "a@example.com", "b@example.com" ],
  "participants_data": [ ],
  "fetched_at": "2026-06-04T12:00:00.000Z",
  "updated_at": "2026-06-04T12:00:00.000Z"
}

Fields are stored as JSON in Supabase; shapes depend on what wrote the cache (extension, internal jobs, etc.).

Access control

Returns 404 if:

  • No row exists for that legacy_thread_id, or
  • The row is not tied to your company (via client_id, order ids in orders_data, or company_id inside cached JSON).

This prevents cross-tenant reads when the table has no company_id column.

Typical CLI flow

  1. User opens a Gmail thread in the CLI.
  2. CLI calls this endpoint with the thread’s legacy id.
  3. CLI uses orders_data / client_data / participants_data to show linked CRM records or suggest matches.

2. Email threads (upsert full thread + messages)

PUT /api/v1/gmail/email-threads/{legacyThreadId}

Creates or updates a row in email_threads for the authenticated company and mailbox.

Conflict key: (company_id, account_email, legacy_thread_id) — same legacyThreadId with a different account_email is a separate row.

Request body

Field Type Required Description
account_email string yes Gmail mailbox that was synced (normalized to lowercase)
messages array yes Message objects from Gmail (use [] if empty)
participants array no Default [] — people on the thread
labels array no Default [] — Gmail label ids/names
subject string no Thread subject
snippet string no Short preview for lists
gmail_thread_id string no Canonical Gmail thread id if different from legacy
first_message_at ISO string no Earliest message time
last_message_at ISO string no Latest message time
gmail_history_id string no For incremental sync
last_synced_at ISO string no Defaults to server now()

Example: upsert from CLI

curl -sS -X PUT "https://YOUR_SITE/api/v1/gmail/email-threads/LEGACY_THREAD_ID" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "account_email": "shop@yourcompany.com",
    "subject": "Re: Quote #4421",
    "snippet": "Thanks, can we add 12 more shirts?",
    "participants": [
      { "email": "customer@example.com", "name": "Jane Customer" },
      { "email": "shop@yourcompany.com", "name": "Your Shop" }
    ],
    "labels": ["INBOX", "IMPORTANT"],
    "messages": [
      {
        "id": "MSG_ID_1",
        "internal_date": "2026-06-01T14:22:00.000Z",
        "from": "customer@example.com",
        "to": ["shop@yourcompany.com"],
        "subject": "Quote #4421",
        "snippet": "Hi, we need 48 tees…",
        "body_plain": "Hi, we need 48 tees…",
        "body_html": "<p>Hi, we need 48 tees…</p>"
      },
      {
        "id": "MSG_ID_2",
        "internal_date": "2026-06-04T09:15:00.000Z",
        "from": "customer@example.com",
        "to": ["shop@yourcompany.com"],
        "subject": "Re: Quote #4421",
        "snippet": "Thanks, can we add 12 more shirts?",
        "body_plain": "Thanks, can we add 12 more shirts?",
        "body_html": null
      }
    ],
    "first_message_at": "2026-06-01T14:22:00.000Z",
    "last_message_at": "2026-06-04T09:15:00.000Z",
    "gmail_history_id": "987654",
    "gmail_thread_id": "LEGACY_THREAD_ID"
  }'

Response 200

{
  "email_thread": {
    "id": "uuid",
    "account_email": "shop@yourcompany.com",
    "legacy_thread_id": "LEGACY_THREAD_ID",
    "gmail_thread_id": "LEGACY_THREAD_ID",
    "subject": "Re: Quote #4421",
    "snippet": "Thanks, can we add 12 more shirts?",
    "participants": [ ],
    "labels": [ "INBOX", "IMPORTANT" ],
    "messages": [ ],
    "message_count": 2,
    "first_message_at": "2026-06-01T14:22:00.000Z",
    "last_message_at": "2026-06-04T09:15:00.000Z",
    "gmail_history_id": "987654",
    "last_synced_at": "2026-06-04T10:00:00.000Z",
    "created_at": "2026-06-04T09:00:00.000Z",
    "updated_at": "2026-06-04T10:00:00.000Z"
  }
}

message_count is derived from messages.length in the database trigger.

Upsert semantics

  • First call for (company, account_email, legacy_thread_id) → insert.
  • Repeat calls → update the same row (full replace of JSON arrays and scalar fields you send).
  • deleted is set back to false on upsert (restore after soft-delete, if you add that later in-app).
  • Send the complete messages array each sync; omitted keys are not merged field-by-field.

Read back (optional)

GET /api/v1/gmail/email-threads/{legacyThreadId}?account_email=shop@yourcompany.com

curl -sS -G "https://YOUR_SITE/api/v1/gmail/email-threads/LEGACY_THREAD_ID" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  --data-urlencode "account_email=shop@yourcompany.com"

Returns the same { "email_thread": { … } } wrapper. 404 if not found for that company + mailbox + thread id.


Suggested message object shape (CLI)

The API does not validate per-message schema; store consistent objects for your own tooling.

The Messages tab parser expects:

Field Type Notes
id string Gmail message id (required)
sent_at ISO string Preferred timestamp (internal_date is accepted by tooling but not shown in UI)
from { name?, email? } Object form preferred for the Messages UI
to { name?, email? }[] Object form preferred
subject string Optional per message
snippet string Short preview
body_plain / body_text string Plain body
is_from_us boolean Aligns bubble to the right when true
attachments array See below

Attachment objects

Field Type Notes
filename string Required for the chip to render
mime_type string Optional
size_bytes number Optional
storage_key string R2 object key after upload (makes the chip downloadable)
gmail_attachment_id string Optional; use for idempotent matching when merging keys

Example after R2 sync:

{
  "filename": "Tiger.jpg",
  "mime_type": "image/jpeg",
  "size_bytes": 245760,
  "storage_key": "orders/{companyId}/{orderId}/email/{legacyThreadId}/Tiger.jpg",
  "gmail_attachment_id": "ANGjdJ8…"
}

3. Order file upload (Gmail attachments → R2)

Integrators (e.g. openclaw) upload attachment bytes into the same Cloudflare R2 store used by the order Files tab. Do not put R2 credentials on the CLI — use the Developer API token and presigned PUTs.

Resolve target order

  1. GET /api/v1/gmail/threads/{legacyThreadId}
  2. Require orders_data with at least one order (existing guardrail).
  3. Target order id:
    • selected_order_id if set, else
    • the sole orders_data[0].id when orders_data.length === 1, else
    • skip (multi-order thread without a selection).

Local attachment dirs that are not in gmail_thread_contexts are intentionally unlinked — skip them.

Presign

POST /api/v1/orders/{orderId}/files/presign

curl -sS -X POST "https://YOUR_SITE/api/v1/orders/1383/files/presign" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "fileName": "Tiger.jpg",
    "contentType": "image/jpeg",
    "size": 245760,
    "legacyThreadId": "19f38fde180ceb36"
  }'
Field Required Description
fileName yes Basename used in the R2 key
contentType no Defaults to application/octet-stream
size yes Bytes; max 150 MB
legacyThreadId yes Gmail legacy thread id (folder name under .attachments/)

Response:

{
  "uploadUrl": "https://…r2.cloudflarestorage.com/…",
  "key": "orders/{companyId}/1383/email/19f38fde180ceb36/Tiger.jpg",
  "contentType": "image/jpeg",
  "expiresIn": 600
}

Then PUT the raw file bytes to uploadUrl with matching Content-Type (and Content-Length when required by the signer).

Object key (stable / idempotent overwrite):

orders/{companyId}/{orderId}/email/{legacyThreadId}/{safeBasename}

List (idempotency)

GET /api/v1/orders/{orderId}/files?legacyThreadId={legacyThreadId}

Returns { files: [{ path, objectName, length, lastChanged, source }], configured }.
source is "email" when the key contains /email/, otherwise "manual".
Omit legacyThreadId to list the whole order prefix.

Merge keys into the email thread

After uploads, PUT /api/v1/gmail/email-threads/{legacyThreadId} with the full messages array, setting attachments[].storage_key to the returned key (match by gmail_attachment_id when present, else filename).

Suggested openclaw cadence

  • Primary: after a successful email-thread write when keith/.attachments/{legacyThreadId}/ is non-empty.
  • Catch-up: daily cron over all attachment folders (cheap skip for unlinked / already listed in R2).
  • Default dry-run; production writes only with --apply.
  • Same eligibility rules as thread sync (client-com labels, linked order context required).

In-app result:

  • Files tab lists the objects (with an Email badge).
  • Messages chips become download links when storage_key is set.

End-to-end CLI workflow

  1. Pull CRM contextGET …/gmail/threads/{legacyThreadId} to show linked orders/clients.
  2. Push mail snapshotPUT …/gmail/email-threads/{legacyThreadId} after fetching thread/messages from Gmail API.
  3. Upload attachments — for each local file under .attachments/{legacyThreadId}/, presign → PUT → merge storage_key.
  4. Verify (optional) — GET …/gmail/email-threads/… and GET …/orders/{orderId}/files?legacyThreadId=….

Rate limits and docs

  • ~300 requests/minute per API key.
  • Interactive OpenAPI: /docs/api/explorer
  • OpenAPI YAML: /openapi.yaml

File Role
server/api/v1/gmail/threads/[legacyThreadId].get.ts Thread context read
server/api/v1/gmail/email-threads/[legacyThreadId].put.ts Email thread upsert
server/api/v1/gmail/email-threads/[legacyThreadId].get.ts Email thread read
server/api/v1/orders/[id]/files/presign.post.ts Attachment upload presign
server/api/v1/orders/[id]/files/index.get.ts List order / email R2 files
migrations/20260604_gmail_thread_contexts.sql Context table
migrations/20260605_email_threads.sql Email threads table