// VA-P23 · mailbox connection lifecycle (connect / callback / disconnect). // // OAuth tokens are sealed with the per-tenant DEK envelope (AD-8) — never stored // plain, never logged (DoD 7). Writes are audited automatically by the M001 // Prisma middleware. Webhook subscription is best-effort: a failure degrades the // mailbox with a Klartext hint, it does not block the connect (mirrors // CalendarConnectionService). Disconnect revokes provider-side (Gmail token // revoke + watch stop; Graph subscription delete — Graph has no delegated-token // revoke, documented in graphMailApi) and nulls the ciphertexts. import { randomBytes } from "node:crypto"; import { decryptTenantField, encryptTenantField } from "@hivegrid/db/crypto/tenant-envelope.server"; import { getRlsExecutionContext } from "@hivegrid/db/rls-context.server"; import { GMAIL_GLOBAL_FOLDER, GRAPH_SYNC_FOLDERS } from "./adapters/EmailProviderAdapter"; import type { EmailCreds, EmailProviderAdapter } from "./adapters/EmailProviderAdapter"; import { gmailProviderAdapter } from "./adapters/GmailAdapter.server"; import { microsoftMailProviderAdapter } from "./adapters/MicrosoftMailAdapter.server"; import { EmailInboxNotConfiguredError } from "./adapters/GmailAdapter.server"; import { signEmailOAuthState, verifyEmailOAuthState, type EmailOAuthProvider } from "./oauthState.server"; import { db } from "~/utils/db.server"; export class MailboxConnectError extends Error { readonly code: "not_configured" | "invalid_state" | "exchange_failed"; constructor(code: MailboxConnectError["code"]) { super(code); this.name = "MailboxConnectError"; this.code = code; } } export function adapterForProvider(provider: EmailOAuthProvider): EmailProviderAdapter { return provider === "gmail" ? gmailProviderAdapter : microsoftMailProviderAdapter; } function webhookUrlForProvider(provider: EmailOAuthProvider): string | null { return (provider === "gmail" ? process.env.GOOGLE_GMAIL_WEBHOOK_URL : process.env.MICROSOFT_MAIL_WEBHOOK_URL) ?? null; } /** Which providers are configured (drives the connect buttons' enabled state). */ export function configuredEmailProviders(): { gmail: boolean; microsoft: boolean } { return { gmail: gmailProviderAdapter.isConfigured(), microsoft: microsoftMailProviderAdapter.isConfigured() }; } /** Build the provider consent URL for the current tenant user. */ export function buildConnectUrl(input: { tenantId: string; userId: string; tenantSlug: string; provider: EmailOAuthProvider }): string { const adapter = adapterForProvider(input.provider); if (!adapter.isConfigured()) { throw new MailboxConnectError("not_configured"); } const state = signEmailOAuthState(input); return adapter.buildAuthorizationUrl(state); } /** * Complete an OAuth callback: verify state (provider-pinned), exchange the code, * seal the tokens, upsert the per-user mailbox, and best-effort subscribe the * push channel. Returns the tenant slug + ids so the callback route can kick the * initial sync (kept out of this service to avoid a sync-service import cycle). */ export async function completeOAuthCallback(input: { code: string; state: string; provider: EmailOAuthProvider; }): Promise<{ tenantSlug: string; tenantId: string; mailboxId: string }> { const verified = verifyEmailOAuthState(input.state, input.provider); if (!verified.ok) { throw new MailboxConnectError("invalid_state"); } const { tenantId, userId, tenantSlug } = verified.payload; const rlsContext = getRlsExecutionContext(); if (rlsContext?.tenantId !== tenantId || rlsContext.userId !== userId) { throw new MailboxConnectError("invalid_state"); } const adapter = adapterForProvider(input.provider); let exchange: Awaited>; try { exchange = await adapter.exchangeCode(input.code); } catch (error) { if (error instanceof EmailInboxNotConfiguredError) { throw new MailboxConnectError("not_configured"); } throw new MailboxConnectError("exchange_failed"); } const { tokens, account } = exchange; // Seal refresh + access tokens separately (AD-8, DoD 7). A refresh token is // only guaranteed on first consent; keep an existing one when omitted. const refreshCiphertext = tokens.refreshToken ? await encryptTenantField(tenantId, tokens.refreshToken) : null; const accessCiphertext = await encryptTenantField(tenantId, tokens.accessToken); const provider = input.provider; const emailAddress = account.email.toLowerCase(); const existingForUser = await db.emailIntegrationMailbox.count({ where: { tenantId, ownerUserId: userId, archivedAt: null } }); const mailbox = await db.emailIntegrationMailbox.upsert({ where: { tenantId_ownerUserId_provider_externalAccountId: { tenantId, ownerUserId: userId, provider, externalAccountId: account.accountId }, }, create: { tenantId, ownerUserId: userId, provider, emailAddress, externalAccountId: account.accountId, displayName: account.displayName ?? null, oauthRefreshTokenCiphertext: refreshCiphertext, oauthAccessTokenCiphertext: accessCiphertext, oauthAccessTokenExpiresAt: tokens.expiresAt, oauthScopes: [...tokens.scopes], isDefaultForUser: existingForUser === 0, status: "connecting", }, update: { emailAddress, displayName: account.displayName ?? null, // Preserve the stored refresh token when the provider omits it on re-consent. ...(refreshCiphertext ? { oauthRefreshTokenCiphertext: refreshCiphertext } : {}), oauthAccessTokenCiphertext: accessCiphertext, oauthAccessTokenExpiresAt: tokens.expiresAt, oauthScopes: [...tokens.scopes], status: "connecting", lastErrorText: null, archivedAt: null, }, select: { id: true }, }); await trySubscribeWebhook({ tenantId, mailboxId: mailbox.id, provider, creds: { accessToken: tokens.accessToken, refreshToken: tokens.refreshToken ?? "" }, }); await ensureSyncCursors(tenantId, mailbox.id, provider); await db.emailIntegrationMailbox.update({ where: { id: mailbox.id }, data: { status: "active" } }); return { tenantSlug, tenantId, mailboxId: mailbox.id }; } /** * Best-effort push subscription. Gmail: users.watch on the shared Pub/Sub topic * (the mailbox address routes the push, historyId anchors the cursor). Graph: * /subscriptions with a per-mailbox random clientState (its only auth). Absent * env or a provider failure degrades with a Klartext hint instead of failing. */ async function trySubscribeWebhook(input: { tenantId: string; mailboxId: string; provider: EmailOAuthProvider; creds: EmailCreds; }): Promise { const webhookUrl = webhookUrlForProvider(input.provider); if (input.provider === "microsoft" && !webhookUrl) { return; } const adapter = adapterForProvider(input.provider); const clientState = randomBytes(24).toString("base64url"); try { const subscription = await adapter.subscribeWebhook(input.creds, webhookUrl ?? "", clientState); await db.emailIntegrationMailbox.update({ where: { id: input.mailboxId }, data: { webhookSubscriptionId: subscription.subscriptionId, webhookSubscriptionExpiresAt: subscription.expiresAt, webhookClientState: clientState, }, }); // Gmail delivers the current historyId on watch — anchor the global cursor // so the incremental drain starts at "now" (the initial sync backfills). if (input.provider === "gmail" && subscription.initialCursor) { await db.emailIntegrationSyncCursor.upsert({ where: { mailboxId_externalFolderId: { mailboxId: input.mailboxId, externalFolderId: GMAIL_GLOBAL_FOLDER } }, create: { tenantId: input.tenantId, mailboxId: input.mailboxId, externalFolderId: GMAIL_GLOBAL_FOLDER, cursorToken: subscription.initialCursor, }, update: { cursorToken: subscription.initialCursor }, }); } } catch { await db.emailIntegrationMailbox.update({ where: { id: input.mailboxId }, data: { lastErrorText: "emailInbox.health.webhookSubscriptionFailed" }, }); } } /** Ensure the per-folder sync cursor rows exist (initial sync = pending). */ async function ensureSyncCursors(tenantId: string, mailboxId: string, provider: EmailOAuthProvider): Promise { const folders = provider === "gmail" ? [GMAIL_GLOBAL_FOLDER] : [...GRAPH_SYNC_FOLDERS]; for (const externalFolderId of folders) { await db.emailIntegrationSyncCursor.upsert({ where: { mailboxId_externalFolderId: { mailboxId, externalFolderId } }, create: { tenantId, mailboxId, externalFolderId, initialSyncStatus: "pending" }, update: {}, }); } } /** Decrypt a mailbox's sealed tokens into adapter creds (server-only). */ export async function loadMailboxCreds(tenantId: string, mailboxId: string): Promise { const row = await db.emailIntegrationMailbox.findFirst({ where: { id: mailboxId, tenantId }, select: { oauthRefreshTokenCiphertext: true, oauthAccessTokenCiphertext: true, oauthAccessTokenExpiresAt: true }, }); if (!row?.oauthRefreshTokenCiphertext || !row.oauthAccessTokenCiphertext) { return null; } const [refreshToken, accessToken] = await Promise.all([ decryptTenantField(tenantId, row.oauthRefreshTokenCiphertext), decryptTenantField(tenantId, row.oauthAccessTokenCiphertext), ]); return { refreshToken, accessToken, expiresAt: row.oauthAccessTokenExpiresAt }; } /** * Refresh a mailbox's access token when it expires within `windowMs`, sealing * the rotated tokens (Graph rotates the refresh token too). Returns fresh creds. */ export async function ensureFreshMailboxCreds( tenantId: string, mailboxId: string, provider: EmailOAuthProvider, creds: EmailCreds, opts: { now?: Date; windowMs?: number } = {}, ): Promise { const now = opts.now ?? new Date(); if (creds.expiresAt && creds.expiresAt.getTime() > now.getTime() + (opts.windowMs ?? 60_000)) { return creds; } const adapter = adapterForProvider(provider); const refreshed = await adapter.refreshTokens(creds.refreshToken); const nextRefreshToken = refreshed.refreshToken ?? creds.refreshToken; const [accessCiphertext, refreshCiphertext] = await Promise.all([ encryptTenantField(tenantId, refreshed.accessToken), encryptTenantField(tenantId, nextRefreshToken), ]); await db.emailIntegrationMailbox.update({ where: { id: mailboxId }, data: { oauthAccessTokenCiphertext: accessCiphertext, oauthRefreshTokenCiphertext: refreshCiphertext, oauthAccessTokenExpiresAt: refreshed.expiresAt, lastErrorText: null, }, }); return { accessToken: refreshed.accessToken, refreshToken: nextRefreshToken, expiresAt: refreshed.expiresAt }; } /** * Disconnect a mailbox (owner or read_all admin — enforced by the route). * Best-effort provider revoke (Gmail: watch stop + token revoke; Graph: * subscription delete), then local soft-revoke: status revoked, soft-delete, * ciphertexts nulled (defense-in-depth). Mirrored messages stay in the CRM * (M09 Block B disconnect Klartext). */ export async function disconnectMailbox(input: { tenantId: string; mailboxId: string }): Promise { const row = await db.emailIntegrationMailbox.findFirst({ where: { id: input.mailboxId, tenantId: input.tenantId, archivedAt: null }, select: { id: true, provider: true, webhookSubscriptionId: true }, }); if (!row) { return; } const provider = row.provider as EmailOAuthProvider; const adapter = adapterForProvider(provider); const creds = await loadMailboxCreds(input.tenantId, input.mailboxId); if (creds) { if (row.webhookSubscriptionId) { await adapter.revokeWebhook(creds, row.webhookSubscriptionId).catch(() => undefined); } await adapter.revokeTokens(creds).catch(() => undefined); } await db.emailIntegrationMailbox.update({ where: { id: row.id }, data: { status: "revoked", archivedAt: new Date(), oauthRefreshTokenCiphertext: null, oauthAccessTokenCiphertext: null, webhookSubscriptionId: null, webhookClientState: null, }, }); }