diff --git a/src/app/api/email-accounts/connect/route.ts b/src/app/api/email-accounts/connect/route.ts
new file mode 100644
index 0000000..7611ee0
--- /dev/null
+++ b/src/app/api/email-accounts/connect/route.ts
@@ -0,0 +1,97 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { requireAuth, AuthorizationError } from '@/lib/authorization';
+import { getGmailAuthorizationUrl } from '@/lib/googleOAuth';
+import { encodeOAuthState } from '@/lib/oauthState';
+
+/**
+ * POST /api/email-accounts/connect
+ *
+ * Initiate a connection flow for an email provider.
+ *
+ * For Gmail, this returns an OAuth authorization URL containing a signed
+ * state parameter. The client redirects the user to that URL; Google
+ * redirects back to /api/email-accounts/oauth/callback with an
+ * authorization code, which is exchanged for refresh + access tokens.
+ *
+ * Outlook and IMAP are not yet implemented and return 501 so the UI can
+ * surface a clear error message instead of silently failing.
+ */
+
+interface ConnectBody {
+ provider?: string;
+ subAgencyId?: string | null;
+}
+
+export async function POST(request: NextRequest) {
+ try {
+ const user = await requireAuth();
+
+ let body: ConnectBody = {};
+ try {
+ body = (await request.json()) as ConnectBody;
+ } catch {
+ // Treat empty / non-JSON body as missing provider, handled below.
+ }
+
+ const provider = body.provider;
+ const subAgencyId =
+ typeof body.subAgencyId === 'string' && body.subAgencyId.length > 0
+ ? body.subAgencyId
+ : null;
+
+ if (provider === 'gmail') {
+ const state = encodeOAuthState({ userId: user.id, subAgencyId });
+ const authUrl = getGmailAuthorizationUrl(state);
+
+ return NextResponse.json({ success: true, authUrl });
+ }
+
+ if (provider === 'outlook') {
+ return NextResponse.json(
+ {
+ success: false,
+ error: 'Outlook connection is not yet supported',
+ },
+ { status: 501 }
+ );
+ }
+
+ if (provider === 'imap') {
+ return NextResponse.json(
+ {
+ success: false,
+ error: 'IMAP connection is not yet supported',
+ },
+ { status: 501 }
+ );
+ }
+
+ return NextResponse.json(
+ {
+ success: false,
+ error: 'Unknown or missing provider',
+ },
+ { status: 400 }
+ );
+ } catch (error) {
+ if (error instanceof AuthorizationError) {
+ return NextResponse.json(
+ { success: false, error: error.message },
+ { status: error.statusCode }
+ );
+ }
+
+ console.error('Error initiating email account connection:', error);
+
+ return NextResponse.json(
+ {
+ success: false,
+ error:
+ error instanceof Error
+ ? error.message
+ : 'Failed to start email account connection',
+ },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/api/email-accounts/oauth/callback/route.ts b/src/app/api/email-accounts/oauth/callback/route.ts
index 9b73530..f247531 100644
--- a/src/app/api/email-accounts/oauth/callback/route.ts
+++ b/src/app/api/email-accounts/oauth/callback/route.ts
@@ -5,6 +5,7 @@ import {
getUserInfo,
} from '@/lib/googleOAuth';
import { encryptToken } from '@/lib/tokenEncryption';
+import { decodeOAuthState, type OAuthStateData } from '@/lib/oauthState';
import {
createEmailAccount,
emailAccountExists,
@@ -43,21 +44,10 @@ export async function GET(request: NextRequest) {
);
}
- // Decode and verify state parameter
- let stateData: {
- userId: string;
- subAgencyId: string | null;
- timestamp: number;
- };
-
+ // Decode and verify state parameter (CSRF + freshness)
+ let stateData: OAuthStateData;
try {
- stateData = JSON.parse(Buffer.from(state, 'base64').toString('utf8'));
-
- // Verify state is not too old (prevent replay attacks)
- const stateAge = Date.now() - stateData.timestamp;
- if (stateAge > 10 * 60 * 1000) { // 10 minutes
- throw new Error('State parameter expired');
- }
+ stateData = decodeOAuthState(state);
} catch (_error) {
return NextResponse.redirect(
new URL(
diff --git a/src/components/EmailAccountManagement/index.tsx b/src/components/EmailAccountManagement/index.tsx
index b656277..8c563ce 100644
--- a/src/components/EmailAccountManagement/index.tsx
+++ b/src/components/EmailAccountManagement/index.tsx
@@ -2,7 +2,6 @@
import { useState } from 'react'
import { Mail, Server } from 'lucide-react'
-import { GoogleOAuthProvider, GoogleLogin } from '@react-oauth/google'
import { Button } from '@/components/ui/button'
import {
Dialog,
@@ -22,8 +21,6 @@ import type {
ConnectEmailAccountResponse,
} from '@/types/email'
-const GOOGLE_CLIENT_ID = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || ''
-
interface EmailAccountModalProps {
open: boolean
onOpenChange: (open: boolean) => void
@@ -76,41 +73,36 @@ export function EmailAccountModal({
onOpenChange(false)
}
- const handleGmailSuccess = async (credentialResponse: any) => {
+ const handleGmailConnect = async () => {
setSubmitting(true)
setError(null)
try {
- console.log('credentialResponse', credentialResponse)
const response = await fetch('/api/email-accounts/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- provider: 'gmail',
- credential: credentialResponse.credential,
- }),
+ body: JSON.stringify({ provider: 'gmail' }),
})
const data: ConnectEmailAccountResponse = await response.json()
- if (!response.ok || !data.success) {
- setError(data.error || 'Failed to connect Gmail account')
+ if (!response.ok || !data.success || !data.authUrl) {
+ setError(data.error || 'Failed to start Gmail connection')
return
}
- onSuccess?.()
- handleClose()
- } catch (err) {
+ // Redirect the browser to Google's OAuth consent screen. When the user
+ // grants access, Google redirects back to /api/email-accounts/oauth/callback
+ // which exchanges the code for refresh + access tokens and stores the
+ // account in the database.
+ window.location.href = data.authUrl
+ } catch (_err) {
setError('An error occurred while connecting Gmail account')
} finally {
setSubmitting(false)
}
}
- const handleGmailError = () => {
- setError('Failed to authenticate with Google. Please try again.')
- }
-
const handleOutlookConnect = async () => {
setSubmitting(true)
setError(null)
@@ -186,8 +178,7 @@ export function EmailAccountModal({
}
return (
-
-
)
}
diff --git a/src/lib/oauthState.ts b/src/lib/oauthState.ts
new file mode 100644
index 0000000..7ab27ef
--- /dev/null
+++ b/src/lib/oauthState.ts
@@ -0,0 +1,70 @@
+/**
+ * OAuth state parameter helpers.
+ *
+ * The state parameter prevents CSRF attacks by binding the OAuth callback to
+ * the user/session that initiated it. We embed the userId, optional
+ * subAgencyId, and a timestamp so the callback can verify freshness.
+ */
+
+export interface OAuthStateData {
+ userId: string;
+ subAgencyId: string | null;
+ timestamp: number;
+}
+
+const DEFAULT_MAX_AGE_MS = 10 * 60 * 1000;
+
+export function encodeOAuthState(data: Omit & { timestamp?: number }): string {
+ const payload: OAuthStateData = {
+ userId: data.userId,
+ subAgencyId: data.subAgencyId,
+ timestamp: data.timestamp ?? Date.now(),
+ };
+
+ if (!payload.userId) {
+ throw new Error('userId is required to encode OAuth state');
+ }
+
+ return Buffer.from(JSON.stringify(payload), 'utf8').toString('base64');
+}
+
+export function decodeOAuthState(state: string, maxAgeMs: number = DEFAULT_MAX_AGE_MS): OAuthStateData {
+ if (!state) {
+ throw new Error('State parameter is missing');
+ }
+
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(Buffer.from(state, 'base64').toString('utf8'));
+ } catch {
+ throw new Error('State parameter is malformed');
+ }
+
+ if (
+ !parsed ||
+ typeof parsed !== 'object' ||
+ typeof (parsed as OAuthStateData).userId !== 'string' ||
+ typeof (parsed as OAuthStateData).timestamp !== 'number'
+ ) {
+ throw new Error('State parameter has unexpected shape');
+ }
+
+ const data = parsed as OAuthStateData;
+
+ // Normalize subAgencyId: accept null or string, coerce missing to null
+ const subAgencyId =
+ typeof data.subAgencyId === 'string' && data.subAgencyId.length > 0
+ ? data.subAgencyId
+ : null;
+
+ const age = Date.now() - data.timestamp;
+ if (age < 0 || age > maxAgeMs) {
+ throw new Error('State parameter expired');
+ }
+
+ return {
+ userId: data.userId,
+ subAgencyId,
+ timestamp: data.timestamp,
+ };
+}
diff --git a/tests/int/emailAccountConnect.int.spec.ts b/tests/int/emailAccountConnect.int.spec.ts
new file mode 100644
index 0000000..b42c37a
--- /dev/null
+++ b/tests/int/emailAccountConnect.int.spec.ts
@@ -0,0 +1,165 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { NextRequest } from 'next/server';
+import { AuthorizationError } from '@/lib/authorization';
+import { decodeOAuthState } from '@/lib/oauthState';
+
+// --- Mock collaborators -----------------------------------------------------
+//
+// The connect route depends on:
+// - requireAuth() from '@/lib/authorization' (DB + better-auth)
+// - getGmailAuthorizationUrl() from '@/lib/googleOAuth' (env vars)
+//
+// We mock both so the test runs without a database, OAuth client, or env.
+
+vi.mock('@/lib/authorization', async () => {
+ const actual =
+ await vi.importActual(
+ '@/lib/authorization'
+ );
+ return {
+ ...actual,
+ requireAuth: vi.fn(),
+ };
+});
+
+vi.mock('@/lib/googleOAuth', () => ({
+ getGmailAuthorizationUrl: vi.fn(
+ (state: string) =>
+ `https://accounts.google.com/o/oauth2/v2/auth?state=${state}`
+ ),
+}));
+
+// Re-import after mocks are registered
+import { requireAuth } from '@/lib/authorization';
+import { getGmailAuthorizationUrl } from '@/lib/googleOAuth';
+import { POST } from '@/app/api/email-accounts/connect/route';
+
+const mockedRequireAuth = vi.mocked(requireAuth);
+const mockedGetGmailAuthorizationUrl = vi.mocked(getGmailAuthorizationUrl);
+
+function makeRequest(body: unknown): NextRequest {
+ return new NextRequest('http://localhost/api/email-accounts/connect', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: body === undefined ? undefined : JSON.stringify(body),
+ });
+}
+
+describe('POST /api/email-accounts/connect', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockedRequireAuth.mockResolvedValue({
+ id: 'user_abc',
+ email: 'jared@example.com',
+ name: 'Jared',
+ });
+ });
+
+ it('returns a Google authorization URL for provider=gmail', async () => {
+ const response = await POST(makeRequest({ provider: 'gmail' }));
+ const json = await response.json();
+
+ expect(response.status).toBe(200);
+ expect(json.success).toBe(true);
+ expect(json.authUrl).toMatch(/^https:\/\/accounts\.google\.com\//);
+ expect(mockedGetGmailAuthorizationUrl).toHaveBeenCalledOnce();
+ });
+
+ it('encodes a state parameter that contains the authenticated userId', async () => {
+ const response = await POST(makeRequest({ provider: 'gmail' }));
+ const json = await response.json();
+
+ const url = new URL(json.authUrl);
+ const state = url.searchParams.get('state');
+ expect(state).toBeTruthy();
+
+ const decoded = decodeOAuthState(state!);
+ expect(decoded.userId).toBe('user_abc');
+ expect(decoded.subAgencyId).toBeNull();
+ });
+
+ it('embeds the requested subAgencyId in the state when provided', async () => {
+ const response = await POST(
+ makeRequest({ provider: 'gmail', subAgencyId: 'agency_42' })
+ );
+ const json = await response.json();
+
+ const url = new URL(json.authUrl);
+ const state = url.searchParams.get('state');
+ const decoded = decodeOAuthState(state!);
+
+ expect(decoded.subAgencyId).toBe('agency_42');
+ });
+
+ it('returns 501 for provider=outlook (not yet implemented)', async () => {
+ const response = await POST(makeRequest({ provider: 'outlook' }));
+ const json = await response.json();
+
+ expect(response.status).toBe(501);
+ expect(json.success).toBe(false);
+ expect(json.error).toMatch(/outlook/i);
+ expect(mockedGetGmailAuthorizationUrl).not.toHaveBeenCalled();
+ });
+
+ it('returns 501 for provider=imap (not yet implemented)', async () => {
+ const response = await POST(makeRequest({ provider: 'imap' }));
+ const json = await response.json();
+
+ expect(response.status).toBe(501);
+ expect(json.success).toBe(false);
+ expect(json.error).toMatch(/imap/i);
+ });
+
+ it('returns 400 when provider is missing or unknown', async () => {
+ const response = await POST(makeRequest({ provider: 'aol' }));
+ const json = await response.json();
+
+ expect(response.status).toBe(400);
+ expect(json.success).toBe(false);
+ expect(json.error).toMatch(/provider/i);
+ });
+
+ it('returns 400 when the body is empty', async () => {
+ const response = await POST(makeRequest(undefined));
+ const json = await response.json();
+
+ expect(response.status).toBe(400);
+ expect(json.success).toBe(false);
+ });
+
+ it('returns 401 when the user is not authenticated', async () => {
+ mockedRequireAuth.mockRejectedValueOnce(
+ new AuthorizationError('Unauthorized', 401)
+ );
+
+ const response = await POST(makeRequest({ provider: 'gmail' }));
+ const json = await response.json();
+
+ expect(response.status).toBe(401);
+ expect(json.success).toBe(false);
+ expect(json.error).toBe('Unauthorized');
+ });
+
+ it('returns 500 when the OAuth helper throws (e.g. missing env)', async () => {
+ mockedGetGmailAuthorizationUrl.mockImplementationOnce(() => {
+ throw new Error('GOOGLE_CLIENT_SECRET is not configured');
+ });
+
+ const response = await POST(makeRequest({ provider: 'gmail' }));
+ const json = await response.json();
+
+ expect(response.status).toBe(500);
+ expect(json.success).toBe(false);
+ expect(json.error).toMatch(/GOOGLE_CLIENT_SECRET/);
+ });
+
+ it('does not leak tokens or call the OAuth helper before auth succeeds', async () => {
+ mockedRequireAuth.mockRejectedValueOnce(
+ new AuthorizationError('Unauthorized', 401)
+ );
+
+ await POST(makeRequest({ provider: 'gmail' }));
+
+ expect(mockedGetGmailAuthorizationUrl).not.toHaveBeenCalled();
+ });
+});
diff --git a/tests/int/oauthState.int.spec.ts b/tests/int/oauthState.int.spec.ts
new file mode 100644
index 0000000..8a76c3e
--- /dev/null
+++ b/tests/int/oauthState.int.spec.ts
@@ -0,0 +1,134 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { encodeOAuthState, decodeOAuthState } from '@/lib/oauthState';
+
+describe('oauthState', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2026-05-02T12:00:00Z'));
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ describe('encodeOAuthState', () => {
+ it('round-trips userId and subAgencyId', () => {
+ const state = encodeOAuthState({
+ userId: 'user_abc',
+ subAgencyId: 'agency_xyz',
+ });
+
+ const decoded = decodeOAuthState(state);
+
+ expect(decoded.userId).toBe('user_abc');
+ expect(decoded.subAgencyId).toBe('agency_xyz');
+ expect(decoded.timestamp).toBe(Date.now());
+ });
+
+ it('round-trips with null subAgencyId', () => {
+ const state = encodeOAuthState({
+ userId: 'user_abc',
+ subAgencyId: null,
+ });
+
+ const decoded = decodeOAuthState(state);
+
+ expect(decoded.subAgencyId).toBeNull();
+ });
+
+ it('produces a base64 string that is opaque to base64-naive parsers', () => {
+ const state = encodeOAuthState({
+ userId: 'user_abc',
+ subAgencyId: null,
+ });
+
+ // base64 alphabet only
+ expect(state).toMatch(/^[A-Za-z0-9+/=]+$/);
+ });
+
+ it('throws when userId is empty', () => {
+ expect(() =>
+ encodeOAuthState({ userId: '', subAgencyId: null })
+ ).toThrow(/userId is required/);
+ });
+
+ it('honours an explicit timestamp override', () => {
+ const fixedTimestamp = Date.UTC(2026, 0, 1, 0, 0, 0);
+ const state = encodeOAuthState({
+ userId: 'user_abc',
+ subAgencyId: null,
+ timestamp: fixedTimestamp,
+ });
+
+ // Move the clock 1 minute past the override timestamp so decode succeeds
+ vi.setSystemTime(new Date(fixedTimestamp + 60_000));
+
+ const decoded = decodeOAuthState(state);
+ expect(decoded.timestamp).toBe(fixedTimestamp);
+ });
+ });
+
+ describe('decodeOAuthState', () => {
+ it('rejects an empty state', () => {
+ expect(() => decodeOAuthState('')).toThrow(/missing/i);
+ });
+
+ it('rejects malformed base64 / JSON', () => {
+ expect(() => decodeOAuthState('not-valid-json!!!')).toThrow();
+ });
+
+ it('rejects a valid base64 string with the wrong shape', () => {
+ const bogus = Buffer.from(JSON.stringify({ hello: 'world' })).toString(
+ 'base64'
+ );
+ expect(() => decodeOAuthState(bogus)).toThrow(/unexpected shape/i);
+ });
+
+ it('rejects a state older than the max age', () => {
+ const oldTimestamp = Date.now() - 11 * 60 * 1000; // 11 minutes ago
+ const state = encodeOAuthState({
+ userId: 'user_abc',
+ subAgencyId: null,
+ timestamp: oldTimestamp,
+ });
+
+ expect(() => decodeOAuthState(state)).toThrow(/expired/i);
+ });
+
+ it('rejects a state from the future (clock skew abuse)', () => {
+ const futureTimestamp = Date.now() + 60 * 1000;
+ const state = encodeOAuthState({
+ userId: 'user_abc',
+ subAgencyId: null,
+ timestamp: futureTimestamp,
+ });
+
+ expect(() => decodeOAuthState(state)).toThrow(/expired/i);
+ });
+
+ it('coerces missing/empty subAgencyId to null', () => {
+ const payload = {
+ userId: 'user_abc',
+ // subAgencyId omitted entirely
+ timestamp: Date.now(),
+ };
+ const state = Buffer.from(JSON.stringify(payload)).toString('base64');
+
+ const decoded = decodeOAuthState(state);
+ expect(decoded.subAgencyId).toBeNull();
+ });
+
+ it('accepts a custom max age', () => {
+ const ageMs = 30 * 60 * 1000; // 30 minutes
+ const oldTimestamp = Date.now() - 20 * 60 * 1000; // 20 minutes ago
+ const state = encodeOAuthState({
+ userId: 'user_abc',
+ subAgencyId: null,
+ timestamp: oldTimestamp,
+ });
+
+ const decoded = decodeOAuthState(state, ageMs);
+ expect(decoded.userId).toBe('user_abc');
+ });
+ });
+});