289 lines
12 KiB
TypeScript
289 lines
12 KiB
TypeScript
// 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<ReturnType<EmailProviderAdapter["exchangeCode"]>>;
|
|
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<void> {
|
|
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<void> {
|
|
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<EmailCreds | null> {
|
|
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<EmailCreds> {
|
|
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<void> {
|
|
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,
|
|
},
|
|
});
|
|
}
|