Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/modules/emailInbox/services/MailboxConnectionService.server.ts
T

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,
},
});
}