Skip to content

feat(api): add zoho sign-in and zoho mail mailbox sync - #211

Draft
WifiDan wants to merge 2 commits into
trycompai:mainfrom
WifiDan:feat/zoho-mailbox
Draft

feat(api): add zoho sign-in and zoho mail mailbox sync#211
WifiDan wants to merge 2 commits into
trycompai:mainfrom
WifiDan:feat/zoho-mailbox

Conversation

@WifiDan

@WifiDan WifiDan commented Sep 6, 2026

Copy link
Copy Markdown

Opening this as a draft, and leading with the why as CONTRIBUTING.md asks. There is a
matching ADR in the diff at adrs/zoho-mail.md — that file is the argument, this is the
summary.

Why

We run this CRM against a business whose mail is on Zoho, not Google Workspace or
Microsoft 365. Today that means the agent has nothing to read: the sales mailbox is on
Zoho, and the only mailbox the CRM can see belongs to a personal Gmail that isn't where
the business happens. Every company, contact and thread the product is meant to fill in
by itself gets typed in by hand instead.

Zoho Mail is the usual answer for a small company that wanted its own domain without
paying per seat, so this reads less like "support my setup" and more like the
second-tier mail host the CRM's premise quietly excludes.

We looked at generic IMAP and at pushing mail in from a side script first. Both are
argued and rejected in the ADR — short version: IMAP would be the only provider with no
OAuth story and no usable incremental cursor, and a side script leaves the mail outside
everything that makes the mailbox layer worth having (scope checks, purge, reconnect,
the connection card).

What this does

Follows #73 as closely as it deserves to be followed. The provider union in
packages/auth/src/scopes.ts and the satisfies Record<…> maps in
mailbox.constants.ts are already the shape a third provider slots into — the compiler
names every arm that needs filling, which is the property you want here. Every one of
those sites is in this diff.

Reused untouched: ThreadWriterService, MailboxMatchService, SyncStateService, and
the EmailThread/EmailMessage models. zohoMessageId and zohoWebLink sit beside
the Gmail and Outlook columns in one migration.

Four things about Zoho are genuinely not Graph, and they are where the code stops being
a copy of the Outlook adapter:

  • It is a generic OAuth provider, not a better-auth social one, so it goes through
    the same genericOAuth plugin Slack already uses. Its callback is
    /api/auth/oauth2/callback/zoho — note the extra segment. This also means it links
    onto an existing account rather than only being a sign-in, which is the case we
    actually have: sign in with Google, attach a Zoho mailbox on another domain.
  • Authorization: Zoho-oauthtoken <token>, not Bearer. MailboxApiClient grew
    one optional scheme argument; every existing caller is unchanged.
  • No "changed since" filter exists. /messages/view pages with start/limit over
    a date-sorted list, so the sync reads newest-first and stops at the first message the
    last tick already saw. The cursor is epoch milliseconds rather than an ISO string.
  • The message list carries no RFC Message-ID. Without one, the same mail seen
    through Gmail and through Zoho would be stored twice, so each new message costs a
    second call to /header. That is the main cost of this adapter and why its per-tick
    ceiling is 60 rather than Outlook's 120.

One refactor rode along, and I'd rather flag it than have it found: rebuildThreads was
duplicated verbatim in the Google and Microsoft connection services. Instead of adding a
third copy it moved to mailbox/thread-rebuild.ts and both existing callers import it.
Happy to drop that out if you'd rather it stayed local.

What it costs an install that never sets ZOHO_CLIENT_ID

Nothing. The provider isn't registered, the connection card says it isn't configured,
and no new query runs.

Tests

bun run check-types, lint, lint:slop and test are all green. New:
apps/api/test/zoho-mail-client.spec.ts (envelope unwrapping, the auth scheme, error
mapping, the schema refusing a shape it can't read) and
apps/api/test/zoho-sync.spec.ts (cursor behaviour, folder exclusion, paging by message
count, threading and dedup), plus Zoho arms on the existing scope and grant specs.

Two things the tests turned up that are worth knowing:

  • Zoho returns messageId on the /header and /content responses as a bare JSON
    number past Number.MAX_SAFE_INTEGER
    JSON.parse has already rounded it before
    any parser can see it. Those two schemas deliberately do not read it; the list
    endpoint returns the same id as a string and that is the copy everything uses. There's
    a test pinning that.
  • Zoho only issues a refresh token while the consent screen is up, hence
    access_type=offline + prompt=consent on the authorization URL. A connection that
    comes back without one can't be repaired in place, so the card asks for a disconnect
    and reconnect and says why.

Status: draft, and what's not done

Not yet exercised against a live Zoho tenant. The OAuth client for our domain needs
Zoho admin access we're in the middle of getting, so every call in here is written
against the published Zoho Mail API docs and covered by tests, not by a real round trip.
I'd rather say that plainly than let it read as verified. I'll take it out of draft once
it has actually synced a mailbox; happy to take review before then.


Summary by cubic

Adds Zoho Mail as a third mailbox provider alongside Google and Microsoft, so installs whose mail is on Zoho can sync email threads instead of typing them by hand. Also adds Zoho as a sign-in option. Installs that never set ZOHO_CLIENT_ID see no change.

What changed

  • Zoho goes through the generic OAuth plugin rather than better-auth's social providers; its callback is /api/auth/oauth2/callback/zoho and it links onto an existing account instead of only signing in.
  • MailboxApiClient gained an optional auth scheme; Zoho sends Zoho-oauthtoken instead of Bearer, and every existing caller is unchanged.
  • Zoho has no "changed since" filter, so sync pages newest-first and stops at the first message the last tick already saw, capped at 60 messages per tick.
  • Zoho message lists carry no RFC Message-ID, so each new message costs one /header call to avoid storing the same mail twice when it is also seen through Gmail or Outlook.
  • The rebuildThreads helper was duplicated in the Google and Microsoft connection services; it now lives in mailbox/thread-rebuild.ts and both import it.
  • A matching ADR at adrs/zoho-mail.md documents why generic IMAP and a side script were rejected.

Adoption notes

  • New env vars ZOHO_CLIENT_ID, ZOHO_CLIENT_SECRET, and ZOHO_REGION; set all three or none. Without them, the provider is not registered and the connection card says it isn't configured.
  • The migration adds zohoMessageId and zohoWebLink to emailMessage.
  • Zoho returns message ids as bare JSON numbers past Number.MAX_SAFE_INTEGER, so the /header and /content schemas deliberately ignore them and use the string copy from the list endpoint.
  • Zoho only issues a refresh token while the consent screen is open, so the authorization URL forces prompt=consent; a connection that comes back without one must be disconnected and reconnected.
  • Not yet exercised against a live Zoho tenant — every call is written against the published API docs and covered by tests. The PR comes out of draft once a real mailbox has synced.

Written for commit 66ecc63. Summary will update on new commits.

Review in cubic

@vercel

vercel Bot commented Sep 6, 2026

Copy link
Copy Markdown

@WifiDan is attempting to deploy a commit to the Comp AI - PoC Team on Vercel.

A member of the Team first needs to authorize it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant