From 3c5f9194ddb9a4d07dbb9b02f519e83e342fe3d6 Mon Sep 17 00:00:00 2001 From: WifiDan Date: Sat, 5 Sep 2026 18:37:01 -0600 Subject: [PATCH 1/2] feat(api): add zoho sign-in and zoho mail mailbox sync --- apps/api/src/app.module.ts | 2 + apps/api/src/config/env.validation.ts | 13 + apps/api/src/generated/server.ts | 19 + apps/api/src/google/conversation.service.ts | 7 +- .../src/google/google-connection.service.ts | 43 +- apps/api/src/google/google.contracts.ts | 1 + apps/api/src/mailbox/mailbox-api.client.ts | 23 +- apps/api/src/mailbox/mailbox-token.service.ts | 10 +- apps/api/src/mailbox/mailbox.constants.ts | 25 +- apps/api/src/mailbox/thread-rebuild.ts | 51 +++ apps/api/src/mailbox/thread-writer.service.ts | 4 + .../microsoft/microsoft-connection.service.ts | 43 +- apps/api/src/sso/sso.contracts.ts | 1 + apps/api/src/sso/sso.service.ts | 2 + apps/api/src/sync/mailbox-sync.service.ts | 8 + apps/api/src/sync/sync.module.ts | 3 +- apps/api/src/zoho/zoho-connection.service.ts | 167 +++++++ apps/api/src/zoho/zoho-mail-sync.service.ts | 431 ++++++++++++++++++ apps/api/src/zoho/zoho-mail.client.ts | 226 +++++++++ apps/api/src/zoho/zoho-sync.service.ts | 25 + apps/api/src/zoho/zoho.constants.ts | 43 ++ apps/api/src/zoho/zoho.contracts.ts | 46 ++ apps/api/src/zoho/zoho.module.ts | 21 + apps/api/src/zoho/zoho.router.ts | 82 ++++ apps/api/test/auth.e2e.spec.ts | 3 + apps/api/test/mailbox-sync-tick.spec.ts | 4 + apps/api/turbo.json | 6 + .../connections/add-connection-dialog.tsx | 9 + .../[slug]/settings/connections/page.tsx | 22 +- .../settings/connections/zoho-connection.tsx | 382 ++++++++++++++++ .../[slug]/settings/connections/zoho/page.tsx | 21 + .../(landing)/grant-access/grant-access.tsx | 27 +- apps/app/app/(landing)/grant-access/page.tsx | 1 + apps/app/app/(landing)/sign-in/page.tsx | 10 +- .../app/(landing)/sign-in/social-sign-in.tsx | 7 +- apps/app/lib/mailbox-oauth.ts | 60 +++ apps/app/lib/trpc/cache.ts | 14 + packages/auth/src/auth.ts | 240 ++++++---- packages/auth/src/env.ts | 31 ++ packages/auth/src/index.ts | 19 + packages/auth/src/scopes.ts | 30 ++ packages/auth/src/zoho-region.ts | 60 +++ .../20260905120000_zoho_mail/migration.sql | 3 + packages/db/prisma/schema.prisma | 2 + packages/telemetry/src/allowlist.ts | 8 +- .../ui/src/components/brand-logos/zoho.tsx | 22 + packages/validation/src/index.ts | 2 + packages/validation/src/zoho.ts | 129 ++++++ turbo.json | 3 + 49 files changed, 2199 insertions(+), 212 deletions(-) create mode 100644 apps/api/src/mailbox/thread-rebuild.ts create mode 100644 apps/api/src/zoho/zoho-connection.service.ts create mode 100644 apps/api/src/zoho/zoho-mail-sync.service.ts create mode 100644 apps/api/src/zoho/zoho-mail.client.ts create mode 100644 apps/api/src/zoho/zoho-sync.service.ts create mode 100644 apps/api/src/zoho/zoho.constants.ts create mode 100644 apps/api/src/zoho/zoho.contracts.ts create mode 100644 apps/api/src/zoho/zoho.module.ts create mode 100644 apps/api/src/zoho/zoho.router.ts create mode 100644 apps/app/app/(app)/[slug]/settings/connections/zoho-connection.tsx create mode 100644 apps/app/app/(app)/[slug]/settings/connections/zoho/page.tsx create mode 100644 apps/app/lib/mailbox-oauth.ts create mode 100644 packages/auth/src/zoho-region.ts create mode 100644 packages/db/prisma/migrations/20260905120000_zoho_mail/migration.sql create mode 100644 packages/ui/src/components/brand-logos/zoho.tsx create mode 100644 packages/validation/src/zoho.ts diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 82a345df2..176813fb1 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -37,6 +37,7 @@ import { TrackingModule } from "./tracking/tracking.module"; import { TrpcModule } from "./trpc/trpc.module"; import { UsersModule } from "./users/users.module"; import { WorkspaceModule } from "./workspace/workspace.module"; +import { ZohoModule } from "./zoho/zoho.module"; @Module({ imports: [ @@ -69,6 +70,7 @@ import { WorkspaceModule } from "./workspace/workspace.module"; MailboxModule, GoogleModule, MicrosoftModule, + ZohoModule, SyncModule, SettingsModule, WorkspaceModule, diff --git a/apps/api/src/config/env.validation.ts b/apps/api/src/config/env.validation.ts index 08cb676c2..6a0cf1e4f 100644 --- a/apps/api/src/config/env.validation.ts +++ b/apps/api/src/config/env.validation.ts @@ -68,6 +68,19 @@ export class EnvironmentVariables { @IsString() MICROSOFT_TENANT_ID?: string; + @IsOptional() + @IsString() + ZOHO_CLIENT_ID?: string; + + @IsOptional() + @IsString() + ZOHO_CLIENT_SECRET?: string; + + /** Zoho data-centre suffix: com, eu, in, com.au, jp, ca, sa, com.cn. */ + @IsOptional() + @IsString() + ZOHO_REGION?: string; + @IsOptional() @IsString() SLACK_CLIENT_ID?: string; diff --git a/apps/api/src/generated/server.ts b/apps/api/src/generated/server.ts index b77da4001..217ab39ac 100644 --- a/apps/api/src/generated/server.ts +++ b/apps/api/src/generated/server.ts @@ -32,6 +32,7 @@ import { slackStatusOutput, slackMatchesOutput, slackChannelsInput, slackChannel import { ssoSignInOptionsOutput, ssoSettingsOutput, ssoProviderListInput, ssoProviderListOutput, registerSsoProviderInput, ssoProviderOutput, deleteSsoProviderInput, deleteSsoProviderOutput } from "../sso/sso.contracts"; import { trackingSettingsOutput, trackingFlagInput, cookieLifetimeInput, addDomainInput, trackedDomainOutput, removeDomainInput, rotateSiteIdOutput, verifyInput, verifyOutput, sourcesOutput, companyActivityInput, websiteActivityOutput, contactActivityInput } from "../tracking/tracking.contracts"; import { workspaceOutput, memberListInput, memberListOutput, updateWorkspaceInput, setMemberRoleInput, workspaceMemberOutput } from "../workspace/workspace.contracts"; +import { zohoConnectionStatusOutput, zohoPurgeSyncedDataOutput, zohoRevokeAccessOutput, setZohoAutoCreateInput } from "../zoho/zoho.contracts"; import type { UsersRouter } from "../users/users.router"; const appRouter = t.router({ @@ -750,6 +751,24 @@ const appRouter = t.router({ .input(setMemberRoleInput) .output(workspaceMemberOutput) .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any) + }), + zoho: t.router({ + status: publicProcedure + .output(zohoConnectionStatusOutput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + purgeSyncedData: publicProcedure + .output(zohoPurgeSyncedDataOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + revokeAccess: publicProcedure + .output(zohoRevokeAccessOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + syncNow: publicProcedure + .output(zohoConnectionStatusOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + setAutoCreate: publicProcedure + .input(setZohoAutoCreateInput) + .output(zohoConnectionStatusOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any) }) }); diff --git a/apps/api/src/google/conversation.service.ts b/apps/api/src/google/conversation.service.ts index 232b84f58..9ea027c22 100644 --- a/apps/api/src/google/conversation.service.ts +++ b/apps/api/src/google/conversation.service.ts @@ -42,6 +42,7 @@ export class ConversationService { sentAt: true, gmailMessageId: true, outlookWebLink: true, + zohoWebLink: true, }, }, }, @@ -66,12 +67,14 @@ export class ConversationService { fromImageUrl: faces.get(message.fromEmail.toLowerCase()) ?? null, mailboxUrl: message.gmailMessageId ? `https://mail.google.com/mail/u/0/#all/${message.gmailMessageId}` - : message.outlookWebLink, + : (message.outlookWebLink ?? message.zohoWebLink), mailboxName: message.gmailMessageId ? "Gmail" : message.outlookWebLink ? "Outlook" - : null, + : message.zohoWebLink + ? "Zoho Mail" + : null, })), }; } diff --git a/apps/api/src/google/google-connection.service.ts b/apps/api/src/google/google-connection.service.ts index 7c583b8e1..1770a11ed 100644 --- a/apps/api/src/google/google-connection.service.ts +++ b/apps/api/src/google/google-connection.service.ts @@ -7,6 +7,7 @@ import { InjectDatabase } from "../database/database.constants"; import { MailboxMatchService } from "../mailbox/mailbox-match.service"; import { MailboxTokenService } from "../mailbox/mailbox-token.service"; import { SyncStateService } from "../mailbox/sync-state.service"; +import { rebuildThreads } from "../mailbox/thread-rebuild"; import { GOOGLE_PROVIDER_ID, GOOGLE_SYNC_SOURCES, @@ -222,45 +223,3 @@ export class GoogleConnectionService { return { domain: normalised, purged: threads.count + events.count }; } } - -async function rebuildThreads( - tx: Prisma.TransactionClient, - threadIds: string[], -): Promise { - if (threadIds.length === 0) return; - - const remaining = await tx.emailMessage.findMany({ - where: { threadId: { in: threadIds } }, - select: { threadId: true, sentAt: true, subject: true, snippet: true }, - orderBy: { sentAt: "asc" }, - }); - - const byThread = new Map(); - - for (const message of remaining) { - const group = byThread.get(message.threadId); - if (group) group.push(message); - else byThread.set(message.threadId, [message]); - } - - for (const [threadId, messages] of byThread) { - const first = messages.at(0); - const last = messages.at(-1); - if (!first || !last) continue; - - await tx.emailThread.update({ - where: { id: threadId }, - data: { - messageCount: messages.length, - firstMessageAt: first.sentAt, - lastMessageAt: last.sentAt, - subject: first.subject, - }, - }); - - await tx.activity.updateMany({ - where: { emailThreadId: threadId }, - data: { body: last.snippet, occurredAt: last.sentAt }, - }); - } -} diff --git a/apps/api/src/google/google.contracts.ts b/apps/api/src/google/google.contracts.ts index 4aea716db..de7cb27eb 100644 --- a/apps/api/src/google/google.contracts.ts +++ b/apps/api/src/google/google.contracts.ts @@ -91,6 +91,7 @@ const emailThreadMessageOutput = z.object({ sentAt: z.string(), gmailMessageId: z.string().nullable(), outlookWebLink: z.string().nullable(), + zohoWebLink: z.string().nullable(), fromImageUrl: z.string().nullable(), mailboxUrl: z.string().nullable(), mailboxName: z.string().nullable(), diff --git a/apps/api/src/mailbox/mailbox-api.client.ts b/apps/api/src/mailbox/mailbox-api.client.ts index c286d3ba0..368081db7 100644 --- a/apps/api/src/mailbox/mailbox-api.client.ts +++ b/apps/api/src/mailbox/mailbox-api.client.ts @@ -9,6 +9,15 @@ export type MailboxResult = const DEFAULT_TIMEOUT_MS = 20_000; +// Google and Microsoft both take `Authorization: Bearer `. Zoho does +// not — it wants its own `Zoho-oauthtoken` scheme and answers 401 to a Bearer. +const DEFAULT_AUTH_SCHEME = "Bearer"; + +export type MailboxRequestOptions = { + /** OAuth authorization scheme, e.g. `Bearer` or `Zoho-oauthtoken`. */ + scheme?: string; +}; + const MIN_BACKOFF_MS = 30_000; const MAX_BACKOFF_MS = 15 * 60_000; @@ -20,6 +29,7 @@ export class MailboxApiClient { url: string, accessToken: string, params: Record = {}, + options: MailboxRequestOptions = {}, ): Promise> { const target = new URL(url); for (const [key, value] of Object.entries(params)) { @@ -30,8 +40,13 @@ export class MailboxApiClient { const timeout = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS); try { + const scheme = options.scheme ?? DEFAULT_AUTH_SCHEME; + const response = await fetch(target, { - headers: { authorization: `Bearer ${accessToken}` }, + headers: { + authorization: `${scheme} ${accessToken}`, + accept: "application/json", + }, signal: controller.signal, }); @@ -114,11 +129,17 @@ export class MailboxApiClient { try { const body = (await response.json()) as { error?: { message?: string; status?: string; code?: string }; + // Zoho puts the human-readable reason in the envelope instead. + status?: { description?: string }; + data?: { errorCode?: string; moreInfo?: string }; }; return ( body.error?.message ?? body.error?.status ?? body.error?.code ?? + body.data?.moreInfo ?? + body.data?.errorCode ?? + body.status?.description ?? `HTTP ${response.status}` ); } catch { diff --git a/apps/api/src/mailbox/mailbox-token.service.ts b/apps/api/src/mailbox/mailbox-token.service.ts index bc5df6ece..0830d9078 100644 --- a/apps/api/src/mailbox/mailbox-token.service.ts +++ b/apps/api/src/mailbox/mailbox-token.service.ts @@ -9,9 +9,11 @@ import { Injectable, Logger } from "@nestjs/common"; import { InjectDatabase } from "../database/database.constants"; import { GOOGLE_PROVIDER_ID, + MICROSOFT_PROVIDER_ID, PROVIDER_FOR_SOURCE, SCOPE_FOR_SOURCE, type SyncSource, + ZOHO_PROVIDER_ID, } from "./mailbox.constants"; export type TokenFailure = @@ -164,6 +166,12 @@ export class MailboxTokenService { } } +const PROVIDER_LABELS = { + [GOOGLE_PROVIDER_ID]: "Google", + [MICROSOFT_PROVIDER_ID]: "Microsoft", + [ZOHO_PROVIDER_ID]: "Zoho", +} satisfies Record; + function label(providerId: MailboxProviderId): string { - return providerId === GOOGLE_PROVIDER_ID ? "Google" : "Microsoft"; + return PROVIDER_LABELS[providerId]; } diff --git a/apps/api/src/mailbox/mailbox.constants.ts b/apps/api/src/mailbox/mailbox.constants.ts index a9228d8b9..030edca95 100644 --- a/apps/api/src/mailbox/mailbox.constants.ts +++ b/apps/api/src/mailbox/mailbox.constants.ts @@ -5,6 +5,8 @@ import { type MailboxProviderId, MICROSOFT_PROVIDER_ID, OUTLOOK_MAIL_SCOPE, + ZOHO_MESSAGES_SCOPE, + ZOHO_PROVIDER_ID, } from "@crm/auth"; export { @@ -16,16 +18,28 @@ export { MICROSOFT_SYNC_SCOPES, OUTLOOK_MAIL_SCOPE, SYNC_SCOPES, + ZOHO_ACCOUNTS_SCOPE, + ZOHO_FOLDERS_SCOPE, + ZOHO_MESSAGES_SCOPE, + ZOHO_PROVIDER_ID, + ZOHO_SYNC_SCOPES, } from "@crm/auth"; -export const SYNC_SOURCES = ["calendar", "gmail", "outlook"] as const; +export const SYNC_SOURCES = [ + "calendar", + "gmail", + "outlook", + "zohomail", +] as const; export type SyncSource = (typeof SYNC_SOURCES)[number]; export const GOOGLE_SYNC_SOURCES = ["calendar", "gmail"] as const; export const MICROSOFT_SYNC_SOURCES = ["outlook"] as const; +export const ZOHO_SYNC_SOURCES = ["zohomail"] as const; export type GoogleSyncSource = (typeof GOOGLE_SYNC_SOURCES)[number]; export type MicrosoftSyncSource = (typeof MICROSOFT_SYNC_SOURCES)[number]; +export type ZohoSyncSource = (typeof ZOHO_SYNC_SOURCES)[number]; export function isGoogleSyncSource(source: string): source is GoogleSyncSource { return (GOOGLE_SYNC_SOURCES as readonly string[]).includes(source); @@ -37,14 +51,23 @@ export function isMicrosoftSyncSource( return (MICROSOFT_SYNC_SOURCES as readonly string[]).includes(source); } +export function isZohoSyncSource(source: string): source is ZohoSyncSource { + return (ZOHO_SYNC_SOURCES as readonly string[]).includes(source); +} + +// One scope per source: the one that, on its own, proves the source can be +// read. Zoho needs three scopes to work, but `messages.READ` is the one that +// distinguishes "mail is connected" from "the user only linked their profile". export const SCOPE_FOR_SOURCE = { calendar: CALENDAR_SCOPE, gmail: GMAIL_SCOPE, outlook: OUTLOOK_MAIL_SCOPE, + zohomail: ZOHO_MESSAGES_SCOPE, } satisfies Record; export const PROVIDER_FOR_SOURCE = { calendar: GOOGLE_PROVIDER_ID, gmail: GOOGLE_PROVIDER_ID, outlook: MICROSOFT_PROVIDER_ID, + zohomail: ZOHO_PROVIDER_ID, } satisfies Record; diff --git a/apps/api/src/mailbox/thread-rebuild.ts b/apps/api/src/mailbox/thread-rebuild.ts new file mode 100644 index 000000000..cb52b26b9 --- /dev/null +++ b/apps/api/src/mailbox/thread-rebuild.ts @@ -0,0 +1,51 @@ +import type { Prisma } from "@crm/db"; + +/** + * Put threads back together after some of their messages were purged. + * + * Counts, first/last timestamps and the activity preview are all derived from + * the messages a thread holds, so deleting one provider's messages out of a + * mixed thread leaves those fields lying. Every provider's purge needs this, + * which is why it lives here rather than beside any one of them. + */ +export async function rebuildThreads( + tx: Prisma.TransactionClient, + threadIds: string[], +): Promise { + if (threadIds.length === 0) return; + + const remaining = await tx.emailMessage.findMany({ + where: { threadId: { in: threadIds } }, + select: { threadId: true, sentAt: true, subject: true, snippet: true }, + orderBy: { sentAt: "asc" }, + }); + + const byThread = new Map(); + + for (const message of remaining) { + const group = byThread.get(message.threadId); + if (group) group.push(message); + else byThread.set(message.threadId, [message]); + } + + for (const [threadId, messages] of byThread) { + const first = messages.at(0); + const last = messages.at(-1); + if (!first || !last) continue; + + await tx.emailThread.update({ + where: { id: threadId }, + data: { + messageCount: messages.length, + firstMessageAt: first.sentAt, + lastMessageAt: last.sentAt, + subject: first.subject, + }, + }); + + await tx.activity.updateMany({ + where: { emailThreadId: threadId }, + data: { body: last.snippet, occurredAt: last.sentAt }, + }); + } +} diff --git a/apps/api/src/mailbox/thread-writer.service.ts b/apps/api/src/mailbox/thread-writer.service.ts index 9b06692c6..2a5c11d96 100644 --- a/apps/api/src/mailbox/thread-writer.service.ts +++ b/apps/api/src/mailbox/thread-writer.service.ts @@ -29,6 +29,8 @@ export type IncomingMessage = { gmailMessageId?: string | null; outlookMessageId?: string | null; outlookWebLink?: string | null; + zohoMessageId?: string | null; + zohoWebLink?: string | null; }; @Injectable() @@ -148,6 +150,8 @@ export class ThreadWriterService { gmailMessageId: parsed.gmailMessageId ?? null, outlookMessageId: parsed.outlookMessageId ?? null, outlookWebLink: parsed.outlookWebLink ?? null, + zohoMessageId: parsed.zohoMessageId ?? null, + zohoWebLink: parsed.zohoWebLink ?? null, direction: outbound ? EmailDirection.OUTBOUND : EmailDirection.INBOUND, diff --git a/apps/api/src/microsoft/microsoft-connection.service.ts b/apps/api/src/microsoft/microsoft-connection.service.ts index 73b1c6e25..835989980 100644 --- a/apps/api/src/microsoft/microsoft-connection.service.ts +++ b/apps/api/src/microsoft/microsoft-connection.service.ts @@ -5,6 +5,7 @@ import { ActivityStampService } from "../crm/activity-stamp.service"; import { InjectDatabase } from "../database/database.constants"; import { MailboxTokenService } from "../mailbox/mailbox-token.service"; import { SyncStateService } from "../mailbox/sync-state.service"; +import { rebuildThreads } from "../mailbox/thread-rebuild"; import { MICROSOFT_PROVIDER_ID, MICROSOFT_SYNC_SOURCES, @@ -171,45 +172,3 @@ export class MicrosoftConnectionService { await this.state.setAutoCreate(userId, source, enabled); } } - -async function rebuildThreads( - tx: Prisma.TransactionClient, - threadIds: string[], -): Promise { - if (threadIds.length === 0) return; - - const remaining = await tx.emailMessage.findMany({ - where: { threadId: { in: threadIds } }, - select: { threadId: true, sentAt: true, subject: true, snippet: true }, - orderBy: { sentAt: "asc" }, - }); - - const byThread = new Map(); - - for (const message of remaining) { - const group = byThread.get(message.threadId); - if (group) group.push(message); - else byThread.set(message.threadId, [message]); - } - - for (const [threadId, messages] of byThread) { - const first = messages.at(0); - const last = messages.at(-1); - if (!first || !last) continue; - - await tx.emailThread.update({ - where: { id: threadId }, - data: { - messageCount: messages.length, - firstMessageAt: first.sentAt, - lastMessageAt: last.sentAt, - subject: first.subject, - }, - }); - - await tx.activity.updateMany({ - where: { emailThreadId: threadId }, - data: { body: last.snippet, occurredAt: last.sentAt }, - }); - } -} diff --git a/apps/api/src/sso/sso.contracts.ts b/apps/api/src/sso/sso.contracts.ts index d36d63e5c..22d7c9901 100644 --- a/apps/api/src/sso/sso.contracts.ts +++ b/apps/api/src/sso/sso.contracts.ts @@ -35,6 +35,7 @@ const ssoPublicProviderOutput = z.object({ export const ssoSignInOptionsOutput = z.object({ google: z.boolean(), microsoft: z.boolean(), + zoho: z.boolean(), providers: z.array(ssoPublicProviderOutput), }); diff --git a/apps/api/src/sso/sso.service.ts b/apps/api/src/sso/sso.service.ts index 340efcb65..9d70c49f8 100644 --- a/apps/api/src/sso/sso.service.ts +++ b/apps/api/src/sso/sso.service.ts @@ -3,6 +3,7 @@ import { canConfigureSso, isGoogleConfigured, isMicrosoftConfigured, + isZohoConfigured, ssoCallbackBase, ssoCallbackURL, ssoProviderName, @@ -126,6 +127,7 @@ export class SsoService { return { google: isGoogleConfigured(), microsoft: isMicrosoftConfigured(), + zoho: isZohoConfigured(), providers: rows.map((row) => ({ providerId: row.providerId, name: ssoProviderName(row.providerId), diff --git a/apps/api/src/sync/mailbox-sync.service.ts b/apps/api/src/sync/mailbox-sync.service.ts index 3eac76b26..b806b1700 100644 --- a/apps/api/src/sync/mailbox-sync.service.ts +++ b/apps/api/src/sync/mailbox-sync.service.ts @@ -5,10 +5,13 @@ import { GoogleSyncService } from "../google/google-sync.service"; import { isGoogleSyncSource, isMicrosoftSyncSource, + isZohoSyncSource, } from "../mailbox/mailbox.constants"; import { SyncStateService } from "../mailbox/sync-state.service"; import { MicrosoftConnectionService } from "../microsoft/microsoft-connection.service"; import { MicrosoftSyncService } from "../microsoft/microsoft-sync.service"; +import { ZohoConnectionService } from "../zoho/zoho-connection.service"; +import { ZohoSyncService } from "../zoho/zoho-sync.service"; const TICK_BUDGET_MS = 60_000; @@ -31,6 +34,8 @@ export class MailboxSyncService { private readonly microsoft: MicrosoftSyncService, private readonly googleConnections: GoogleConnectionService, private readonly microsoftConnections: MicrosoftConnectionService, + private readonly zoho: ZohoSyncService, + private readonly zohoConnections: ZohoConnectionService, ) {} async runDue(): Promise { @@ -46,6 +51,7 @@ export class MailboxSyncService { await this.googleConnections.reconcileAll(); await this.microsoftConnections.reconcileAll(); + await this.zohoConnections.reconcileAll(); const due = await this.state.due(new Date()); @@ -119,6 +125,8 @@ export class MailboxSyncService { return this.microsoft.runOne(userId, source); } + if (isZohoSyncSource(source)) return this.zoho.runOne(userId, source); + return null; } } diff --git a/apps/api/src/sync/sync.module.ts b/apps/api/src/sync/sync.module.ts index db76e9051..e40ea57df 100644 --- a/apps/api/src/sync/sync.module.ts +++ b/apps/api/src/sync/sync.module.ts @@ -2,11 +2,12 @@ import { Module } from "@nestjs/common"; import { GoogleModule } from "../google/google.module"; import { MailboxModule } from "../mailbox/mailbox.module"; import { MicrosoftModule } from "../microsoft/microsoft.module"; +import { ZohoModule } from "../zoho/zoho.module"; import { MailboxSyncService } from "./mailbox-sync.service"; import { SyncController } from "./sync.controller"; @Module({ - imports: [MailboxModule, GoogleModule, MicrosoftModule], + imports: [MailboxModule, GoogleModule, MicrosoftModule, ZohoModule], controllers: [SyncController], providers: [MailboxSyncService], exports: [MailboxSyncService], diff --git a/apps/api/src/zoho/zoho-connection.service.ts b/apps/api/src/zoho/zoho-connection.service.ts new file mode 100644 index 000000000..70336078a --- /dev/null +++ b/apps/api/src/zoho/zoho-connection.service.ts @@ -0,0 +1,167 @@ +import { isZohoConfigured, signsInWithZoho } from "@crm/auth"; +import type { Db, Prisma } from "@crm/db"; +import { Injectable, Logger, NotFoundException } from "@nestjs/common"; +import { ActivityStampService } from "../crm/activity-stamp.service"; +import { InjectDatabase } from "../database/database.constants"; +import { MailboxTokenService } from "../mailbox/mailbox-token.service"; +import { SyncStateService } from "../mailbox/sync-state.service"; +import { rebuildThreads } from "../mailbox/thread-rebuild"; +import { + SCOPE_FOR_SOURCE, + ZOHO_PROVIDER_ID, + ZOHO_SYNC_SOURCES, + type ZohoSyncSource, +} from "./zoho.constants"; +import type { + ZohoConnectionStatus, + ZohoPurgeSyncedDataOutput, + ZohoRevokeAccessOutput, + ZohoSourceStatus, +} from "./zoho.contracts"; + +const PURGE_TIMEOUT_MS = 60_000; + +@Injectable() +export class ZohoConnectionService { + private readonly logger = new Logger(ZohoConnectionService.name); + + constructor( + @InjectDatabase() private readonly db: Db, + private readonly tokens: MailboxTokenService, + private readonly state: SyncStateService, + private readonly stamp: ActivityStampService, + ) {} + + async status(userId: string): Promise { + await this.onConnected(userId); + + const [granted, rows, hasRefreshToken, accounts] = await Promise.all([ + this.tokens.grantedScopes(userId, ZOHO_PROVIDER_ID), + this.state.listForUser(userId, ZOHO_SYNC_SOURCES), + this.tokens.hasRefreshToken(userId, ZOHO_PROVIDER_ID), + this.tokens.signInAccounts(userId), + ]); + + const bySource = new Map(rows.map((row) => [row.source, row])); + + const sources = ZOHO_SYNC_SOURCES.map((source): ZohoSourceStatus => { + const row = bySource.get(source); + + return { + source, + connected: granted.has(SCOPE_FOR_SOURCE[source]), + status: row?.status ?? null, + lastSyncedAt: row?.lastSyncedAt?.toISOString() ?? null, + lastError: row?.lastError ?? null, + autoCreate: row?.autoCreate ?? false, + }; + }); + + return { + configured: isZohoConfigured(), + linked: + accounts.some((account) => account.providerId === ZOHO_PROVIDER_ID) && + sources.some((source) => source.connected), + required: signsInWithZoho(accounts), + hasRefreshToken, + sources, + }; + } + + async onConnected(userId: string): Promise { + const [granted, existing] = await Promise.all([ + this.tokens.grantedScopes(userId, ZOHO_PROVIDER_ID), + this.state.listForUser(userId, ZOHO_SYNC_SOURCES), + ]); + + const known = new Set(existing.map((row) => row.source)); + + const added: string[] = []; + + for (const source of ZOHO_SYNC_SOURCES) { + if (!granted.has(SCOPE_FOR_SOURCE[source])) continue; + if (known.has(source)) continue; + + await this.state.ensure(userId, source, { autoCreate: false }); + + added.push(source); + } + + if (added.length > 0) { + this.logger.log({ message: "Zoho connected", userId, sources: added }); + } + } + + async reconcileAll(): Promise { + const accounts = await this.db.account.findMany({ + where: { + providerId: ZOHO_PROVIDER_ID, + OR: ZOHO_SYNC_SOURCES.map((source) => ({ + scope: { contains: SCOPE_FOR_SOURCE[source] }, + })), + }, + select: { userId: true }, + }); + + for (const userId of new Set(accounts.map((row) => row.userId))) { + await this.onConnected(userId); + } + } + + async purgeSyncedData(userId: string): Promise { + const mine: Prisma.EmailMessageWhereInput = { + syncedByUserId: userId, + zohoMessageId: { not: null }, + }; + + const purged = await this.db.$transaction( + async (tx) => { + const touched = await tx.emailMessage.findMany({ + where: mine, + select: { threadId: true }, + distinct: ["threadId"], + }); + + const threadIds = touched.map((row) => row.threadId); + const messages = await tx.emailMessage.deleteMany({ where: mine }); + + await tx.emailThread.deleteMany({ + where: { id: { in: threadIds }, messages: { none: {} } }, + }); + + await rebuildThreads(tx, threadIds); + + return messages.count; + }, + { timeout: PURGE_TIMEOUT_MS }, + ); + + await this.stamp.recomputeAll(); + + this.logger.log({ message: "Zoho Mail data purged", userId, purged }); + + return { purged }; + } + + async revoke(userId: string): Promise { + for (const source of ZOHO_SYNC_SOURCES) { + await this.state.remove(userId, source); + } + + const revoked = await this.tokens.revoke(userId, ZOHO_PROVIDER_ID); + return { revoked }; + } + + async setAutoCreate( + userId: string, + source: ZohoSyncSource, + enabled: boolean, + ): Promise { + const row = await this.state.get(userId, source); + if (!row) { + throw new NotFoundException(`${source} is not connected.`); + } + + await this.state.setAutoCreate(userId, source, enabled); + } +} diff --git a/apps/api/src/zoho/zoho-mail-sync.service.ts b/apps/api/src/zoho/zoho-mail-sync.service.ts new file mode 100644 index 000000000..d93ae2f19 --- /dev/null +++ b/apps/api/src/zoho/zoho-mail-sync.service.ts @@ -0,0 +1,431 @@ +import { + GoogleSyncStatus, + type MailboxSyncModel as MailboxSync, +} from "@crm/db"; +import { Injectable, Logger } from "@nestjs/common"; +import type { MailboxResult } from "../mailbox/mailbox-api.client"; +import type { MatchContext } from "../mailbox/mailbox-match.service"; +import { MailboxTokenService } from "../mailbox/mailbox-token.service"; +import { + normaliseMessageId, + rootMessageIdFrom, + stripHtml, + stripQuotedHistory, +} from "../mailbox/message-text"; +import { parseAddress, parseAddressList } from "../mailbox/participants"; +import { SyncStateService } from "../mailbox/sync-state.service"; +import { + type IncomingMessage, + ThreadWriterService, +} from "../mailbox/thread-writer.service"; +import { + ZOHO_EXCLUDED_FOLDER_TYPES, + ZOHO_MAX_MESSAGES_PER_TICK, + ZOHO_PAGE_SIZE, + ZOHO_THREAD_ROOT_PREFIX, +} from "./zoho.constants"; +import { + type ZohoAccount, + type ZohoHeaders, + ZohoMailClient, + type ZohoMessageSummary, +} from "./zoho-mail.client"; + +// The list is sorted by date, so re-reading a second of overlap costs one or +// two duplicate lookups and closes the window where two messages share a +// timestamp and the later one is dropped. +const OVERLAP_MS = 1_000; + +export type ZohoSyncOutcome = { + source: "zohomail"; + userId: string; + status: "synced" | "skipped" | "reconnect" | "rate-limited" | "failed"; + messagesWritten?: number; + reason?: string; +}; + +type Failure = Exclude, { outcome: "ok" }>; + +type Mailbox = { + accountId: string; + address: string; +}; + +@Injectable() +export class ZohoMailSyncService { + private readonly logger = new Logger(ZohoMailSyncService.name); + + constructor( + private readonly zoho: ZohoMailClient, + private readonly tokens: MailboxTokenService, + private readonly state: SyncStateService, + private readonly threads: ThreadWriterService, + ) {} + + async sync(row: MailboxSync): Promise { + const initializedAt = new Date(); + + const token = await this.tokens.accessTokenFor(row.userId, "zohomail"); + + if (token.outcome === "not-connected") { + return this.outcome(row, "skipped", token.reason); + } + + if (token.outcome === "needs-reconnect") { + await this.state.markNeedsReconnect(row.id, token.reason); + return this.outcome(row, "reconnect", token.reason); + } + + await this.state.markRunning(row.id); + + const mailbox = await this.mailbox(token.accessToken); + if (mailbox.outcome !== "ok") return this.handleFailure(row, mailbox); + + // First run: remember where "now" is and sync forward from here. Back- + // filling a whole mailbox is a different, much heavier job than keeping + // up with it, and doing it silently on connect is a nasty surprise. + if (!row.cursor) { + await this.state.settle(row.id, { + cursor: initializedAt.getTime().toString(), + status: GoogleSyncStatus.RUNNING, + }); + + this.logger.log({ + message: "Zoho Mail sync started — watching for new mail", + userId: row.userId, + mailbox: mailbox.data.address, + }); + + return this.outcome(row, "synced"); + } + + return this.incremental(row, token.accessToken, mailbox.data, row.cursor); + } + + private async incremental( + row: MailboxSync, + accessToken: string, + mailbox: Mailbox, + cursor: string, + ): Promise { + const since = Number(cursor); + if (!Number.isFinite(since)) { + await this.state.clearCursor( + row.id, + "The stored cursor was not a timestamp.", + ); + return this.outcome(row, "synced", "Cursor reset; resuming from now."); + } + + const excluded = await this.excludedFolderIds( + accessToken, + mailbox.accountId, + ); + if (excluded.outcome !== "ok") return this.handleFailure(row, excluded); + + const floor = since - OVERLAP_MS; + + let context: MatchContext | null = null; + let start = 1; + let seen = 0; + let written = 0; + let furthest = since; + let exhausted = false; + + // Zoho has no "modified since" filter, so this walks the date-sorted list + // newest-first and stops at the first message the last tick already saw. + while (seen < ZOHO_MAX_MESSAGES_PER_TICK && !exhausted) { + const page = await this.zoho.listMessages( + accessToken, + mailbox.accountId, + { + start, + limit: ZOHO_PAGE_SIZE, + }, + ); + if (page.outcome !== "ok") return this.handleFailure(row, page); + + if (page.data.length === 0) break; + + for (const summary of page.data) { + const receivedAt = summary.receivedTime ?? summary.sentDateInGMT; + + if (receivedAt !== undefined && receivedAt <= floor) { + exhausted = true; + break; + } + + seen += 1; + if (seen > ZOHO_MAX_MESSAGES_PER_TICK) { + exhausted = true; + break; + } + + if (receivedAt !== undefined && receivedAt > furthest) { + furthest = receivedAt; + } + + if (excluded.data.has(summary.folderId)) continue; + + const parsed = await this.parse( + accessToken, + mailbox.accountId, + summary, + ); + if (parsed.outcome === "skip") continue; + if (parsed.outcome !== "ok") return this.handleFailure(row, parsed); + + context ??= await this.threads.context(); + + const stored = await this.threads.store( + row, + { mailbox: mailbox.address, origin: "zohomail" }, + parsed.data, + context, + ); + if (stored) written += 1; + } + + if (page.data.length < ZOHO_PAGE_SIZE) break; + + start += page.data.length; + } + + await this.state.settle(row.id, { + cursor: furthest.toString(), + status: GoogleSyncStatus.RUNNING, + }); + + if (written > 0) { + this.logger.log({ + message: "Zoho Mail incremental sync", + userId: row.userId, + messagesWritten: written, + messagesSeen: seen, + }); + } + + return this.outcome(row, "synced", undefined, written); + } + + /** + * Zoho keys every call on a numeric account id, and one login can carry + * several: the real mailbox plus any POP/IMAP mailbox the user has attached. + * Only a `ZOHO_ACCOUNT` can be read through this API. + */ + private async mailbox(accessToken: string): Promise> { + const accounts = await this.zoho.accounts(accessToken); + if (accounts.outcome !== "ok") return accounts; + + const usable = accounts.data.find( + (account) => account.type !== "IMAP_ACCOUNT" && account.enabled !== false, + ); + + const address = addressOf(usable); + + if (!usable || !address) { + return { + outcome: "failed", + reason: "Zoho returned no readable mailbox for this account.", + retryable: false, + }; + } + + return { + outcome: "ok", + data: { accountId: usable.accountId, address }, + }; + } + + private async excludedFolderIds( + accessToken: string, + accountId: string, + ): Promise>> { + const folders = await this.zoho.folders(accessToken, accountId); + if (folders.outcome !== "ok") return folders; + + const excluded = new Set(); + + for (const folder of folders.data) { + const type = folder.folderType; + if (!type) continue; + + if ((ZOHO_EXCLUDED_FOLDER_TYPES as readonly string[]).includes(type)) { + excluded.add(folder.folderId); + } + } + + return { outcome: "ok", data: excluded }; + } + + /** + * Turn one list entry into a storable message. + * + * The list alone is not enough: it has no RFC `Message-ID`, so a mail seen + * through both Gmail and Zoho would be stored twice. The header call is what + * makes the `rfcMessageId` unique constraint do its job across providers. + */ + private async parse( + accessToken: string, + accountId: string, + summary: ZohoMessageSummary, + ): Promise< + { outcome: "ok"; data: IncomingMessage } | { outcome: "skip" } | Failure + > { + const from = senderOf(summary); + if (!from) return { outcome: "skip" }; + + const sentAtMillis = summary.sentDateInGMT ?? summary.receivedTime; + if (sentAtMillis === undefined) return { outcome: "skip" }; + + const headers = await this.zoho.messageHeaders( + accessToken, + accountId, + summary.folderId, + summary.messageId, + ); + if (headers.outcome !== "ok") { + // A message that vanished between the list and the fetch is normal — + // the user moved or deleted it. Skip rather than fail the whole tick. + if (headers.outcome === "cursor-invalid") return { outcome: "skip" }; + return headers; + } + + const rfcMessageId = firstHeader(headers.data, "message-id"); + if (!rfcMessageId) return { outcome: "skip" }; + + const content = await this.zoho.messageContent( + accessToken, + accountId, + summary.folderId, + summary.messageId, + ); + if (content.outcome !== "ok") { + if (content.outcome === "cursor-invalid") return { outcome: "skip" }; + return content; + } + + const recipients = [ + ...named(parseAddressList(summary.toAddress), "to"), + ...named(parseAddressList(summary.ccAddress), "cc"), + ]; + + return { + outcome: "ok", + data: { + rfcMessageId: normaliseMessageId(rfcMessageId), + rootId: this.rootIdOf(headers.data, rfcMessageId, summary), + subject: summary.subject?.trim() || null, + from, + recipients, + body: stripQuotedHistory(stripHtml(content.data.content)), + sentAt: new Date(sentAtMillis), + zohoMessageId: summary.messageId, + zohoWebLink: this.zoho.messageUrl(summary.folderId, summary.messageId), + }, + }; + } + + private rootIdOf( + headers: ZohoHeaders, + rfcMessageId: string, + summary: ZohoMessageSummary, + ): string { + const references = firstHeader(headers, "references"); + const inReplyTo = firstHeader(headers, "in-reply-to"); + + if (references || inReplyTo) { + const root = rootMessageIdFrom({ + references, + inReplyTo, + messageId: rfcMessageId, + }); + + if (root) return root; + } + + // Zoho's own conversation id is the fallback, not the first choice: it + // only groups mail this mailbox has seen, where References spans + // providers. + if (summary.threadId) { + return `${ZOHO_THREAD_ROOT_PREFIX}${summary.threadId}`; + } + + return normaliseMessageId(rfcMessageId); + } + + private async handleFailure( + row: MailboxSync, + result: { outcome: string; reason: string; retryAfterMs?: number }, + ): Promise { + if (result.outcome === "unauthorized") { + await this.state.markNeedsReconnect(row.id, result.reason); + return this.outcome(row, "reconnect", result.reason); + } + + if (result.outcome === "rate-limited") { + await this.state.markRateLimited(row.id, result.retryAfterMs ?? 60_000); + return this.outcome(row, "rate-limited", result.reason); + } + + await this.state.markFailed(row.id, result.reason); + return this.outcome(row, "failed", result.reason); + } + + private outcome( + row: MailboxSync, + status: ZohoSyncOutcome["status"], + reason?: string, + messagesWritten?: number, + ): ZohoSyncOutcome { + const outcome: ZohoSyncOutcome = { + source: "zohomail", + userId: row.userId, + status, + }; + + if (reason !== undefined) outcome.reason = reason; + if (messagesWritten !== undefined) { + outcome.messagesWritten = messagesWritten; + } + + return outcome; + } +} + +function addressOf(account: ZohoAccount | undefined): string | null { + const address = ( + account?.mailboxAddress ?? + account?.primaryEmailAddress ?? + "" + ) + .trim() + .toLowerCase(); + + return address || null; +} + +/** + * `fromAddress` is a bare address and `sender` is the display name, so the two + * have to be recombined before the shared address parser sees them. + */ +function senderOf(summary: ZohoMessageSummary) { + const email = summary.fromAddress?.trim(); + if (!email) return null; + + const name = summary.sender?.trim(); + + return parseAddress(name ? `"${name}" <${email}>` : email); +} + +function named( + participants: { email: string; name: string | null }[], + kind: "to" | "cc", +) { + return participants.map((participant) => ({ ...participant, kind })); +} + +function firstHeader(headers: ZohoHeaders, name: string): string | null { + const value = headers.values.get(name)?.at(0)?.trim(); + return value || null; +} diff --git a/apps/api/src/zoho/zoho-mail.client.ts b/apps/api/src/zoho/zoho-mail.client.ts new file mode 100644 index 000000000..456015369 --- /dev/null +++ b/apps/api/src/zoho/zoho-mail.client.ts @@ -0,0 +1,226 @@ +import { zohoConfig } from "@crm/auth"; +import { schemas } from "@crm/validation"; +import { Injectable } from "@nestjs/common"; +import type { ZodType } from "zod"; +import { + MailboxApiClient, + type MailboxResult, +} from "../mailbox/mailbox-api.client"; +import { ZOHO_AUTH_SCHEME, ZOHO_PAGE_SIZE } from "./zoho.constants"; + +export type ZohoAccount = ReturnType; +export type ZohoFolder = ReturnType; +export type ZohoMessageSummary = ReturnType< + typeof schemas.zoho.messageSummary.parse +>; + +export type ZohoMessageBody = { + messageId: string; + /** HTML, as Zoho stores it. The caller strips it. */ + content: string; +}; + +export type ZohoHeaders = { + messageId: string; + /** Header names lower-cased, so lookups do not have to guess the casing. */ + values: Map; +}; + +/** + * Zoho Mail's REST API, shaped like the Graph client next door. + * + * Three things about it drive the design here and are worth stating once: + * + * 1. Everything is keyed on a numeric `accountId` that only `/api/accounts` + * knows, so a sync always starts with an account lookup. + * 2. There is no "changed since" filter. The list endpoint pages with + * `start`/`limit` over a date-sorted list, so an incremental sync reads + * newest-first and stops when it reaches mail it has already seen. + * 3. The list gives no RFC `Message-ID`, `References` or `In-Reply-To`. Those + * come from a per-message header call, which is what lets a Zoho-synced + * message de-duplicate against the same mail seen through Gmail. + */ +@Injectable() +export class ZohoMailClient { + constructor(private readonly api: MailboxApiClient) {} + + private base(): string { + const config = zohoConfig(); + if (!config) { + throw new Error( + "Zoho is not configured: set ZOHO_CLIENT_ID and ZOHO_CLIENT_SECRET.", + ); + } + + return config.endpoints.mailApiBase; + } + + /** + * A deep link into Zoho's web client for one message. Zoho has no + * `webLink` field of its own, so this is assembled from the ids we hold. + */ + messageUrl(folderId: string, messageId: string): string | null { + const config = zohoConfig(); + if (!config) return null; + + return `${config.endpoints.mailWebBase}/zm/#mail/folder/${folderId}/p/${messageId}`; + } + + async accounts(accessToken: string): Promise> { + return this.get("/accounts", accessToken, schemas.zoho.accounts); + } + + async folders( + accessToken: string, + accountId: string, + ): Promise> { + return this.get( + `/accounts/${encodeURIComponent(accountId)}/folders`, + accessToken, + schemas.zoho.folders, + ); + } + + /** + * One page of the mailbox, newest first. + * + * `start` is 1-based and counts messages, not pages. `includesent` pulls in + * the user's own replies, which is how an outbound message gets filed + * against a company at all. + */ + async listMessages( + accessToken: string, + accountId: string, + options: { start: number; limit?: number }, + ): Promise> { + return this.get( + `/accounts/${encodeURIComponent(accountId)}/messages/view`, + accessToken, + schemas.zoho.messageList, + { + start: options.start, + limit: options.limit ?? ZOHO_PAGE_SIZE, + sortBy: "date", + sortorder: false, + includeto: true, + includesent: true, + }, + ); + } + + async messageHeaders( + accessToken: string, + accountId: string, + folderId: string, + messageId: string, + ): Promise> { + const result = await this.get( + `${this.messagePath(accountId, folderId, messageId)}/header`, + accessToken, + schemas.zoho.messageHeaders, + { raw: false }, + ); + if (result.outcome !== "ok") return result; + + const values = new Map(); + for (const [name, entries] of Object.entries(result.data.headerContent)) { + values.set(name.toLowerCase(), entries); + } + + return { + outcome: "ok", + data: { messageId: result.data.messageId, values }, + }; + } + + async messageContent( + accessToken: string, + accountId: string, + folderId: string, + messageId: string, + ): Promise> { + const result = await this.get( + `${this.messagePath(accountId, folderId, messageId)}/content`, + accessToken, + schemas.zoho.messageContent, + ); + if (result.outcome !== "ok") return result; + + return { + outcome: "ok", + data: { + messageId: result.data.messageId, + content: result.data.content ?? "", + }, + }; + } + + private messagePath( + accountId: string, + folderId: string, + messageId: string, + ): string { + return [ + "/accounts", + encodeURIComponent(accountId), + "folders", + encodeURIComponent(folderId), + "messages", + encodeURIComponent(messageId), + ].join("/"); + } + + /** + * Zoho wraps every reply in `{ status, data }` and does not always let the + * HTTP status disagree with `status.code`. Unwrap here so callers only ever + * see the payload — or a `MailboxResult` failure that reads the same as + * Google's and Microsoft's. + */ + private async get( + path: string, + accessToken: string, + schema: ZodType, + params: Record = {}, + ): Promise> { + const result = await this.api.get( + `${this.base()}${path}`, + accessToken, + params, + { scheme: ZOHO_AUTH_SCHEME }, + ); + + if (result.outcome !== "ok") return result; + + const envelope = schemas.zoho.envelope.safeParse(result.data); + if (!envelope.success) { + return { + outcome: "failed", + reason: `Zoho returned a response this client does not recognise (${path}).`, + retryable: false, + }; + } + + const { status, data } = envelope.data; + + if (status.code >= 200 && status.code < 300) { + const payload = schema.safeParse(data); + if (payload.success) return { outcome: "ok", data: payload.data }; + + return { + outcome: "failed", + reason: `Zoho sent a shape this client cannot read (${path}): ${payload.error.message}`, + retryable: false, + }; + } + + const reason = status.description ?? `Zoho status ${status.code}`; + + if (status.code === 401) return { outcome: "unauthorized", reason }; + if (status.code === 404) return { outcome: "cursor-invalid", reason }; + if (status.code === 429) { + return { outcome: "rate-limited", reason, retryAfterMs: 60_000 }; + } + + return { outcome: "failed", reason, retryable: status.code >= 500 }; + } +} diff --git a/apps/api/src/zoho/zoho-sync.service.ts b/apps/api/src/zoho/zoho-sync.service.ts new file mode 100644 index 000000000..b80411e6b --- /dev/null +++ b/apps/api/src/zoho/zoho-sync.service.ts @@ -0,0 +1,25 @@ +import { Injectable } from "@nestjs/common"; +import { SyncStateService } from "../mailbox/sync-state.service"; +import { ZOHO_SYNC_SOURCES, type ZohoSyncSource } from "./zoho.constants"; +import { ZohoMailSyncService } from "./zoho-mail-sync.service"; + +@Injectable() +export class ZohoSyncService { + constructor( + private readonly state: SyncStateService, + private readonly mail: ZohoMailSyncService, + ) {} + + async runOne(userId: string, source: ZohoSyncSource) { + const row = await this.state.get(userId, source); + if (!row) return null; + + return this.mail.sync(row); + } + + async runForUser(userId: string): Promise { + for (const source of ZOHO_SYNC_SOURCES) { + await this.runOne(userId, source); + } + } +} diff --git a/apps/api/src/zoho/zoho.constants.ts b/apps/api/src/zoho/zoho.constants.ts new file mode 100644 index 000000000..b0ac475d6 --- /dev/null +++ b/apps/api/src/zoho/zoho.constants.ts @@ -0,0 +1,43 @@ +export { + SCOPE_FOR_SOURCE, + ZOHO_ACCOUNTS_SCOPE, + ZOHO_FOLDERS_SCOPE, + ZOHO_MESSAGES_SCOPE, + ZOHO_PROVIDER_ID, + ZOHO_SYNC_SCOPES, + ZOHO_SYNC_SOURCES, + type ZohoSyncSource, +} from "../mailbox/mailbox.constants"; + +/** Zoho rejects `Bearer`; its APIs take this scheme instead. */ +export const ZOHO_AUTH_SCHEME = "Zoho-oauthtoken"; + +/** Zoho caps `limit` at 200. Stay well under it — each hit costs two more + * calls per message (headers, then content). */ +export const ZOHO_PAGE_SIZE = 50; + +/** + * A tick reads at most this many messages. Zoho has no batch endpoint, so the + * ceiling is three HTTP calls per message; keeping it modest keeps a tick + * inside the sync service's per-tick budget. + */ +export const ZOHO_MAX_MESSAGES_PER_TICK = 60; + +/** + * Folders whose mail never belongs in a CRM. Matched on `folderType`, which is + * Zoho's stable classification — `folderName` is user-renameable and localised. + */ +export const ZOHO_EXCLUDED_FOLDER_TYPES = [ + "Spam", + "Trash", + "Drafts", + "Outbox", + "Templates", +] as const; + +/** + * Zoho's own conversation id, used as a thread root only when the message + * carries no `References`/`In-Reply-To` of its own. Prefixed so it can never + * collide with a real RFC message id. + */ +export const ZOHO_THREAD_ROOT_PREFIX = "zoho-thread:"; diff --git a/apps/api/src/zoho/zoho.contracts.ts b/apps/api/src/zoho/zoho.contracts.ts new file mode 100644 index 000000000..48e2e424f --- /dev/null +++ b/apps/api/src/zoho/zoho.contracts.ts @@ -0,0 +1,46 @@ +import { GoogleSyncStatus } from "@crm/db"; +import { z } from "zod"; +import { ZOHO_SYNC_SOURCES } from "./zoho.constants"; + +export const setZohoAutoCreateInput = z.object({ + source: z.enum(ZOHO_SYNC_SOURCES), + enabled: z.boolean(), +}); + +export type SetZohoAutoCreateInput = z.infer; + +const zohoSyncStatusOutput = z.enum( + Object.values(GoogleSyncStatus) as [GoogleSyncStatus, ...GoogleSyncStatus[]], +); + +export const zohoSourceStatusOutput = z.object({ + source: z.enum(ZOHO_SYNC_SOURCES), + connected: z.boolean(), + status: zohoSyncStatusOutput.nullable(), + lastSyncedAt: z.string().nullable(), + lastError: z.string().nullable(), + autoCreate: z.boolean(), +}); + +export const zohoConnectionStatusOutput = z.object({ + configured: z.boolean(), + linked: z.boolean(), + required: z.boolean(), + hasRefreshToken: z.boolean(), + sources: z.array(zohoSourceStatusOutput), +}); + +export const zohoPurgeSyncedDataOutput = z.object({ + purged: z.number(), +}); + +export const zohoRevokeAccessOutput = z.object({ + revoked: z.boolean(), +}); + +export type ZohoSourceStatus = z.infer; +export type ZohoConnectionStatus = z.infer; +export type ZohoPurgeSyncedDataOutput = z.infer< + typeof zohoPurgeSyncedDataOutput +>; +export type ZohoRevokeAccessOutput = z.infer; diff --git a/apps/api/src/zoho/zoho.module.ts b/apps/api/src/zoho/zoho.module.ts new file mode 100644 index 000000000..8e9a3395d --- /dev/null +++ b/apps/api/src/zoho/zoho.module.ts @@ -0,0 +1,21 @@ +import { Module } from "@nestjs/common"; +import { MailboxModule } from "../mailbox/mailbox.module"; +import { TrpcModule } from "../trpc/trpc.module"; +import { ZohoRouter } from "./zoho.router"; +import { ZohoConnectionService } from "./zoho-connection.service"; +import { ZohoMailClient } from "./zoho-mail.client"; +import { ZohoMailSyncService } from "./zoho-mail-sync.service"; +import { ZohoSyncService } from "./zoho-sync.service"; + +@Module({ + imports: [TrpcModule, MailboxModule], + providers: [ + ZohoMailClient, + ZohoMailSyncService, + ZohoSyncService, + ZohoConnectionService, + ZohoRouter, + ], + exports: [ZohoSyncService, ZohoConnectionService], +}) +export class ZohoModule {} diff --git a/apps/api/src/zoho/zoho.router.ts b/apps/api/src/zoho/zoho.router.ts new file mode 100644 index 000000000..2e01fe297 --- /dev/null +++ b/apps/api/src/zoho/zoho.router.ts @@ -0,0 +1,82 @@ +import { Inject } from "@nestjs/common"; +import { + Ctx, + Input, + Mutation, + Query, + Router, + UseMiddlewares, +} from "nestjs-trpc"; +import type { z } from "zod"; +import type { AuthedTrpcContext } from "../trpc/context.types"; +import { AuthMiddleware } from "../trpc/middlewares/auth.middleware"; +import { restMeta } from "../trpc/openapi"; +import { + setZohoAutoCreateInput, + zohoConnectionStatusOutput, + zohoPurgeSyncedDataOutput, + zohoRevokeAccessOutput, +} from "./zoho.contracts"; +import { ZohoConnectionService } from "./zoho-connection.service"; +import { ZohoSyncService } from "./zoho-sync.service"; + +@Router({ alias: "zoho" }) +@UseMiddlewares(AuthMiddleware) +export class ZohoRouter { + constructor( + @Inject(ZohoConnectionService) + private readonly connection: ZohoConnectionService, + @Inject(ZohoSyncService) + private readonly sync: ZohoSyncService, + ) {} + + @Query({ + output: zohoConnectionStatusOutput, + meta: restMeta("GET", "/zoho/status", ["Zoho"]), + }) + async status(@Ctx() ctx: AuthedTrpcContext) { + return this.connection.status(ctx.user.id); + } + + @Mutation({ + output: zohoPurgeSyncedDataOutput, + meta: restMeta("POST", "/zoho/purge-synced-data", ["Zoho"]), + }) + async purgeSyncedData(@Ctx() ctx: AuthedTrpcContext) { + return this.connection.purgeSyncedData(ctx.user.id); + } + + @Mutation({ + output: zohoRevokeAccessOutput, + meta: restMeta("POST", "/zoho/revoke", ["Zoho"]), + }) + async revokeAccess(@Ctx() ctx: AuthedTrpcContext) { + return this.connection.revoke(ctx.user.id); + } + + @Mutation({ + output: zohoConnectionStatusOutput, + meta: restMeta("POST", "/zoho/sync", ["Zoho"]), + }) + async syncNow(@Ctx() ctx: AuthedTrpcContext) { + await this.sync.runForUser(ctx.user.id); + return this.connection.status(ctx.user.id); + } + + @Mutation({ + input: setZohoAutoCreateInput, + output: zohoConnectionStatusOutput, + meta: restMeta("PATCH", "/zoho/auto-create", ["Zoho"]), + }) + async setAutoCreate( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + await this.connection.setAutoCreate( + ctx.user.id, + input.source, + input.enabled, + ); + return this.connection.status(ctx.user.id); + } +} diff --git a/apps/api/test/auth.e2e.spec.ts b/apps/api/test/auth.e2e.spec.ts index d5d79131d..cd4e19f73 100644 --- a/apps/api/test/auth.e2e.spec.ts +++ b/apps/api/test/auth.e2e.spec.ts @@ -67,6 +67,9 @@ describe("Auth (e2e)", () => { expect(response.body.result.data).toEqual({ google: true, microsoft: microsoftConfigured, + zoho: Boolean( + process.env.ZOHO_CLIENT_ID && process.env.ZOHO_CLIENT_SECRET, + ), providers: [], }); }); diff --git a/apps/api/test/mailbox-sync-tick.spec.ts b/apps/api/test/mailbox-sync-tick.spec.ts index e721ed07f..39146eae3 100644 --- a/apps/api/test/mailbox-sync-tick.spec.ts +++ b/apps/api/test/mailbox-sync-tick.spec.ts @@ -12,6 +12,8 @@ import { import type { MicrosoftConnectionService } from "../src/microsoft/microsoft-connection.service"; import type { MicrosoftSyncService } from "../src/microsoft/microsoft-sync.service"; import { MailboxSyncService } from "../src/sync/mailbox-sync.service"; +import type { ZohoConnectionService } from "../src/zoho/zoho-connection.service"; +import type { ZohoSyncService } from "../src/zoho/zoho-sync.service"; type Outcome = { source: string; @@ -123,6 +125,8 @@ function build( provider as unknown as MicrosoftSyncService, noConnections as unknown as GoogleConnectionService, noConnections as unknown as MicrosoftConnectionService, + provider as unknown as ZohoSyncService, + noConnections as unknown as ZohoConnectionService, ); } diff --git a/apps/api/turbo.json b/apps/api/turbo.json index 04699fe51..061bdc78f 100644 --- a/apps/api/turbo.json +++ b/apps/api/turbo.json @@ -31,6 +31,9 @@ "MICROSOFT_CLIENT_ID", "MICROSOFT_CLIENT_SECRET", "MICROSOFT_TENANT_ID", + "ZOHO_CLIENT_ID", + "ZOHO_CLIENT_SECRET", + "ZOHO_REGION", "PORT", "REDIS_URL" ] @@ -49,6 +52,9 @@ "MICROSOFT_CLIENT_ID", "MICROSOFT_CLIENT_SECRET", "MICROSOFT_TENANT_ID", + "ZOHO_CLIENT_ID", + "ZOHO_CLIENT_SECRET", + "ZOHO_REGION", "PORT", "REDIS_URL" ] diff --git a/apps/app/app/(app)/[slug]/settings/connections/add-connection-dialog.tsx b/apps/app/app/(app)/[slug]/settings/connections/add-connection-dialog.tsx index fd41e8343..0c4f5cc69 100644 --- a/apps/app/app/(app)/[slug]/settings/connections/add-connection-dialog.tsx +++ b/apps/app/app/(app)/[slug]/settings/connections/add-connection-dialog.tsx @@ -6,6 +6,7 @@ import GoogleLogo from "@crm/ui/components/brand-logos/google"; import MicrosoftLogo from "@crm/ui/components/brand-logos/microsoft"; import SlackLogo from "@crm/ui/components/brand-logos/slack"; import StripeLogo from "@crm/ui/components/brand-logos/stripe"; +import ZohoLogo from "@crm/ui/components/brand-logos/zoho"; import { Dialog, DialogContent, @@ -65,6 +66,14 @@ export function AddConnectionDialog({ href={`/${slug}/settings/connections/microsoft`} /> ) : null} + {!connected.includes("Zoho Mail") ? ( + + ) : null} +

Looking for something else?{" "} diff --git a/apps/app/app/(app)/[slug]/settings/connections/zoho-connection.tsx b/apps/app/app/(app)/[slug]/settings/connections/zoho-connection.tsx new file mode 100644 index 000000000..ae73cc382 --- /dev/null +++ b/apps/app/app/(app)/[slug]/settings/connections/zoho-connection.tsx @@ -0,0 +1,382 @@ +"use client"; + +import Warning from "@carbon/icons-react/es/Warning"; +import { Alert, AlertDescription, AlertTitle } from "@crm/ui/components/alert"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@crm/ui/components/alert-dialog"; +import ZohoLogo from "@crm/ui/components/brand-logos/zoho"; +import { Button } from "@crm/ui/components/button"; +import { + Card, + CardAction, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@crm/ui/components/card"; +import { Icon } from "@crm/ui/components/icon"; +import { Label } from "@crm/ui/components/label"; +import { Spinner } from "@crm/ui/components/spinner"; +import { StatusIndicator } from "@crm/ui/components/status-indicator"; +import { Switch } from "@crm/ui/components/switch"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import Link from "next/link"; +import { useState } from "react"; +import { toast } from "sonner"; +import { LocalRelativeTime } from "@/components/local-date-time"; +import { startMailboxGrant } from "@/lib/mailbox-oauth"; +import { isSyncing, SYNC_POLL_MS } from "@/lib/sync-status"; +import { useCrmCache } from "@/lib/trpc/cache"; +import { useTRPC } from "@/lib/trpc/client"; + +const AUTO_CREATE = "Add the company and contact when you reply to someone new"; + +const CONNECT_ERRORS = new Map([ + [ + "email_doesn't_match", + "That Zoho account has a different email address to the one you sign in with, so it cannot be attached to your account. Connect the Zoho account that matches your sign-in address.", + ], +]); + +function ZohoUnavailable() { + return ( + + + +

+ Zoho Mail + +
+ + + Set ZOHO_CLIENT_ID and ZOHO_CLIENT_SECRET in the root .env file and + restart. + + + + ); +} + +function ConnectZoho({ + slug, + connectError, +}: { + slug: string; + connectError?: string; +}) { + const [pending, setPending] = useState(false); + + function fail(message?: string) { + setPending(false); + toast.error(message ?? "Could not reach Zoho."); + } + + async function handleConnect() { + setPending(true); + + const origin = window.location.origin; + + const { error } = await startMailboxGrant("zoho", { + callbackURL: `${origin}/${slug}/settings/connections/zoho`, + errorCallbackURL: `${origin}/${slug}/settings/connections/zoho?provider=zoho`, + }); + + if (error) fail(error.message); + } + + return ( + + + +
+ Zoho Mail + +
+
+ + Read-only Zoho Mail. Only conversations with companies in the CRM are + stored. + + + + + +
+ + {connectError ? ( + + + + Zoho did not finish connecting + + {CONNECT_ERRORS.get(connectError) ?? + "Zoho returned an error before the connection was made. Try again."} + + + + ) : null} +
+ ); +} + +export function ZohoConnection({ + slug, + connectError, +}: { + slug: string; + connectError?: string; +}) { + const trpc = useTRPC(); + const cache = useCrmCache(); + + const status = useQuery({ + ...trpc.zoho.status.queryOptions(), + refetchInterval: (query) => + query.state.data?.sources.some((source) => isSyncing(source.status)) + ? SYNC_POLL_MS + : false, + }); + + const purge = useMutation( + trpc.zoho.purgeSyncedData.mutationOptions({ + onSuccess: async (result) => { + await cache.zoho(); + toast.success(`Removed ${result.purged} synced items.`); + }, + onError: (error) => toast.error(error.message), + }), + ); + + const revoke = useMutation( + trpc.zoho.revokeAccess.mutationOptions({ + onSuccess: () => + window.location.assign( + status.data?.required ? "/" : `/${slug}/settings/connections`, + ), + onError: (error) => toast.error(error.message), + }), + ); + + const setAutoCreate = useMutation( + trpc.zoho.setAutoCreate.mutationOptions({ + onSuccess: () => cache.zoho({ settle: "record" }), + onError: (error) => toast.error(error.message), + }), + ); + + const syncNow = useMutation( + trpc.zoho.syncNow.mutationOptions({ + onSuccess: () => cache.zoho(), + onError: (error) => toast.error(error.message), + }), + ); + + if (!status.data) return null; + + const { sources, hasRefreshToken, configured, linked, required } = + status.data; + + if (!configured) return ; + if (!linked) { + return ; + } + + const failing = sources.filter( + (source) => source.status === "NEEDS_RECONNECT" || source.lastError, + ); + const lastSyncedAt = sources + .map((source) => source.lastSyncedAt) + .filter((at): at is string => at !== null) + .sort() + .at(-1); + + const healthy = failing.length === 0 && hasRefreshToken; + + return ( + + + +
+ Zoho Mail + +
+
+ + Email threads land on the matching company as they happen. + + + + + +
+ + + {!hasRefreshToken ? ( + + + Zoho did not return a refresh token + + Disconnect and reconnect — Zoho only issues one while the consent + screen is being shown. + + + ) : failing.length > 0 ? ( + failing.map((source) => ( + + + Email sync failed + + {source.lastError ?? "Zoho needs reconnecting."} + + + )) + ) : ( +

+ {lastSyncedAt ? ( + <> + Last checked + + ) : ( + "Waiting for the first check" + )} +

+ )} + + {sources.map((source) => ( +
+ + + + setAutoCreate.mutate({ source: source.source, enabled }) + } + /> +
+ ))} + + +
+ + + + + + + + Delete synced data? + + Every email brought in from Zoho Mail is removed from the + CRM. The next check starts from now, so nothing deleted here + comes back. + + + + + Cancel + purge.mutate()} + > + Delete + + + + + + + + + + + + + Disconnect Zoho? + + {required + ? "You will be signed out, and you cannot use the CRM again until you grant access." + : "New email stops arriving. Everything already synced stays, and you can connect Zoho again from this page."}{" "} + The stored tokens are cleared here — revoke the app from + your Zoho account to withdraw consent at Zoho's end too. + + + + + Cancel + revoke.mutate()} + > + Disconnect + + + + + + +
+
+
+
+ ); +} diff --git a/apps/app/app/(app)/[slug]/settings/connections/zoho/page.tsx b/apps/app/app/(app)/[slug]/settings/connections/zoho/page.tsx new file mode 100644 index 000000000..5d3bc25c9 --- /dev/null +++ b/apps/app/app/(app)/[slug]/settings/connections/zoho/page.tsx @@ -0,0 +1,21 @@ +import type { Metadata } from "next"; +import { + type ConnectionQuery, + OAuthConnectionPage, +} from "../oauth-connection-page"; +import { ZohoConnection } from "../zoho-connection"; + +export const metadata: Metadata = { title: "Zoho Mail" }; + +export default function ZohoConnectionPage(props: { + params: Promise<{ slug: string }>; + searchParams: Promise; +}) { + return ( + + ); +} diff --git a/apps/app/app/(landing)/grant-access/grant-access.tsx b/apps/app/app/(landing)/grant-access/grant-access.tsx index 9f5ee1fad..ce4ed72ba 100644 --- a/apps/app/app/(landing)/grant-access/grant-access.tsx +++ b/apps/app/app/(landing)/grant-access/grant-access.tsx @@ -1,37 +1,26 @@ "use client"; -import { authClient } from "@crm/auth/client"; -import { - type MailboxProviderId, - MICROSOFT_SYNC_SCOPES, - SYNC_SCOPES, -} from "@crm/auth/scopes"; +import type { MailboxProviderId } from "@crm/auth/scopes"; import GoogleLogo from "@crm/ui/components/brand-logos/google"; import MicrosoftLogo from "@crm/ui/components/brand-logos/microsoft"; +import ZohoLogo from "@crm/ui/components/brand-logos/zoho"; import { Button } from "@crm/ui/components/button"; import { Spinner } from "@crm/ui/components/spinner"; import type { FC, SVGProps } from "react"; import { useState } from "react"; import { toast } from "sonner"; +import { startMailboxGrant } from "@/lib/mailbox-oauth"; import { signOutAndRedirect } from "@/lib/sign-out"; type ProviderGrant = { label: string; - scopes: readonly string[]; Logo: FC>; }; const PROVIDERS = { - google: { - label: "Grant Google access", - scopes: [...SYNC_SCOPES], - Logo: GoogleLogo, - }, - microsoft: { - label: "Grant Microsoft access", - scopes: [...MICROSOFT_SYNC_SCOPES], - Logo: MicrosoftLogo, - }, + google: { label: "Grant Google access", Logo: GoogleLogo }, + microsoft: { label: "Grant Microsoft access", Logo: MicrosoftLogo }, + zoho: { label: "Grant Zoho Mail access", Logo: ZohoLogo }, } as const satisfies Record; export function GrantAccess({ @@ -51,9 +40,7 @@ export function GrantAccess({ const origin = window.location.origin; - const { error } = await authClient.linkSocial({ - provider, - scopes: [...PROVIDERS[provider].scopes], + const { error } = await startMailboxGrant(provider, { callbackURL: `${origin}/`, errorCallbackURL: `${origin}/grant-access`, }); diff --git a/apps/app/app/(landing)/grant-access/page.tsx b/apps/app/app/(landing)/grant-access/page.tsx index 39d306de4..2274c2680 100644 --- a/apps/app/app/(landing)/grant-access/page.tsx +++ b/apps/app/app/(landing)/grant-access/page.tsx @@ -16,6 +16,7 @@ const DESCRIPTION = { "This CRM reads your Gmail and Calendar so meetings and email threads show up on the right company. It is read-only — nothing is ever sent on your behalf.", microsoft: "This CRM reads your Outlook mail so email threads show up on the right company. It is read-only — nothing is ever sent on your behalf.", + zoho: "This CRM reads your Zoho Mail so email threads show up on the right company. It is read-only — nothing is ever sent on your behalf.", } satisfies Record; const BOTH = diff --git a/apps/app/app/(landing)/sign-in/page.tsx b/apps/app/app/(landing)/sign-in/page.tsx index 5b0e451af..fd4349467 100644 --- a/apps/app/app/(landing)/sign-in/page.tsx +++ b/apps/app/app/(landing)/sign-in/page.tsx @@ -15,6 +15,7 @@ export const metadata: Metadata = { type SignInOptions = { google: boolean; microsoft: boolean; + zoho: boolean; providers: SsoProvider[]; }; @@ -73,6 +74,7 @@ async function SignIn({ const configured: MailboxProviderId[] = []; if (options?.google ?? true) configured.push("google"); if (options?.microsoft ?? false) configured.push("microsoft"); + if (options?.zoho ?? false) configured.push("zoho"); const providers = options?.providers ?? []; @@ -94,10 +96,10 @@ async function SignIn({ />

- Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET — or MICROSOFT_CLIENT_ID - and MICROSOFT_CLIENT_SECRET — in the root .env file and restart. Your - own identity provider can be added from Settings once somebody is - signed in. + Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET — or the MICROSOFT_ or + ZOHO_ equivalents — in the root .env file and restart. Your own + identity provider can be added from Settings once somebody is signed + in.

); diff --git a/apps/app/app/(landing)/sign-in/social-sign-in.tsx b/apps/app/app/(landing)/sign-in/social-sign-in.tsx index 882f89c12..319978332 100644 --- a/apps/app/app/(landing)/sign-in/social-sign-in.tsx +++ b/apps/app/app/(landing)/sign-in/social-sign-in.tsx @@ -1,14 +1,15 @@ "use client"; -import { signIn } from "@crm/auth/client"; import type { MailboxProviderId } from "@crm/auth/scopes"; import GoogleLogo from "@crm/ui/components/brand-logos/google"; import MicrosoftLogo from "@crm/ui/components/brand-logos/microsoft"; +import ZohoLogo from "@crm/ui/components/brand-logos/zoho"; import { Button } from "@crm/ui/components/button"; import { Spinner } from "@crm/ui/components/spinner"; import type { FC, SVGProps } from "react"; import { useState } from "react"; import { toast } from "sonner"; +import { startMailboxSignIn } from "@/lib/mailbox-oauth"; type ProviderChoice = { label: string; @@ -18,6 +19,7 @@ type ProviderChoice = { const PROVIDERS = { google: { label: "Continue with Google", Logo: GoogleLogo }, microsoft: { label: "Continue with Microsoft", Logo: MicrosoftLogo }, + zoho: { label: "Continue with Zoho", Logo: ZohoLogo }, } as const satisfies Record; export function SocialSignIn({ provider }: { provider: MailboxProviderId }) { @@ -35,8 +37,7 @@ export function SocialSignIn({ provider }: { provider: MailboxProviderId }) { const origin = window.location.origin; - const { error } = await signIn.social({ - provider, + const { error } = await startMailboxSignIn(provider, { callbackURL: `${origin}/`, errorCallbackURL: `${origin}/sign-in`, }); diff --git a/apps/app/lib/mailbox-oauth.ts b/apps/app/lib/mailbox-oauth.ts new file mode 100644 index 000000000..70047b715 --- /dev/null +++ b/apps/app/lib/mailbox-oauth.ts @@ -0,0 +1,60 @@ +import { authClient } from "@crm/auth/client"; +import { + GOOGLE_PROVIDER_ID, + type MailboxProviderId, + MICROSOFT_SYNC_SCOPES, + SYNC_SCOPES, + ZOHO_PROVIDER_ID, +} from "@crm/auth/scopes"; + +type Redirects = { + callbackURL: string; + errorCallbackURL: string; +}; + +type Started = { error?: { message?: string } | null }; + +/** + * Google and Microsoft are better-auth social providers; Zoho is a generic + * OAuth provider, so it goes through `oauth2` rather than `social`/`linkSocial` + * and takes its scopes from the plugin config instead of the call site. + * + * Every mailbox provider is routed through this pair so a fourth one has one + * obvious place to be added, and so no screen has to know which kind it is. + */ + +export async function startMailboxSignIn( + provider: MailboxProviderId, + redirects: Redirects, +): Promise { + if (provider === ZOHO_PROVIDER_ID) { + return authClient.signIn.oauth2({ + providerId: ZOHO_PROVIDER_ID, + ...redirects, + }); + } + + return authClient.signIn.social({ provider, ...redirects }); +} + +export async function startMailboxGrant( + provider: MailboxProviderId, + redirects: Redirects, +): Promise { + if (provider === ZOHO_PROVIDER_ID) { + return authClient.oauth2.link({ + providerId: ZOHO_PROVIDER_ID, + ...redirects, + }); + } + + return authClient.linkSocial({ + provider, + scopes: [ + ...(provider === GOOGLE_PROVIDER_ID + ? SYNC_SCOPES + : MICROSOFT_SYNC_SCOPES), + ], + ...redirects, + }); +} diff --git a/apps/app/lib/trpc/cache.ts b/apps/app/lib/trpc/cache.ts index 790e37211..b7749860b 100644 --- a/apps/app/lib/trpc/cache.ts +++ b/apps/app/lib/trpc/cache.ts @@ -34,6 +34,7 @@ export type CrmCache = { activity(options?: Options): Promise; google(options?: Options): Promise; microsoft(options?: Options): Promise; + zoho(options?: Options): Promise; settings(options?: Options): Promise; currency(options?: Options): Promise; workspace(options?: Options): Promise; @@ -271,6 +272,19 @@ export function useCrmCache(): CrmCache { options, ), + zoho: (options) => + run( + [trpc.zoho.status.queryKey()], + [ + ...activityKeys(), + ...listKeys(), + trpc.companies.byId.queryKey(), + trpc.contacts.byId.queryKey(), + trpc.dashboard.summary.queryKey(), + ], + options, + ), + settings: (options) => run( [ diff --git a/packages/auth/src/auth.ts b/packages/auth/src/auth.ts index c59c98254..82bfdc151 100644 --- a/packages/auth/src/auth.ts +++ b/packages/auth/src/auth.ts @@ -5,7 +5,10 @@ import { schemas } from "@crm/validation"; import { type BetterAuthOptions, betterAuth } from "better-auth"; import { prismaAdapter } from "better-auth/adapters/prisma"; import { APIError } from "better-auth/api"; -import { genericOAuth } from "better-auth/plugins/generic-oauth"; +import { + type GenericOAuthConfig, + genericOAuth, +} from "better-auth/plugins/generic-oauth"; import { organization } from "better-auth/plugins/organization"; import { API_KEY_EXPIRATION, API_KEY_HEADER, API_KEY_PREFIX } from "./api-keys"; import { AUTH_COOKIE_PREFIX } from "./cookies"; @@ -17,6 +20,8 @@ import { MICROSOFT_SYNC_SCOPES, SLACK_PROVIDER_ID, SYNC_SCOPES, + ZOHO_PROVIDER_ID, + ZOHO_REQUESTED_SCOPES, } from "./scopes"; import { notifySignedIn } from "./signed-in"; import { slackConnectGuard } from "./slack-connect"; @@ -31,10 +36,13 @@ import { const socialProviders: NonNullable = {}; const slackOAuth = env.slack; -const slackRedirectUri = new URL( - "/api/auth/oauth2/callback/slack", - env.apiUrl, -).toString(); +const zohoOAuth = env.zoho; + +const oauth2RedirectUri = (providerId: string) => + new URL(`/api/auth/oauth2/callback/${providerId}`, env.apiUrl).toString(); + +const slackRedirectUri = oauth2RedirectUri(SLACK_PROVIDER_ID); +const zohoRedirectUri = oauth2RedirectUri(ZOHO_PROVIDER_ID); if (env.google) { const google: NonNullable = { @@ -69,6 +77,133 @@ if (env.microsoft) { }; } +const oauth2Providers: GenericOAuthConfig[] = []; + +if (slackOAuth) { + oauth2Providers.push({ + providerId: SLACK_PROVIDER_ID, + authorizationUrl: "https://slack.com/oauth/v2/authorize", + tokenUrl: "https://slack.com/api/oauth.v2.access", + clientId: slackOAuth.clientId, + clientSecret: slackOAuth.clientSecret, + disableSignUp: true, + redirectURI: slackRedirectUri, + scopes: [...SLACK_REQUESTED_SCOPES], + authorizationUrlParams: { + user_scope: SLACK_USER_SCOPES.join(","), + }, + getToken: async ({ code }) => { + const response = await fetch("https://slack.com/api/oauth.v2.access", { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + client_id: slackOAuth.clientId, + client_secret: slackOAuth.clientSecret, + code, + redirect_uri: slackRedirectUri, + }), + }); + const grant = schemas.slack.oauthAccess.parse(await response.json()); + if (!response.ok || !grant.ok || !grant.access_token) { + throw new APIError("BAD_REQUEST", { + message: `Slack authorization failed (${grant.error ?? "rejected"}).`, + }); + } + await rememberSlackInstall(grant); + + return { + accessToken: grant.access_token, + tokenType: grant.token_type, + scopes: (grant.scope ?? "") + .split(",") + .map((scope) => scope.trim()) + .filter(Boolean), + raw: grant, + }; + }, + getUserInfo: async (tokens) => { + try { + const granted = schemas.slack.oauthAccess.parse(tokens.raw); + const userId = granted.authed_user?.id; + if (!tokens.accessToken || !userId) return null; + const userResponse = await fetch( + `https://slack.com/api/users.info?user=${encodeURIComponent(userId)}`, + { + headers: { + Authorization: `Bearer ${tokens.accessToken}`, + }, + }, + ); + const profile = schemas.slack.userInfo.parse(await userResponse.json()); + if (!userResponse.ok || !profile.ok) return null; + const details = profile.user.profile; + const email = details.email; + if (!email) return null; + return { + id: userId, + name: details.real_name ?? profile.user.name ?? email, + email, + emailVerified: true, + image: details.image_512, + }; + } catch { + return null; + } + }, + }); +} + +if (zohoOAuth) { + oauth2Providers.push({ + providerId: ZOHO_PROVIDER_ID, + authorizationUrl: zohoOAuth.endpoints.authorizationUrl, + tokenUrl: zohoOAuth.endpoints.tokenUrl, + clientId: zohoOAuth.clientId, + clientSecret: zohoOAuth.clientSecret, + redirectURI: zohoRedirectUri, + disableSignUp: false, + + // Zoho delimits scopes with commas, not spaces. better-auth joins the + // array with a space, so the whole list is handed over as one element. + scopes: [ZOHO_REQUESTED_SCOPES.join(",")], + + // Without both of these Zoho issues an access token and no refresh + // token, and the connection silently dies an hour later. + authorizationUrlParams: { + access_type: "offline", + prompt: "consent", + }, + + getUserInfo: async (tokens) => { + if (!tokens.accessToken) return null; + + try { + const response = await fetch(zohoOAuth.endpoints.userInfoUrl, { + headers: { + Authorization: `Zoho-oauthtoken ${tokens.accessToken}`, + Accept: "application/json", + }, + }); + + if (!response.ok) return null; + + const profile = schemas.zoho.userInfo.parse(await response.json()); + + return { + id: profile.ZUID, + email: profile.Email, + name: profile.Display_Name ?? profile.Email, + emailVerified: true, + }; + } catch { + return null; + } + }, + }); +} + export const auth = betterAuth({ appName: "CRM", baseURL: env.apiUrl, @@ -86,7 +221,11 @@ export const auth = betterAuth({ account: { accountLinking: { enabled: true, - trustedProviders: [GOOGLE_PROVIDER_ID, MICROSOFT_PROVIDER_ID], + trustedProviders: [ + GOOGLE_PROVIDER_ID, + MICROSOFT_PROVIDER_ID, + ZOHO_PROVIDER_ID, + ], }, }, @@ -122,93 +261,8 @@ export const auth = betterAuth({ }, plugins: [ - ...(slackOAuth - ? [ - genericOAuth({ - config: [ - { - providerId: SLACK_PROVIDER_ID, - authorizationUrl: "https://slack.com/oauth/v2/authorize", - tokenUrl: "https://slack.com/api/oauth.v2.access", - clientId: slackOAuth.clientId, - clientSecret: slackOAuth.clientSecret, - disableSignUp: true, - redirectURI: slackRedirectUri, - scopes: [...SLACK_REQUESTED_SCOPES], - authorizationUrlParams: { - user_scope: SLACK_USER_SCOPES.join(","), - }, - getToken: async ({ code }) => { - const response = await fetch( - "https://slack.com/api/oauth.v2.access", - { - method: "POST", - headers: { - "content-type": "application/x-www-form-urlencoded", - }, - body: new URLSearchParams({ - client_id: slackOAuth.clientId, - client_secret: slackOAuth.clientSecret, - code, - redirect_uri: slackRedirectUri, - }), - }, - ); - const grant = schemas.slack.oauthAccess.parse( - await response.json(), - ); - if (!response.ok || !grant.ok || !grant.access_token) { - throw new APIError("BAD_REQUEST", { - message: `Slack authorization failed (${grant.error ?? "rejected"}).`, - }); - } - await rememberSlackInstall(grant); - - return { - accessToken: grant.access_token, - tokenType: grant.token_type, - scopes: (grant.scope ?? "") - .split(",") - .map((scope) => scope.trim()) - .filter(Boolean), - raw: grant, - }; - }, - getUserInfo: async (tokens) => { - try { - const granted = schemas.slack.oauthAccess.parse(tokens.raw); - const userId = granted.authed_user?.id; - if (!tokens.accessToken || !userId) return null; - const userResponse = await fetch( - `https://slack.com/api/users.info?user=${encodeURIComponent(userId)}`, - { - headers: { - Authorization: `Bearer ${tokens.accessToken}`, - }, - }, - ); - const profile = schemas.slack.userInfo.parse( - await userResponse.json(), - ); - if (!userResponse.ok || !profile.ok) return null; - const details = profile.user.profile; - const email = details.email; - if (!email) return null; - return { - id: userId, - name: details.real_name ?? profile.user.name ?? email, - email, - emailVerified: true, - image: details.image_512, - }; - } catch { - return null; - } - }, - }, - ], - }), - ] + ...(oauth2Providers.length > 0 + ? [genericOAuth({ config: oauth2Providers })] : []), organization({ allowUserToCreateOrganization: false, diff --git a/packages/auth/src/env.ts b/packages/auth/src/env.ts index 9813d7a44..05d3adcd9 100644 --- a/packages/auth/src/env.ts +++ b/packages/auth/src/env.ts @@ -1,4 +1,10 @@ import "@crm/env/load"; +import { + toZohoRegion, + type ZohoEndpoints, + type ZohoRegion, + zohoEndpoints, +} from "./zoho-region"; const DEFAULT_API_URL = "http://localhost:3001"; const DEFAULT_APP_URL = "http://localhost:3000"; @@ -42,6 +48,22 @@ const microsoftCredentials = (): }; }; +const zohoCredentials = (): + | { + clientId: string; + clientSecret: string; + region: ZohoRegion; + endpoints: ZohoEndpoints; + } + | undefined => { + const credentials = pair("ZOHO_CLIENT_ID", "ZOHO_CLIENT_SECRET"); + if (!credentials) return undefined; + + const region = toZohoRegion(optional("ZOHO_REGION")); + + return { ...credentials, region, endpoints: zohoEndpoints(region) }; +}; + const slackCredentials = (): | { clientId: string; clientSecret: string } | undefined => pair("SLACK_CLIENT_ID", "SLACK_CLIENT_SECRET"); @@ -62,6 +84,7 @@ export const env = { google: googleCredentials(), microsoft: microsoftCredentials(), slack: slackCredentials(), + zoho: zohoCredentials(), cookieDomain: optional("AUTH_COOKIE_DOMAIN"), trustedOrigins: [...new Set([...appUrls, apiUrl])], isProduction: process.env.NODE_ENV === "production", @@ -79,4 +102,12 @@ export function isSlackConfigured(): boolean { return env.slack !== undefined; } +export function isZohoConfigured(): boolean { + return env.zoho !== undefined; +} + export { apiUrl, appUrl }; + +export function zohoConfig() { + return env.zoho; +} diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index 31fbcbaad..4def85c32 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -12,6 +12,8 @@ export { isGoogleConfigured, isMicrosoftConfigured, isSlackConfigured, + isZohoConfigured, + zohoConfig, } from "./env"; export { canChangeRole, @@ -51,6 +53,14 @@ export { signsInOnlyWith, signsInWithGoogle, signsInWithMicrosoft, + signsInWithZoho, + ZOHO_ACCOUNTS_SCOPE, + ZOHO_FOLDERS_SCOPE, + ZOHO_MESSAGES_SCOPE, + ZOHO_PROFILE_SCOPE, + ZOHO_PROVIDER_ID, + ZOHO_REQUESTED_SCOPES, + ZOHO_SYNC_SCOPES, } from "./scopes"; export { onSignedIn, type SignedInHandler } from "./signed-in"; export { @@ -78,3 +88,12 @@ export { primaryWorkspaceDomain, workspaceDomains, } from "./workspace"; +export { + DEFAULT_ZOHO_REGION, + isZohoRegion, + toZohoRegion, + ZOHO_REGIONS, + type ZohoEndpoints, + type ZohoRegion, + zohoEndpoints, +} from "./zoho-region"; diff --git a/packages/auth/src/scopes.ts b/packages/auth/src/scopes.ts index 86cb9a31d..25841b5fd 100644 --- a/packages/auth/src/scopes.ts +++ b/packages/auth/src/scopes.ts @@ -1,10 +1,12 @@ export const GOOGLE_PROVIDER_ID = "google"; export const MICROSOFT_PROVIDER_ID = "microsoft"; export const SLACK_PROVIDER_ID = "slack"; +export const ZOHO_PROVIDER_ID = "zoho"; export const MAILBOX_PROVIDER_IDS = [ GOOGLE_PROVIDER_ID, MICROSOFT_PROVIDER_ID, + ZOHO_PROVIDER_ID, ] as const; export type MailboxProviderId = (typeof MAILBOX_PROVIDER_IDS)[number]; @@ -16,12 +18,36 @@ export const CALENDAR_SCOPE = "https://www.googleapis.com/auth/calendar.readonly"; export const OUTLOOK_MAIL_SCOPE = "Mail.Read"; +// Zoho scopes are `Service.resource.OPERATION`. Reading a mailbox needs all +// three: `accounts` to resolve the numeric account id every other call is keyed +// on, `folders` to know which folder is Spam or Trash, and `messages` to list +// and fetch the mail itself. +export const ZOHO_ACCOUNTS_SCOPE = "ZohoMail.accounts.READ"; +export const ZOHO_FOLDERS_SCOPE = "ZohoMail.folders.READ"; +export const ZOHO_MESSAGES_SCOPE = "ZohoMail.messages.READ"; + +// `email` and `profile` here are Zoho's own OIDC scopes, not Google's. They are +// what makes `/oauth/user/info` return the signed-in address, which is the only +// way to tell whose mailbox we just connected. +export const ZOHO_PROFILE_SCOPE = "AaaServer.profile.READ"; + export const SYNC_SCOPES = [GMAIL_SCOPE, CALENDAR_SCOPE] as const; export const MICROSOFT_SYNC_SCOPES = [OUTLOOK_MAIL_SCOPE] as const; +export const ZOHO_SYNC_SCOPES = [ + ZOHO_ACCOUNTS_SCOPE, + ZOHO_FOLDERS_SCOPE, + ZOHO_MESSAGES_SCOPE, +] as const; + +export const ZOHO_REQUESTED_SCOPES = [ + ...ZOHO_SYNC_SCOPES, + ZOHO_PROFILE_SCOPE, +] as const; export const SYNC_SCOPES_FOR = { [GOOGLE_PROVIDER_ID]: SYNC_SCOPES, [MICROSOFT_PROVIDER_ID]: MICROSOFT_SYNC_SCOPES, + [ZOHO_PROVIDER_ID]: ZOHO_SYNC_SCOPES, } satisfies Record; export const REQUIRED_SCOPES = [...IDENTITY_SCOPES, ...SYNC_SCOPES] as const; @@ -69,6 +95,10 @@ export function signsInWithMicrosoft( return signsInOnlyWith(accounts, MICROSOFT_PROVIDER_ID); } +export function signsInWithZoho(accounts: readonly SignInAccount[]): boolean { + return signsInOnlyWith(accounts, ZOHO_PROVIDER_ID); +} + export function mailboxGrantsNeeded( accounts: readonly SignInAccount[], ): MailboxProviderId[] { diff --git a/packages/auth/src/zoho-region.ts b/packages/auth/src/zoho-region.ts new file mode 100644 index 000000000..f844b57b5 --- /dev/null +++ b/packages/auth/src/zoho-region.ts @@ -0,0 +1,60 @@ +// Zoho is not one deployment: an account lives in exactly one data centre, and +// every host name — accounts, mail API, and the web client a deep link points +// at — carries that data centre's suffix. A token minted in `.com` is rejected +// by `.eu`, so this is not cosmetic. `ZOHO_REGION` names the suffix. + +export const ZOHO_REGIONS = [ + "com", + "eu", + "in", + "com.au", + "jp", + "ca", + "sa", + "com.cn", +] as const; + +export type ZohoRegion = (typeof ZOHO_REGIONS)[number]; + +export const DEFAULT_ZOHO_REGION: ZohoRegion = "com"; + +export function isZohoRegion(value: string): value is ZohoRegion { + return (ZOHO_REGIONS as readonly string[]).includes(value); +} + +export function toZohoRegion(value: string | undefined): ZohoRegion { + if (!value) return DEFAULT_ZOHO_REGION; + + const trimmed = value.trim().toLowerCase().replace(/^\./, ""); + if (!isZohoRegion(trimmed)) { + throw new Error( + `ZOHO_REGION must be one of ${ZOHO_REGIONS.join(", ")} — got "${value}".`, + ); + } + + return trimmed; +} + +export type ZohoEndpoints = { + region: ZohoRegion; + authorizationUrl: string; + tokenUrl: string; + revokeUrl: string; + userInfoUrl: string; + mailApiBase: string; + mailWebBase: string; +}; + +export function zohoEndpoints(region: ZohoRegion): ZohoEndpoints { + const accounts = `https://accounts.zoho.${region}`; + + return { + region, + authorizationUrl: `${accounts}/oauth/v2/auth`, + tokenUrl: `${accounts}/oauth/v2/token`, + revokeUrl: `${accounts}/oauth/v2/token/revoke`, + userInfoUrl: `${accounts}/oauth/user/info`, + mailApiBase: `https://mail.zoho.${region}/api`, + mailWebBase: `https://mail.zoho.${region}`, + }; +} diff --git a/packages/db/prisma/migrations/20260905120000_zoho_mail/migration.sql b/packages/db/prisma/migrations/20260905120000_zoho_mail/migration.sql new file mode 100644 index 000000000..6af263419 --- /dev/null +++ b/packages/db/prisma/migrations/20260905120000_zoho_mail/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "emailMessage" ADD COLUMN "zohoMessageId" TEXT; +ALTER TABLE "emailMessage" ADD COLUMN "zohoWebLink" TEXT; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 82bc1f368..6ed73b1ab 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -1211,6 +1211,8 @@ model EmailMessage { gmailMessageId String? outlookMessageId String? outlookWebLink String? + zohoMessageId String? + zohoWebLink String? direction EmailDirection fromEmail String diff --git a/packages/telemetry/src/allowlist.ts b/packages/telemetry/src/allowlist.ts index 5880dc2bd..de7a31c68 100644 --- a/packages/telemetry/src/allowlist.ts +++ b/packages/telemetry/src/allowlist.ts @@ -228,7 +228,12 @@ export function permittedTaskKind(kind: string | null | undefined): string { return kind && TASK_KIND_SET.has(kind) ? kind : OTHER; } -export const SYNC_SOURCES = ["gmail", "calendar", "outlook"] as const; +export const SYNC_SOURCES = [ + "gmail", + "calendar", + "outlook", + "zohomail", +] as const; export type TelemetrySyncSource = (typeof SYNC_SOURCES)[number]; @@ -244,6 +249,7 @@ const SYNC_ERROR_SOURCES = { gmail: "google_sync", calendar: "google_sync", outlook: "microsoft_sync", + zohomail: "zoho_sync", } as const satisfies Record; export function permittedSyncErrorSource( diff --git a/packages/ui/src/components/brand-logos/zoho.tsx b/packages/ui/src/components/brand-logos/zoho.tsx new file mode 100644 index 000000000..18ace3c88 --- /dev/null +++ b/packages/ui/src/components/brand-logos/zoho.tsx @@ -0,0 +1,22 @@ +import type * as React from "react"; + +/** + * Zoho's mark, drawn as the four brand bars rather than the wordmark so it + * reads at 16px next to the other connection logos. + */ +const ZohoLogo = (props: React.SVGProps) => ( + +); + +export default ZohoLogo; diff --git a/packages/validation/src/index.ts b/packages/validation/src/index.ts index 0cf43c9ca..68e735ad2 100644 --- a/packages/validation/src/index.ts +++ b/packages/validation/src/index.ts @@ -7,6 +7,7 @@ import * as builderQuestion from "./builder-question"; import * as eveStream from "./eve-stream"; import * as eveTool from "./eve-tool"; import * as slack from "./slack"; +import * as zoho from "./zoho"; export const schemas = { activityMeta, @@ -17,6 +18,7 @@ export const schemas = { eveStream, eveTool, slack, + zoho, } as const; export type { ActivityMeta, ActivityMetaFields } from "./activity-meta"; diff --git a/packages/validation/src/zoho.ts b/packages/validation/src/zoho.ts new file mode 100644 index 000000000..dd2819618 --- /dev/null +++ b/packages/validation/src/zoho.ts @@ -0,0 +1,129 @@ +import { z } from "zod"; + +// Every Zoho Mail response is wrapped in `{ status: { code, description }, data }`. +// The HTTP status is usually right, but not always — parse the envelope and +// trust `status.code`, then parse `data` into the shape the caller asked for. +export const envelope = z.object({ + status: z.object({ + code: z.number(), + description: z.string().optional(), + }), + data: z.unknown().optional(), +}); + +// Zoho hands numeric ids back as either a JSON number or a string, sometimes +// varying between endpoints for the same id. They are opaque to us, so coerce +// every one of them to a string once, here, and never think about it again. +const id = z.union([z.string(), z.number()]).transform(String); + +// Timestamps arrive as epoch milliseconds in a string ("1709887053409"). +const epochMillis = z + .union([z.string(), z.number()]) + .transform((value) => Number(value)) + .refine((value) => Number.isFinite(value) && value > 0, { + message: "Not an epoch timestamp.", + }); + +const optionalEpochMillis = epochMillis.optional().catch(undefined); + +// `Not Provided` is Zoho's literal string for an absent address list. +const NOT_PROVIDED = "Not Provided"; + +const addressLine = z + .string() + .optional() + .transform((value) => { + const trimmed = value?.trim(); + if (!trimmed || trimmed === NOT_PROVIDED) return null; + return trimmed; + }); + +export const oauthToken = z.object({ + access_token: z.string().min(1).optional(), + refresh_token: z.string().min(1).optional(), + token_type: z.string().optional(), + scope: z.string().optional(), + // Zoho has shipped this both as seconds and as milliseconds across API + // versions. The caller normalises; the schema only insists it is a number. + expires_in: z.number().optional(), + error: z.string().optional(), +}); + +export type ZohoOAuthToken = z.infer; + +export const userInfo = z.object({ + ZUID: z.union([z.string(), z.number()]).transform(String), + Email: z.string().email(), + Display_Name: z.string().optional(), + First_Name: z.string().optional(), + Last_Name: z.string().optional(), +}); + +export type ZohoUserInfo = z.infer; + +export const account = z.object({ + accountId: id, + // `mailboxAddress` is the address that actually receives mail; + // `primaryEmailAddress` is what the UI shows. They differ on aliases. + mailboxAddress: z.string().optional(), + primaryEmailAddress: z.string().optional(), + // "ZOHO_ACCOUNT" for a real Zoho mailbox, "IMAP_ACCOUNT" for an external + // mailbox that has been POP/IMAP-attached. Only the former is syncable. + type: z.string().optional(), + enabled: z.boolean().optional(), + accountDisplayName: z.string().optional(), +}); + +export type ZohoAccount = z.infer; + +export const accounts = z.array(account); + +export const folder = z.object({ + folderId: id, + folderName: z.string(), + // "Inbox" | "Sent" | "Drafts" | "Spam" | "Trash" | "Outbox" | "Templates"… + // A user-made folder reports the type of the folder it lives under, so this + // cannot be used to identify a specific folder — only to exclude classes. + folderType: z.string().optional(), + path: z.string().optional(), +}); + +export type ZohoFolder = z.infer; + +export const folders = z.array(folder); + +export const messageSummary = z.object({ + messageId: id, + folderId: id, + threadId: id.optional(), + subject: z.string().optional(), + summary: z.string().optional(), + fromAddress: z.string().optional(), + sender: z.string().optional(), + toAddress: addressLine, + ccAddress: addressLine, + sentDateInGMT: optionalEpochMillis, + receivedTime: optionalEpochMillis, + hasAttachment: z.union([z.string(), z.number()]).optional(), +}); + +export type ZohoMessageSummary = z.infer; + +export const messageList = z.array(messageSummary); + +export const messageContent = z.object({ + messageId: id, + content: z.string().optional(), + blockContent: z.string().optional(), +}); + +export type ZohoMessageContent = z.infer; + +// `?raw=false` returns headers as a name -> values map. Header names keep the +// casing the sending server used, so the reader lower-cases before lookup. +export const messageHeaders = z.object({ + messageId: id, + headerContent: z.record(z.string(), z.array(z.string())), +}); + +export type ZohoMessageHeaders = z.infer; diff --git a/turbo.json b/turbo.json index 110f9bd94..a72a9f065 100644 --- a/turbo.json +++ b/turbo.json @@ -14,6 +14,9 @@ "GOOGLE_CLIENT_SECRET", "MICROSOFT_CLIENT_ID", "MICROSOFT_CLIENT_SECRET", + "ZOHO_CLIENT_ID", + "ZOHO_CLIENT_SECRET", + "ZOHO_REGION", "SLACK_CLIENT_ID", "SLACK_CLIENT_SECRET", "MICROSOFT_TENANT_ID", From 66ecc631f155c2bc0a39f40f87dcab03f3ec5f34 Mon Sep 17 00:00:00 2001 From: WifiDan Date: Sat, 5 Sep 2026 18:46:55 -0600 Subject: [PATCH 2/2] test(api): cover the zoho adapter, and document the provider --- .env.example | 21 ++ README.md | 40 ++- adrs/zoho-mail.md | 67 ++++ apps/api/src/zoho/zoho-mail.client.ts | 41 +-- apps/api/src/zoho/zoho.constants.ts | 3 + apps/api/src/zoho/zoho.module.ts | 8 + apps/api/test/mailbox-scopes.spec.ts | 30 ++ apps/api/test/zoho-mail-client.spec.ts | 190 ++++++++++ apps/api/test/zoho-sync.spec.ts | 430 +++++++++++++++++++++++ docs/api.md | 33 +- docs/environment.md | 22 +- docs/telemetry.md | 2 +- packages/auth/test/mailbox-grant.spec.ts | 49 +++ packages/validation/src/zoho.ts | 7 +- 14 files changed, 900 insertions(+), 43 deletions(-) create mode 100644 adrs/zoho-mail.md create mode 100644 apps/api/test/zoho-mail-client.spec.ts create mode 100644 apps/api/test/zoho-sync.spec.ts diff --git a/.env.example b/.env.example index 12fac543c..702b789b2 100644 --- a/.env.example +++ b/.env.example @@ -52,6 +52,27 @@ GOOGLE_CLIENT_SECRET="" # MICROSOFT_CLIENT_ID="" # MICROSOFT_CLIENT_SECRET="" +# Zoho Mail — the third mailbox, for a company whose domain is on Zoho rather +# than Google or Microsoft. Set both or neither, like the pairs above. +# +# Create the client at https://api-console.zoho.com as a "Server-based +# Application", with the redirect URI +# /api/auth/oauth2/callback/zoho. Note the /oauth2/ segment: Zoho goes +# through the generic OAuth route, not the social one Google and Microsoft use. +# The README has the full walkthrough. +# +# Zoho can be a sign-in method on its own, but the common case is signing in +# with Google or Microsoft and attaching a Zoho mailbox on Settings > +# Connections. +# ZOHO_CLIENT_ID="" +# ZOHO_CLIENT_SECRET="" + +# Which Zoho data centre the account lives in. An account belongs to exactly +# one, and a token minted in one is refused by the others, so this has to match +# the domain you log in to Zoho on. One of: com, eu, in, com.au, jp, ca, sa, +# com.cn. Defaults to com. +# ZOHO_REGION="com" + # Optional. Enables Slack account linking on Settings > Connections. # Add APP_URL + /api/auth/oauth2/callback/slack as the Slack OAuth redirect URL. # SLACK_CLIENT_ID="" diff --git a/README.md b/README.md index c761ebff6..7e959d6ce 100644 --- a/README.md +++ b/README.md @@ -221,10 +221,14 @@ Open `.env` and set these. Everything else in the file is optional and commented | `ALLOWED_SIGN_IN` | Your email domain, e.g. `acme.com`. Or one address, e.g. `you@gmail.com`. | | `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET`| A Google OAuth client — 2 minutes, below. Both or neither. | | `MICROSOFT_CLIENT_ID` / `MICROSOFT_CLIENT_SECRET` | A Microsoft Entra app registration — below. Both or neither. | +| `ZOHO_CLIENT_ID` / `ZOHO_CLIENT_SECRET` | A Zoho API console client, if your mail is on Zoho — below. Both or neither. | -**Pick at least one of Google and Microsoft**, or add your own identity provider on -**Settings → SSO** once you are in. Setting both is fine and common: the sign-in page -offers both buttons, and each rep's mail is read from whichever they signed in with. +**Pick at least one of Google, Microsoft and Zoho**, or add your own identity provider +on **Settings → SSO** once you are in. Setting several is fine and common: the sign-in +page offers each button, and a rep's mail is read from whichever they signed in with. +Zoho can also be attached to an existing Google or Microsoft account from **Settings → +Connections**, which is what you want if the company signs in on one domain and keeps +its sales mailbox on another. `DATABASE_URL` already matches the `docker compose` Postgres, so leave it alone unless you brought your own. @@ -287,6 +291,36 @@ failing to sync. +
+Getting the Zoho OAuth client + +1. [Zoho API console](https://api-console.zoho.com) → **Add Client** → **Server-based + Applications**. Sign in as an admin of the Zoho org that owns the mailbox. +2. **Homepage URL** is your app's origin, e.g. `http://localhost:3000`. +3. **Authorized Redirect URIs** → + `http://localhost:3001/api/auth/oauth2/callback/zoho`. In production this is + `https:///api/auth/oauth2/callback/zoho` — the API's origin, not the + app's. Note the `/oauth2/` segment: Zoho goes through the generic OAuth route, so + this path differs from the Google and Microsoft ones above. +4. Copy the **Client ID** and **Client Secret** into `.env` as `ZOHO_CLIENT_ID` and + `ZOHO_CLIENT_SECRET`. +5. If your Zoho account is not on `zoho.com`, set `ZOHO_REGION` to the suffix you log + in on — `eu`, `in`, `com.au`, `jp`, `ca`, `sa` or `com.cn`. An account lives in + exactly one data centre and a token minted in one is refused by the others, so a + wrong value here looks like an account that will not connect. + +The scopes are requested at sign-in and need no entry in the console: +`ZohoMail.accounts.READ`, `ZohoMail.folders.READ`, `ZohoMail.messages.READ` and +`AaaServer.profile.READ`. All four are read-only — the CRM can list and read mail and +can never send, reply, move or delete. Reading is forward-only, exactly like the other +two: the first check records the current time and imports nothing. + +Zoho only issues a refresh token while it is showing the consent screen, so a +connection that comes back without one has to be disconnected and reconnected rather +than repaired. The connection card says so when it happens. + +
+ `ALLOWED_SIGN_IN` is the entire authorisation model — an unset value means nobody can sign in, which is the safe direction to fail. It takes whole domains, individual addresses, or a mix: diff --git a/adrs/zoho-mail.md b/adrs/zoho-mail.md new file mode 100644 index 000000000..8f6ef5409 --- /dev/null +++ b/adrs/zoho-mail.md @@ -0,0 +1,67 @@ +# Read Zoho Mail as a third mailbox provider + +We run this CRM against a business that hosts its mail on Zoho, not on Google +Workspace or Microsoft 365. Today that means the agent has nothing to read: our +sales mailbox is `@elitesystemsdesign.com` on Zoho, and the only mail the CRM +can see belongs to a personal Gmail account that isn't where the business +actually happens. Every company, contact and thread the product is supposed to +fill in by itself has to be typed in by hand instead. + +Zoho Mail is not a niche choice — it's the usual answer for a small company that +wanted its own domain without paying per seat for Workspace. So this is less +"support my setup" than "the second-tier mail host that the CRM's whole premise +quietly excludes". + +## Why not the two easy answers + +**Generic IMAP** would cover Zoho and everything else in one go, and it's the +obvious suggestion. We didn't do it, for two reasons. It would be the only +provider with no OAuth story — a password in the database, or an app-specific +password the user has to mint and re-mint, against a codebase where every other +credential is a refreshable token in the `account` table. And IMAP gives no +usable incremental cursor without holding a connection and a UID validity map, +which is a different shape of sync service from the two that already exist. It +is a bigger change that fits the codebase worse. + +**A side script** that pushes mail in through an intake API was the other +option. It puts the mail in the CRM but leaves it outside everything that makes +the mailbox layer worth having: no connection card, no scope checking, no +purge, no reconnect flow, no `syncedByUserId` to purge against. + +## What we did instead + +Followed #73. 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 exactly the property you want when adding one. `ThreadWriterService`, +`MailboxMatchService`, `SyncStateService` and the `EmailThread`/`EmailMessage` +models needed no changes at all; `zohoMessageId` and `zohoWebLink` sit beside +the Gmail and Outlook columns. + +Four things about Zoho are genuinely different from Graph, and they are where +the code is not a copy of the Outlook adapter: + +- **It is a generic OAuth provider, not a better-auth social one.** It goes + through the same `genericOAuth` plugin Slack already uses, so it links onto an + existing account rather than only being a sign-in. That matters for the case + above: sign in with Google, attach a Zoho mailbox on a different domain. +- **`Authorization: Zoho-oauthtoken `, not `Bearer`.** `MailboxApiClient` + grew one optional scheme argument. +- **There is no "changed since" filter.** The list endpoint pages with + `start`/`limit` over a date-sorted list, so the incremental 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 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 the header endpoint. That is the main cost of this + adapter and the reason its per-tick ceiling is lower than Outlook's. + +## What it breaks + +`hasSyncScopes`, `mailboxGrantsNeeded` and the `SYNC_SOURCES` maps gain a third +arm, which is a compile error everywhere until filled in — that is the union +doing its job, and every site is in this diff. `rebuildThreads` was duplicated +verbatim in the Google and Microsoft connection services; rather than add a +third copy it moved to `mailbox/thread-rebuild.ts`. Nothing else changes for an +install that never sets `ZOHO_CLIENT_ID`: unset, the provider is not registered, +the connection card says so, and no new query runs. diff --git a/apps/api/src/zoho/zoho-mail.client.ts b/apps/api/src/zoho/zoho-mail.client.ts index 456015369..dd353e4be 100644 --- a/apps/api/src/zoho/zoho-mail.client.ts +++ b/apps/api/src/zoho/zoho-mail.client.ts @@ -1,12 +1,16 @@ -import { zohoConfig } from "@crm/auth"; +import type { ZohoEndpoints } from "@crm/auth"; import { schemas } from "@crm/validation"; -import { Injectable } from "@nestjs/common"; +import { Inject, Injectable } from "@nestjs/common"; import type { ZodType } from "zod"; import { MailboxApiClient, type MailboxResult, } from "../mailbox/mailbox-api.client"; -import { ZOHO_AUTH_SCHEME, ZOHO_PAGE_SIZE } from "./zoho.constants"; +import { + ZOHO_AUTH_SCHEME, + ZOHO_ENDPOINTS, + ZOHO_PAGE_SIZE, +} from "./zoho.constants"; export type ZohoAccount = ReturnType; export type ZohoFolder = ReturnType; @@ -15,13 +19,11 @@ export type ZohoMessageSummary = ReturnType< >; export type ZohoMessageBody = { - messageId: string; /** HTML, as Zoho stores it. The caller strips it. */ content: string; }; export type ZohoHeaders = { - messageId: string; /** Header names lower-cased, so lookups do not have to guess the casing. */ values: Map; }; @@ -42,17 +44,20 @@ export type ZohoHeaders = { */ @Injectable() export class ZohoMailClient { - constructor(private readonly api: MailboxApiClient) {} + constructor( + private readonly api: MailboxApiClient, + @Inject(ZOHO_ENDPOINTS) + private readonly endpoints: ZohoEndpoints | null, + ) {} private base(): string { - const config = zohoConfig(); - if (!config) { + if (!this.endpoints) { throw new Error( "Zoho is not configured: set ZOHO_CLIENT_ID and ZOHO_CLIENT_SECRET.", ); } - return config.endpoints.mailApiBase; + return this.endpoints.mailApiBase; } /** @@ -60,10 +65,9 @@ export class ZohoMailClient { * `webLink` field of its own, so this is assembled from the ids we hold. */ messageUrl(folderId: string, messageId: string): string | null { - const config = zohoConfig(); - if (!config) return null; + if (!this.endpoints) return null; - return `${config.endpoints.mailWebBase}/zm/#mail/folder/${folderId}/p/${messageId}`; + return `${this.endpoints.mailWebBase}/zm/#mail/folder/${folderId}/p/${messageId}`; } async accounts(accessToken: string): Promise> { @@ -127,10 +131,7 @@ export class ZohoMailClient { values.set(name.toLowerCase(), entries); } - return { - outcome: "ok", - data: { messageId: result.data.messageId, values }, - }; + return { outcome: "ok", data: { values } }; } async messageContent( @@ -146,13 +147,7 @@ export class ZohoMailClient { ); if (result.outcome !== "ok") return result; - return { - outcome: "ok", - data: { - messageId: result.data.messageId, - content: result.data.content ?? "", - }, - }; + return { outcome: "ok", data: { content: result.data.content ?? "" } }; } private messagePath( diff --git a/apps/api/src/zoho/zoho.constants.ts b/apps/api/src/zoho/zoho.constants.ts index b0ac475d6..1cc77f76e 100644 --- a/apps/api/src/zoho/zoho.constants.ts +++ b/apps/api/src/zoho/zoho.constants.ts @@ -41,3 +41,6 @@ export const ZOHO_EXCLUDED_FOLDER_TYPES = [ * collide with a real RFC message id. */ export const ZOHO_THREAD_ROOT_PREFIX = "zoho-thread:"; + +/** DI token for the resolved data-centre endpoints, or null when unconfigured. */ +export const ZOHO_ENDPOINTS = "ZOHO_ENDPOINTS"; diff --git a/apps/api/src/zoho/zoho.module.ts b/apps/api/src/zoho/zoho.module.ts index 8e9a3395d..80c044553 100644 --- a/apps/api/src/zoho/zoho.module.ts +++ b/apps/api/src/zoho/zoho.module.ts @@ -1,6 +1,8 @@ +import { zohoConfig } from "@crm/auth"; import { Module } from "@nestjs/common"; import { MailboxModule } from "../mailbox/mailbox.module"; import { TrpcModule } from "../trpc/trpc.module"; +import { ZOHO_ENDPOINTS } from "./zoho.constants"; import { ZohoRouter } from "./zoho.router"; import { ZohoConnectionService } from "./zoho-connection.service"; import { ZohoMailClient } from "./zoho-mail.client"; @@ -10,6 +12,12 @@ import { ZohoSyncService } from "./zoho-sync.service"; @Module({ imports: [TrpcModule, MailboxModule], providers: [ + { + // Resolved once at boot rather than read per call: which data centre + // the account lives in cannot change while the process is running. + provide: ZOHO_ENDPOINTS, + useFactory: () => zohoConfig()?.endpoints ?? null, + }, ZohoMailClient, ZohoMailSyncService, ZohoSyncService, diff --git a/apps/api/test/mailbox-scopes.spec.ts b/apps/api/test/mailbox-scopes.spec.ts index e98586a8c..2e505432e 100644 --- a/apps/api/test/mailbox-scopes.spec.ts +++ b/apps/api/test/mailbox-scopes.spec.ts @@ -7,6 +7,7 @@ import { OUTLOOK_MAIL_SCOPE, parseScopes, SYNC_SCOPES, + ZOHO_SYNC_SCOPES, } from "@crm/auth/scopes"; const BOTH = `openid,email,profile,${GMAIL_SCOPE},${CALENDAR_SCOPE}`; @@ -110,3 +111,32 @@ describe("hasSyncScopes for anything else", () => { expect(hasSyncScopes("okta", BOTH)).toBe(false); }); }); + +describe("hasSyncScopes for Zoho", () => { + it("accepts the comma-separated list Zoho returns", () => { + expect(hasSyncScopes("zoho", ZOHO_SYNC_SCOPES.join(","))).toBe(true); + }); + + it("accepts the profile scope riding along, which Zoho always adds", () => { + expect( + hasSyncScopes( + "zoho", + `${ZOHO_SYNC_SCOPES.join(",")},AaaServer.profile.READ`, + ), + ).toBe(true); + }); + + it("refuses a grant that can read messages but cannot find the account id", () => { + expect( + hasSyncScopes("zoho", "ZohoMail.messages.READ,ZohoMail.folders.READ"), + ).toBe(false); + }); + + it("does not read the ALL scope as the READ one it is not spelled as", () => { + expect(hasSyncScopes("zoho", "ZohoMail.accounts.ALL")).toBe(false); + }); + + it("does not read a Google grant as a Zoho one", () => { + expect(hasSyncScopes("zoho", BOTH)).toBe(false); + }); +}); diff --git a/apps/api/test/zoho-mail-client.spec.ts b/apps/api/test/zoho-mail-client.spec.ts new file mode 100644 index 000000000..a9267582c --- /dev/null +++ b/apps/api/test/zoho-mail-client.spec.ts @@ -0,0 +1,190 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { zohoEndpoints } from "@crm/auth"; +import { MailboxApiClient } from "../src/mailbox/mailbox-api.client"; +import { ZohoMailClient } from "../src/zoho/zoho-mail.client"; + +const realFetch = globalThis.fetch; + +type Call = { url: string; authorization: string | null }; + +type ZohoEnvelope = { + status: { code: number; description: string }; + data: unknown; +}; + +function stub(body: ZohoEnvelope, init: { status?: number } = {}) { + const calls: Call[] = []; + + globalThis.fetch = (async (input: string | URL, options?: RequestInit) => { + const headers = new Headers(options?.headers); + calls.push({ + url: String(input), + authorization: headers.get("authorization"), + }); + + return new Response(JSON.stringify(body), { + status: init.status ?? 200, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; + + return { calls }; +} + +function client(): ZohoMailClient { + return new ZohoMailClient(new MailboxApiClient(), zohoEndpoints("com")); +} + +// The envelope is Zoho's wire shape, and the payload inside it is deliberately +// untyped here: these specs exist to prove the client parses whatever arrives. +const envelope = (data: ZohoEnvelope["data"], code = 200): ZohoEnvelope => ({ + status: { code, description: code === 200 ? "success" : "failure" }, + data, +}); + +afterEach(() => { + globalThis.fetch = realFetch; +}); + +describe("talking to Zoho", () => { + it("authorises with Zoho-oauthtoken, which is the only scheme Zoho accepts", async () => { + const stubbed = stub(envelope([])); + + await client().accounts("secret-token"); + + expect(stubbed.calls[0]?.authorization).toBe( + "Zoho-oauthtoken secret-token", + ); + }); + + it("ignores the message id Zoho sends as an unsafe integer", async () => { + // Zoho returns this one as a bare number, past Number.MAX_SAFE_INTEGER, + // so JSON.parse rounds it before any parser runs. Reading it would be + // reading a wrong id; the list endpoint's string copy is used instead. + stub(envelope({ messageId: 1710915488416100000, headerContent: {} })); + + const result = await client().messageHeaders("t", "a", "f", "m"); + + expect(result.outcome).toBe("ok"); + if (result.outcome !== "ok") return; + + expect(Object.keys(result.data)).toEqual(["values"]); + }); + + it("unwraps the envelope so callers never see Zoho's status wrapper", async () => { + stub( + envelope([ + { + accountId: "2560636000000008002", + mailboxAddress: "rep@trycomp.ai", + type: "ZOHO_ACCOUNT", + }, + ]), + ); + + const result = await client().accounts("token"); + + expect(result.outcome).toBe("ok"); + if (result.outcome !== "ok") return; + + // Zoho ships this id as a JSON number that exceeds a safe integer in + // other endpoints; it is coerced to a string once, at the boundary. + expect(result.data[0]?.accountId).toBe("2560636000000008002"); + expect(result.data[0]?.mailboxAddress).toBe("rep@trycomp.ai"); + }); + + it("believes status.code over the HTTP status", async () => { + stub(envelope(null, 401), { status: 200 }); + + const result = await client().accounts("token"); + + expect(result.outcome).toBe("unauthorized"); + }); + + it("treats a 404 as cursor-invalid, so one missing message is not a failure", async () => { + stub(envelope(null, 404), { status: 200 }); + + const result = await client().messageContent( + "token", + "acct", + "folder", + "message", + ); + + expect(result.outcome).toBe("cursor-invalid"); + }); + + it("refuses a payload that does not match the schema instead of guessing", async () => { + stub(envelope([{ folderName: "Inbox" }])); + + const result = await client().folders("token", "acct"); + + expect(result.outcome).toBe("failed"); + if (result.outcome !== "failed") return; + + expect(result.retryable).toBe(false); + expect(result.reason).toContain("cannot read"); + }); + + it("reads 'Not Provided' as no recipients rather than as an address", async () => { + stub( + envelope([ + { + messageId: "1", + folderId: "2", + fromAddress: "jane@acme.com", + toAddress: '"Rep" ', + ccAddress: "Not Provided", + receivedTime: "1709887053409", + }, + ]), + ); + + const result = await client().listMessages("token", "acct", { start: 1 }); + + expect(result.outcome).toBe("ok"); + if (result.outcome !== "ok") return; + + expect(result.data[0]?.ccAddress).toBeNull(); + expect(result.data[0]?.receivedTime).toBe(1_709_887_053_409); + }); + + it("lower-cases header names, because the sending server chose the casing", async () => { + stub( + envelope({ + headerContent: { + "Message-Id": [""], + REFERENCES: [""], + }, + }), + ); + + const result = await client().messageHeaders("t", "a", "f", "m"); + + expect(result.outcome).toBe("ok"); + if (result.outcome !== "ok") return; + + expect(result.data.values.get("message-id")).toEqual([""]); + expect(result.data.values.get("references")).toEqual([""]); + }); + + it("asks for headers as JSON, not raw, so they arrive already split", async () => { + const stubbed = stub(envelope({ headerContent: {} })); + + await client().messageHeaders("t", "acct", "folder", "msg"); + + expect(stubbed.calls[0]?.url).toContain("raw=false"); + expect(stubbed.calls[0]?.url).toContain( + "/accounts/acct/folders/folder/messages/msg/header", + ); + }); + + it("pulls sent mail in, which is how an outbound reply gets filed at all", async () => { + const stubbed = stub(envelope([])); + + await client().listMessages("token", "acct", { start: 11 }); + + expect(stubbed.calls[0]?.url).toContain("includesent=true"); + expect(stubbed.calls[0]?.url).toContain("start=11"); + }); +}); diff --git a/apps/api/test/zoho-sync.spec.ts b/apps/api/test/zoho-sync.spec.ts new file mode 100644 index 000000000..bc76b9d6c --- /dev/null +++ b/apps/api/test/zoho-sync.spec.ts @@ -0,0 +1,430 @@ +import { describe, expect, it } from "bun:test"; +import type { MailboxSyncModel as MailboxSync } from "@crm/db"; +import type { SyncSource } from "../src/mailbox/mailbox.constants"; +import type { MailboxTokenService } from "../src/mailbox/mailbox-token.service"; +import type { SyncStateService } from "../src/mailbox/sync-state.service"; +import type { + IncomingMessage, + ThreadWriterService, +} from "../src/mailbox/thread-writer.service"; +import type { + ZohoHeaders, + ZohoMailClient, + ZohoMessageSummary, +} from "../src/zoho/zoho-mail.client"; +import { ZohoMailSyncService } from "../src/zoho/zoho-mail-sync.service"; + +type Ok = { outcome: "ok"; data: T }; +type NotOk = + | { outcome: "cursor-invalid"; reason: string } + | { outcome: "unauthorized"; reason: string } + | { outcome: "rate-limited"; reason: string; retryAfterMs: number } + | { outcome: "failed"; reason: string; retryable: boolean }; + +const ok = (data: T): Ok => ({ outcome: "ok", data }); + +type StoreOptions = { mailbox: string; origin: SyncSource }; + +const CURSOR = Date.parse("2025-08-01T09:00:00.000Z"); + +const INBOX = "folder-inbox"; +const SPAM = "folder-spam"; + +const row = { + id: "sync-1", + userId: "user-1", + source: "zohomail", + cursor: String(CURSOR), + autoCreate: true, +} as unknown as MailboxSync; + +const rowWith = (cursor: string | null): MailboxSync => + ({ ...row, cursor }) as MailboxSync; + +type Harness = { + service: ZohoMailSyncService; + stored: IncomingMessage[]; + settled: { cursor?: string | null }[]; + rateLimited: number[]; + reconnected: string[]; + failed: string[]; + listedFrom: number[]; + headerCalls: string[]; +}; + +function headersFor(values: Record): ZohoHeaders { + const map = new Map(); + for (const [name, value] of Object.entries(values)) { + map.set(name.toLowerCase(), [value]); + } + + return { values: map }; +} + +function harness(options: { + pages?: ZohoMessageSummary[][]; + headers?: Record>; + content?: (messageId: string) => Ok<{ content: string }>; + headerResult?: (messageId: string) => Ok | NotOk; + accounts?: Ok | NotOk; + folders?: Ok | NotOk; +}): Harness { + const stored: IncomingMessage[] = []; + const settled: { cursor?: string | null }[] = []; + const rateLimited: number[] = []; + const reconnected: string[] = []; + const failed: string[] = []; + const listedFrom: number[] = []; + const headerCalls: string[] = []; + + const pages = options.pages ?? [[]]; + + const zoho = { + async accounts() { + return ( + options.accounts ?? + ok([ + { + accountId: "acct-1", + mailboxAddress: "rep@trycomp.ai", + type: "ZOHO_ACCOUNT", + enabled: true, + }, + // An attached IMAP mailbox must never be picked: the Zoho Mail + // API cannot read one, so choosing it would fail every tick. + { + accountId: "acct-imap", + mailboxAddress: "old@elsewhere.com", + type: "IMAP_ACCOUNT", + }, + ]) + ); + }, + async folders() { + return ( + options.folders ?? + ok([ + { folderId: INBOX, folderName: "Inbox", folderType: "Inbox" }, + { folderId: SPAM, folderName: "Spam", folderType: "Spam" }, + ]) + ); + }, + async listMessages( + _token: string, + _accountId: string, + page: { start: number }, + ) { + listedFrom.push(page.start); + + // `start` is 1-based and counts messages, not pages. + let consumed = 1; + for (const messages of pages) { + if (consumed === page.start) return ok(messages); + consumed += messages.length; + } + + return ok([]); + }, + async messageHeaders( + _token: string, + _accountId: string, + _folderId: string, + messageId: string, + ) { + headerCalls.push(messageId); + + if (options.headerResult) return options.headerResult(messageId); + + return ok( + headersFor( + options.headers?.[messageId] ?? { + "Message-Id": `<${messageId}@acme.com>`, + }, + ), + ); + }, + async messageContent( + _token: string, + _accountId: string, + _folderId: string, + messageId: string, + ) { + if (options.content) return options.content(messageId); + + return ok({ content: "
Hello
" }); + }, + messageUrl(folderId: string, messageId: string) { + return `https://mail.zoho.com/zm/#mail/folder/${folderId}/p/${messageId}`; + }, + } as unknown as ZohoMailClient; + + const tokens = { + async accessTokenFor() { + return { outcome: "ok" as const, accessToken: "token" }; + }, + } as unknown as MailboxTokenService; + + const state = { + async markRunning() {}, + async settle(_id: string, update: { cursor?: string | null }) { + settled.push(update); + }, + async clearCursor() {}, + async markNeedsReconnect(_id: string, reason: string) { + reconnected.push(reason); + }, + async markRateLimited(_id: string, retryAfterMs: number) { + rateLimited.push(retryAfterMs); + }, + async markFailed(_id: string, reason: string) { + failed.push(reason); + }, + } as unknown as SyncStateService; + + const threads = { + async context() { + return {}; + }, + async store( + _row: MailboxSync, + _options: StoreOptions, + parsed: IncomingMessage, + ) { + stored.push(parsed); + return true; + }, + } as unknown as ThreadWriterService; + + return { + service: new ZohoMailSyncService(zoho, tokens, state, threads), + stored, + settled, + rateLimited, + reconnected, + failed, + listedFrom, + headerCalls, + }; +} + +function summary( + overrides: Partial = {}, +): ZohoMessageSummary { + return { + messageId: "zm-1", + folderId: INBOX, + threadId: "thread-1", + subject: "Pricing", + summary: "Hello", + fromAddress: "jane@acme.com", + sender: "Jane", + toAddress: '"Rep" ', + ccAddress: null, + sentDateInGMT: CURSOR + 60_000, + receivedTime: CURSOR + 60_000, + ...overrides, + } as ZohoMessageSummary; +} + +describe("the first Zoho tick", () => { + it("marks where now is instead of back-filling the whole mailbox", async () => { + const kit = harness({ pages: [[summary()]] }); + + const outcome = await kit.service.sync(rowWith(null)); + + expect(outcome.status).toBe("synced"); + expect(kit.stored).toHaveLength(0); + expect(kit.settled).toHaveLength(1); + + const cursor = Number(kit.settled[0]?.cursor); + expect(Number.isFinite(cursor)).toBe(true); + }); +}); + +describe("reading new mail", () => { + it("stores a message the CRM has not seen and keeps a link back to Zoho", async () => { + const kit = harness({ pages: [[summary()]] }); + + await kit.service.sync(row); + + expect(kit.stored).toHaveLength(1); + const message = kit.stored[0]; + + expect(message?.rfcMessageId).toBe("zm-1@acme.com"); + expect(message?.from.email).toBe("jane@acme.com"); + expect(message?.from.name).toBe("Jane"); + expect(message?.recipients).toEqual([ + { email: "rep@trycomp.ai", name: "Rep", kind: "to" }, + ]); + expect(message?.body).toBe("Hello"); + expect(message?.zohoMessageId).toBe("zm-1"); + expect(message?.zohoWebLink).toContain("/zm/#mail/folder/folder-inbox/p/"); + }); + + it("stops at the first message the previous tick already read", async () => { + const kit = harness({ + pages: [ + [ + summary({ messageId: "new", receivedTime: CURSOR + 60_000 }), + summary({ messageId: "old", receivedTime: CURSOR - 60_000 }), + ], + ], + }); + + await kit.service.sync(row); + + expect(kit.stored.map((message) => message.zohoMessageId)).toEqual(["new"]); + expect(kit.headerCalls).toEqual(["new"]); + }); + + it("moves the cursor to the newest message it saw", async () => { + const newest = CURSOR + 120_000; + const kit = harness({ + pages: [ + [ + summary({ messageId: "a", receivedTime: newest }), + summary({ messageId: "b", receivedTime: CURSOR + 60_000 }), + ], + ], + }); + + await kit.service.sync(row); + + expect(kit.settled.at(-1)?.cursor).toBe(String(newest)); + }); + + it("never files mail out of Spam or Trash", async () => { + const kit = harness({ + pages: [ + [ + summary({ messageId: "junk", folderId: SPAM }), + summary({ messageId: "real", folderId: INBOX }), + ], + ], + }); + + await kit.service.sync(row); + + expect(kit.stored.map((message) => message.zohoMessageId)).toEqual([ + "real", + ]); + }); + + it("pages by message count, which is what Zoho's start parameter means", async () => { + const kit = harness({ + pages: [ + Array.from({ length: 50 }, (_unused, index) => + summary({ + messageId: `p1-${index}`, + receivedTime: CURSOR + 120_000 - index, + }), + ), + [summary({ messageId: "p2-0", receivedTime: CURSOR - 60_000 })], + ], + }); + + await kit.service.sync(row); + + expect(kit.listedFrom).toEqual([1, 51]); + }); +}); + +describe("threading a Zoho message", () => { + it("roots a reply on References, so it joins a thread seen through Gmail", async () => { + const kit = harness({ + pages: [[summary({ messageId: "reply" })]], + headers: { + reply: { + "Message-Id": "", + References: " ", + }, + }, + }); + + await kit.service.sync(row); + + expect(kit.stored[0]?.rootId).toBe("first@acme.com"); + }); + + it("falls back to Zoho's own thread id when the headers carry no chain", async () => { + const kit = harness({ + pages: [[summary({ messageId: "orphan", threadId: "t-9" })]], + }); + + await kit.service.sync(row); + + expect(kit.stored[0]?.rootId).toBe("zoho-thread:t-9"); + }); + + it("skips a message with no RFC Message-ID rather than inventing one", async () => { + const kit = harness({ + pages: [[summary({ messageId: "headerless" })]], + headers: { headerless: { Subject: "Pricing" } }, + }); + + await kit.service.sync(row); + + expect(kit.stored).toHaveLength(0); + }); + + it("skips a message that vanished between the list and the fetch", async () => { + const kit = harness({ + pages: [[summary({ messageId: "gone" })]], + headerResult: () => ({ + outcome: "cursor-invalid", + reason: "No such message.", + }), + }); + + const outcome = await kit.service.sync(row); + + expect(outcome.status).toBe("synced"); + expect(kit.stored).toHaveLength(0); + expect(kit.failed).toHaveLength(0); + }); +}); + +describe("when Zoho refuses", () => { + it("asks for a reconnect on 401 rather than retrying forever", async () => { + const kit = harness({ + accounts: { outcome: "unauthorized", reason: "Invalid OAuth token." }, + }); + + const outcome = await kit.service.sync(row); + + expect(outcome.status).toBe("reconnect"); + expect(kit.reconnected).toEqual(["Invalid OAuth token."]); + }); + + it("backs off on a rate limit", async () => { + const kit = harness({ + folders: { + outcome: "rate-limited", + reason: "Too many requests.", + retryAfterMs: 90_000, + }, + }); + + const outcome = await kit.service.sync(row); + + expect(outcome.status).toBe("rate-limited"); + expect(kit.rateLimited).toEqual([90_000]); + }); + + it("fails loudly when the login has no mailbox this API can read", async () => { + const kit = harness({ + accounts: ok([ + { + accountId: "acct-imap", + mailboxAddress: "x@y.com", + type: "IMAP_ACCOUNT", + }, + ]), + }); + + const outcome = await kit.service.sync(row); + + expect(outcome.status).toBe("failed"); + expect(kit.failed.at(0)).toContain("no readable mailbox"); + }); +}); diff --git a/docs/api.md b/docs/api.md index 93f4df63f..03f8534a7 100644 --- a/docs/api.md +++ b/docs/api.md @@ -163,17 +163,19 @@ Two rules follow for the serverless build: `MODULE_NOT_FOUND` on the first request. Adding a name to `EXTERNALS` without checking it lands in `.vercel/output` breaks production, and the build stays green. -## Two mail providers, one pipeline +## Three mail providers, one pipeline -`apps/api/src/mailbox` is everything neither Google nor Microsoft owns: -`MailboxApiClient` (bearer GET, and the one place a status code becomes an outcome), +`apps/api/src/mailbox` is everything no single provider owns: +`MailboxApiClient` (an authorised GET, and the one place a status code becomes an +outcome — the scheme is `Bearer` unless the caller asks for another, which only Zoho +does), `SyncStateService` (the `MailboxSync` row), `MailboxTokenService`, -`MailboxMatchService`, `participants.ts`, `message-text.ts`, and +`MailboxMatchService`, `participants.ts`, `message-text.ts`, `thread-rebuild.ts` and `ThreadWriterService`. - **`ThreadWriterService.store` is the only writer of `EmailThread`, `EmailMessage` - and the `EMAIL` activity.** Gmail and Outlook each parse their own wire format down - to one `IncomingMessage` and hand it over; matching, threading, counting and + and the `EMAIL` activity.** Gmail, Outlook and Zoho each parse their own wire format + down to one `IncomingMessage` and hand it over; matching, threading, counting and stamping happen once. A second copy of that is how a rule like *reply before you create a company* comes to be true in one inbox and not the other. - **A thread is keyed by RFC message id, not by the provider's thread id.** Root comes @@ -181,8 +183,12 @@ Two rules follow for the serverless build: Outlook land on the same `EmailThread` for the same conversation. Graph only returns `internetMessageHeaders` when `$select`ed and not for every message, so Outlook falls back to `outlook-conversation:` — threading that still holds inside - Outlook, just not across to Gmail. -- **`MailboxSync.source` is the discriminator** — `calendar`, `gmail`, `outlook`. Each + Outlook, just not across to Gmail. Zoho's message list carries no RFC headers at all, + so its adapter spends one extra call per new message on `/header` to get a real + `Message-ID` rather than falling back; `zoho-thread:` is only used when the + message genuinely has no `References` or `In-Reply-To`. +- **`MailboxSync.source` is the discriminator** — `calendar`, `gmail`, `outlook`, + `zohomail`. Each provider's module only ever sees its own, and `sync/mailbox-sync.service.ts` is the one place that dispatches. One cron, one budget: `POST /internal/sync/mailboxes` (`/google` is kept as an alias so an existing @@ -191,9 +197,14 @@ Two rules follow for the serverless build: mailbox-wide delta, so the Outlook cursor is the last `receivedDateTime` seen, re-read with a one-second overlap; `rfcMessageId` is unique, so the overlap costs a duplicate fetch and never a duplicate row. -- **Microsoft has no token-revocation endpoint.** `revoke` clears the columns and the - UI says the consent itself is removed in the user's Microsoft account. Google's still - posts to `oauth2.googleapis.com/revoke` and refuses to clear if that fails. +- **Zoho has no delta and no date filter at all.** `/messages/view` pages with + `start`/`limit` over a date-sorted list, so the adapter reads newest-first and stops + at the first message under its cursor; the cursor is epoch milliseconds, not an ISO + string. Its per-tick ceiling is lower than Outlook's because each new message costs + three calls rather than one. +- **Microsoft and Zoho have no token-revocation endpoint we call.** `revoke` clears the + columns and the UI says where to withdraw the consent itself. Google's still posts to + `oauth2.googleapis.com/revoke` and refuses to clear if that fails. ## Not every address on a thread is a person diff --git a/docs/environment.md b/docs/environment.md index 22417c60e..7b9f65d8a 100644 --- a/docs/environment.md +++ b/docs/environment.md @@ -43,8 +43,18 @@ the three that is genuinely optional on its own — set it to your tenant's GUID refuse other tenants at Microsoft instead of at `ALLOWED_SIGN_IN`. There is **no Microsoft equivalent of `hd`**: `tenantId` is the whole of it. -**Neither pair is required, but an install wants one of them or an SSO provider** — -with none, the sign-in page says so by name rather than rendering nothing. +**`ZOHO_CLIENT_ID` + `ZOHO_CLIENT_SECRET`** are the third of the same bargain, and the +only one that is not a better-auth *social* provider: Zoho is registered through the +`genericOAuth` plugin, so its callback is `/api/auth/oauth2/callback/zoho` — note the +extra `/oauth2` segment the other two do not have. Same pair rule. +**`ZOHO_REGION`** defaults to `com` and names the data centre suffix (`com`, `eu`, +`in`, `com.au`, `jp`, `ca`, `sa`, `com.cn`). It is not cosmetic: an account lives in +exactly one data centre, every host name carries the suffix, and a token minted in one +is refused by the others. `packages/auth/src/zoho-region.ts` throws on an unknown +value rather than composing a hostname that does not resolve. + +**None of the three pairs is required, but an install wants one of them or an SSO +provider** — with none, the sign-in page says so by name rather than rendering nothing. **`ALLOWED_SIGN_IN`** — comma-separated whole domains or single addresses (bare addresses exist for a solo self-hoster, where `gmail.com` would be an open door). **One @@ -169,7 +179,13 @@ canonicaliser and strips that prefix, so the comparison is against the bare perm everywhere. **Sync is forward-only** — Gmail records the current `historyId` on its first pass and -imports nothing, Calendar reads from `now`, and Outlook records `now` as its cursor. +imports nothing, Calendar reads from `now`, and Outlook and Zoho each record `now` as +their cursor. + +**Zoho only issues a refresh token while the consent screen is up**, which is why the +authorization URL always carries `access_type=offline` and `prompt=consent`. A +connection that comes back without one cannot be repaired in place — the status card +reports `hasRefreshToken: false` and asks for a disconnect and reconnect. **`CRON_SECRET`** (min 16 chars) guards `POST /internal/sync/mailboxes` and `/internal/sync/rates`; both **fail closed when unset**. `/internal/sync/google` is diff --git a/docs/telemetry.md b/docs/telemetry.md index 6e1ed36aa..e8a80dcb1 100644 --- a/docs/telemetry.md +++ b/docs/telemetry.md @@ -221,7 +221,7 @@ our errors would carry contact fields. | Event | Properties | | --- | --- | | `agent_error` | `error_class`, `tool`, `task_kind`, `error_source` (`tool` / `turn` / `session`) | -| `sync_error` | `error_class`, `sync_source` (`gmail` / `calendar` / `outlook`), `error_source` (`google_sync` / `microsoft_sync` / `mailbox_sync`) | +| `sync_error` | `error_class`, `sync_source` (`gmail` / `calendar` / `outlook` / `zohomail`), `error_source` (`google_sync` / `microsoft_sync` / `zoho_sync` / `mailbox_sync`) | | `api_error` | `error_class`, `route`, `status_code` | | `model_error` | `error_class`, `model_id` | diff --git a/packages/auth/test/mailbox-grant.spec.ts b/packages/auth/test/mailbox-grant.spec.ts index 7e3bc5462..c82de7548 100644 --- a/packages/auth/test/mailbox-grant.spec.ts +++ b/packages/auth/test/mailbox-grant.spec.ts @@ -6,10 +6,13 @@ import { SYNC_SCOPES, signsInWithGoogle, signsInWithMicrosoft, + signsInWithZoho, + ZOHO_SYNC_SCOPES, } from "../src/scopes"; const GRANTED = SYNC_SCOPES.join(","); const GRANTED_MICROSOFT = MICROSOFT_SYNC_SCOPES.join(","); +const GRANTED_ZOHO = ZOHO_SYNC_SCOPES.join(","); describe("who has to grant a mailbox", () => { it("walls someone who signed in with Google and granted neither scope", () => { @@ -146,3 +149,49 @@ describe("whether revoking a provider costs someone the CRM", () => { expect(signsInWithGoogle([])).toBe(false); }); }); + +describe("a Zoho mailbox", () => { + it("walls someone who signed in with Zoho and granted only their profile", () => { + expect( + needsMailboxGrant([ + { providerId: "zoho", scope: "AaaServer.profile.READ" }, + ]), + ).toBe(true); + }); + + it("lets a Zoho account through once the three mail scopes are there", () => { + expect( + needsMailboxGrant([{ providerId: "zoho", scope: GRANTED_ZOHO }]), + ).toBe(false); + }); + + it("names Zoho as the provider to grant on", () => { + expect(mailboxGrantsNeeded([{ providerId: "zoho", scope: null }])).toEqual([ + "zoho", + ]); + }); + + it("lets a Google sign-in through on its own grant, with Zoho only attached", () => { + // This is the shape the pilot runs: sign in with Google Workspace, then + // link a Zoho mailbox on a different domain. The Google grant is what + // gets the user in; the Zoho one is a connection, not a gate. + expect( + needsMailboxGrant([ + { providerId: "google", scope: GRANTED }, + { providerId: "zoho", scope: null }, + ]), + ).toBe(false); + }); + + it("is true for signsInWithZoho only when Zoho is the only way in", () => { + expect(signsInWithZoho([{ providerId: "zoho", scope: GRANTED_ZOHO }])).toBe( + true, + ); + expect( + signsInWithZoho([ + { providerId: "google", scope: GRANTED }, + { providerId: "zoho", scope: GRANTED_ZOHO }, + ]), + ).toBe(false); + }); +}); diff --git a/packages/validation/src/zoho.ts b/packages/validation/src/zoho.ts index dd2819618..d8d6dfefb 100644 --- a/packages/validation/src/zoho.ts +++ b/packages/validation/src/zoho.ts @@ -111,8 +111,12 @@ export type ZohoMessageSummary = z.infer; export const messageList = z.array(messageSummary); +// `messageId` is deliberately absent from this shape and the one below. Zoho +// returns it here as a bare JSON number — `1710915488416100000` — which is past +// `Number.MAX_SAFE_INTEGER`, so `JSON.parse` has already rounded it by the time +// any schema sees it. The list endpoint returns the same id as a string, and +// that is the copy every caller uses. export const messageContent = z.object({ - messageId: id, content: z.string().optional(), blockContent: z.string().optional(), }); @@ -122,7 +126,6 @@ export type ZohoMessageContent = z.infer; // `?raw=false` returns headers as a name -> values map. Header names keep the // casing the sending server used, so the reader lower-cases before lookup. export const messageHeaders = z.object({ - messageId: id, headerContent: z.record(z.string(), z.array(z.string())), });