diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..f9857e7 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,28 @@ +#!/bin/bash +# Pre-commit hook to block commits containing production credentials, hostnames, or secrets. + +# 1. Block database or redis URLs with production hostnames in env/config files +STAGED_ENV_FILES=$(git diff --cached --name-only | grep -E "(\.env|\.env\.example)$") +if [ -n "$STAGED_ENV_FILES" ]; then + for file in $STAGED_ENV_FILES; do + if grep -Ei "DATABASE_URL.*(supabase|render|aws|rds)" "$file" >/dev/null 2>&1; then + echo "CRITICAL: DATABASE_URL with production hostnames (supabase, render, aws, rds) detected in $file! Blocking commit." + exit 1 + fi + if grep -Ei "REDIS_URL.*(upstash|render|aws|redis-cloud)" "$file" >/dev/null 2>&1; then + echo "CRITICAL: REDIS_URL with production hostnames (upstash, render, aws, redis-cloud) detected in $file! Blocking commit." + exit 1 + fi + done +fi + +# 2. Block potential OAuth client secrets or refresh tokens, excluding test and hook files +STAGED_CODE_FILES=$(git diff --cached --name-only | grep -vE "(\.test\.|__tests__|pre-commit)") +if [ -n "$STAGED_CODE_FILES" ]; then + if echo "$STAGED_CODE_FILES" | xargs grep -E "(GOCSPX-[A-Za-z0-9_-]{24,}|1\/\/[A-Za-z0-9_-]{20,})" >/dev/null 2>&1; then + echo "CRITICAL: Staged changes contain patterns matching Google OAuth client secrets or refresh tokens! Blocking commit." + exit 1 + fi +fi + +exit 0 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..0c491ec --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,22 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: check-yaml + - id: check-json + - id: end-of-file-fixer + - id: trailing-whitespace + - id: detect-private-key + + - repo: https://github.com/gitleaks/gitleaks + rev: v8.18.2 + hooks: + - id: gitleaks + + - repo: local + hooks: + - id: block-secrets-and-prod-urls + name: Block secrets and prod URLs + entry: .githooks/pre-commit + language: script + stages: [commit] diff --git a/backend/.env.example b/backend/.env.example index d5db064..72bac2c 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -6,11 +6,13 @@ PORT=8000 NODE_ENV=development # Set to 'production' in prod (enables strict TLS for IMAP) +# ─── Environment Configuration ──────────────────────────────────────────────── +ENVIRONMENT=local # 'local' | 'staging' | 'production' +MOCK_GMAIL=true # Set to 'true' to enable mock Gmail flow in local mode + # ─── Database ───────────────────────────────────────────────────────────────── -# SQLite (local dev): -DATABASE_URL="file:../apps/api/inboxos.db" -# PostgreSQL (production): Uncomment and fill in below, comment out SQLite line above -# DATABASE_URL="postgresql://USER:PASSWORD@HOST:5432/inboxos?schema=public" +# Local Docker PostgreSQL instance (Default): +DATABASE_URL="postgresql://postgres:postgres@localhost:5432/inboxos?schema=public" # ─── Authentication ─────────────────────────────────────────────────────────── # Generate with: node -e "console.log(require('crypto').randomBytes(64).toString('hex'))" diff --git a/backend/fixtures/gmail/message_detail.json b/backend/fixtures/gmail/message_detail.json new file mode 100644 index 0000000..902cf77 --- /dev/null +++ b/backend/fixtures/gmail/message_detail.json @@ -0,0 +1,28 @@ +{ + "id": "mock-message-id-1", + "threadId": "mock-thread-id-1", + "payload": { + "headers": [ + { "name": "Subject", "value": "Welcome to local InboxOS development!" }, + { "name": "From", "value": "InboxOS Team " }, + { "name": "To", "value": "mock-contributor@inboxos.dev" } + ], + "mimeType": "multipart/mixed", + "parts": [ + { + "mimeType": "text/plain", + "body": { + "data": "SGVsbG8hIFRoaXMgaXMgYSBtb2NrIGVtYWlsIGJvZHkgZm9yIGxvY2FsIGRldmVsb3BtZW50LiBJZiB5b3UncmUgc2VlaW5nIHRoaXMsIG1vY2sgR21haWwgbW9kZSBpcyB3b3JraW5nIHBlcmZlY3RseSEgQ2hlY2sgb3V0IGh0dHBzOi8vZ2l0aHViLmNvbS9Db2RlTGFic0FJMjkvSW5ib3hfT1MgdG8gY29udHJpYnV0ZS4=" + } + }, + { + "mimeType": "application/pdf", + "filename": "contributors_guide.pdf", + "body": { + "attachmentId": "mock-attachment-id-12345", + "size": 512 + } + } + ] + } +} diff --git a/backend/fixtures/gmail/messages_list.json b/backend/fixtures/gmail/messages_list.json new file mode 100644 index 0000000..e0b496a --- /dev/null +++ b/backend/fixtures/gmail/messages_list.json @@ -0,0 +1,13 @@ +{ + "messages": [ + { + "id": "mock-message-id-1", + "threadId": "mock-thread-id-1" + }, + { + "id": "mock-message-id-2", + "threadId": "mock-thread-id-2" + } + ], + "resultSizeEstimate": 2 +} diff --git a/backend/fixtures/gmail/oauth_token.json b/backend/fixtures/gmail/oauth_token.json new file mode 100644 index 0000000..5171bbe --- /dev/null +++ b/backend/fixtures/gmail/oauth_token.json @@ -0,0 +1,7 @@ +{ + "access_token": "mock-access-token-12345", + "refresh_token": "mock-refresh-token-67890", + "scope": "https://mail.google.com/ https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile", + "token_type": "Bearer", + "expiry_date": 1999999999000 +} diff --git a/backend/fixtures/gmail/profile.json b/backend/fixtures/gmail/profile.json new file mode 100644 index 0000000..bdd6fb7 --- /dev/null +++ b/backend/fixtures/gmail/profile.json @@ -0,0 +1,6 @@ +{ + "emailAddress": "mock-contributor@inboxos.dev", + "messagesTotal": 10, + "threadsTotal": 5, + "historyId": "12345" +} diff --git a/backend/fixtures/gmail/push_notification.json b/backend/fixtures/gmail/push_notification.json new file mode 100644 index 0000000..c830d65 --- /dev/null +++ b/backend/fixtures/gmail/push_notification.json @@ -0,0 +1,7 @@ +{ + "sender": "alerts@inboxos.dev", + "recipient": "mock-contributor@inboxos.dev", + "subject": "Mock Webhook Ingestion Event", + "body": "This is a mock push notification email webhook payload designed to test local webhook ingestion pipelines.", + "messageId": "mock-webhook-message-id-999" +} diff --git a/backend/package.json b/backend/package.json index dafc10f..6f3d2b8 100644 --- a/backend/package.json +++ b/backend/package.json @@ -25,7 +25,8 @@ "lint": "eslint \"src/**/*.ts\"", "format": "prettier --write \"src/**/*.ts\"", "test": "node --max-old-space-size=4096 ../node_modules/jest/bin/jest.js --runInBand", - "test:coverage": "node --max-old-space-size=4096 ../node_modules/jest/bin/jest.js --coverage --runInBand" + "test:coverage": "node --max-old-space-size=4096 ../node_modules/jest/bin/jest.js --coverage --runInBand", + "validate": "npm run lint && npm run typecheck && npm test && npm run build" }, "dependencies": { "@google/genai": "^2.10.0", diff --git a/backend/render.yaml b/backend/render.yaml index c47d495..a6fe00d 100644 --- a/backend/render.yaml +++ b/backend/render.yaml @@ -18,6 +18,8 @@ services: envVars: - key: NODE_ENV value: production + - key: ENVIRONMENT + value: production - key: PORT value: "8000" diff --git a/backend/src/__tests__/mock-gmail.test.ts b/backend/src/__tests__/mock-gmail.test.ts new file mode 100644 index 0000000..9564b9a --- /dev/null +++ b/backend/src/__tests__/mock-gmail.test.ts @@ -0,0 +1,79 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ + +describe('Three-Tier Environment System & Mock Gmail Client', () => { + const originalEnv = process.env; + + beforeEach(() => { + jest.resetModules(); + process.env = { ...originalEnv }; + }); + + afterAll(() => { + process.env = originalEnv; + }); + + it('should successfully boot in mock mode when MOCK_GMAIL=true and ENVIRONMENT=local', () => { + process.env.MOCK_GMAIL = 'true'; + process.env.ENVIRONMENT = 'local'; + + // Dynamically import environment config to check logic + const { + IS_MOCK, + ENVIRONMENT, + MOCK_GMAIL, + } = require('../config/environment'); + expect(IS_MOCK).toBe(true); + expect(ENVIRONMENT).toBe('local'); + expect(MOCK_GMAIL).toBe(true); + + const { + gmailClient: activeClient, + } = require('../services/gmail/gmail-client'); + expect(activeClient.constructor.name).toBe('MockGmailClient'); + }); + + it('should serve fixture data in mock mode', async () => { + process.env.MOCK_GMAIL = 'true'; + process.env.ENVIRONMENT = 'local'; + const { + gmailClient: activeClient, + } = require('../services/gmail/gmail-client'); + + const profile = await activeClient.getProfile({}, 'http://localhost'); + expect(profile.emailAddress).toBe('mock-contributor@inboxos.dev'); + + const msgList = await activeClient.listMessages({}, 'http://localhost', { + maxResults: 10, + }); + expect(msgList.messages).toBeDefined(); + expect(msgList.messages.length).toBeGreaterThan(0); + + const message = await activeClient.getMessage( + {}, + 'http://localhost', + 'mock-msg-1' + ); + expect(message.data.id).toBe('mock-msg-1'); + expect(message.data.payload.parts).toBeDefined(); + }); + + it('should refuse to boot and throw error if MOCK_GMAIL=true but ENVIRONMENT=production', () => { + process.env.MOCK_GMAIL = 'true'; + process.env.ENVIRONMENT = 'production'; + + expect(() => { + require('../config/environment'); + }).toThrow(/MOCK_GMAIL is set to true, but ENVIRONMENT is "production"/); + }); + + it('should refuse to boot and throw error if local database URL points to a production host', () => { + process.env.MOCK_GMAIL = 'true'; + process.env.ENVIRONMENT = 'local'; + process.env.DATABASE_URL = + 'postgresql://user:pass@my-supabase-prod.supabase.co:5432/db'; + + expect(() => { + require('../config/environment'); + }).toThrow(/connect to a production\/staging hostname/); + }); +}); diff --git a/backend/src/config/environment.ts b/backend/src/config/environment.ts new file mode 100644 index 0000000..6579bf6 --- /dev/null +++ b/backend/src/config/environment.ts @@ -0,0 +1,38 @@ +import { logger } from '../utils/logger'; + +// Load variables +export const ENVIRONMENT = process.env.ENVIRONMENT || 'local'; +export const MOCK_GMAIL = process.env.MOCK_GMAIL === 'true'; + +// Validate values +const validEnvironments = ['local', 'staging', 'production']; +if (!validEnvironments.includes(ENVIRONMENT)) { + const errMsg = `CRITICAL CONFIG FAILURE: Invalid ENVIRONMENT value "${ENVIRONMENT}". Must be one of: ${validEnvironments.join(', ')}`; + logger.error(errMsg); + throw new Error(errMsg); +} + +// 1. Mock mode requires BOTH MOCK_GMAIL=true AND ENVIRONMENT=local +export const IS_MOCK = MOCK_GMAIL && ENVIRONMENT === 'local'; + +// 3. Fail loudly at startup if MOCK_GMAIL=true but ENVIRONMENT != local +if (MOCK_GMAIL && ENVIRONMENT !== 'local') { + const errMsg = `CRITICAL SECURITY FAILURE: MOCK_GMAIL is set to true, but ENVIRONMENT is "${ENVIRONMENT}". Mock mode is strictly restricted to local environments to prevent mock data leakage in production/staging.`; + logger.error(errMsg); + throw new Error(errMsg); +} + +// 7. Refuse to boot in local mode if DATABASE_URL or REDIS_URL resolve to a known production/staging hostname pattern +if (ENVIRONMENT === 'local') { + const dbUrl = process.env.DATABASE_URL || ''; + const redisUrl = process.env.REDIS_URL || ''; + + const isProdDb = /supabase|upstash|render|aws|rds/i.test(dbUrl); + const isProdRedis = /supabase|upstash|render|aws|redis-cloud/i.test(redisUrl); + + if (isProdDb || isProdRedis) { + const errMsg = `CRITICAL SECURITY FAILURE: Local development mode is configured to connect to a production/staging hostname (DATABASE_URL=${dbUrl}, REDIS_URL=${redisUrl}). Refusing to boot.`; + logger.error(errMsg); + throw new Error(errMsg); + } +} diff --git a/backend/src/reminder-worker.ts b/backend/src/reminder-worker.ts index 070af45..39ae6fa 100644 --- a/backend/src/reminder-worker.ts +++ b/backend/src/reminder-worker.ts @@ -11,6 +11,7 @@ import * as path from 'path'; dotenv.config({ path: path.resolve(__dirname, '../../.env') }); +import './config/environment'; import './utils/redis-patch'; import { ReminderSchedulerService } from './services/actions/reminder-scheduler.service'; import { logger } from './utils/logger'; diff --git a/backend/src/server.ts b/backend/src/server.ts index ba9d4fa..ab6efde 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -4,6 +4,8 @@ import * as path from 'path'; // Load environment variables dotenv.config({ path: path.resolve(__dirname, '../.env') }); +import { IS_MOCK } from './config/environment'; +import { gmailClient } from './services/gmail/gmail-client'; import './utils/redis-patch'; import express, { Request, Response } from 'express'; import cookieParser from 'cookie-parser'; @@ -1212,8 +1214,7 @@ app.put( } ); -// OAuth2 & Encryption config -const getOAuth2Client = (req?: Request) => { +const getRedirectUri = (req?: Request) => { let redirectUri = process.env.GMAIL_REDIRECT_URI; if (!redirectUri && req) { const protocol = @@ -1226,10 +1227,15 @@ const getOAuth2Client = (req?: Request) => { ? `${process.env.RENDER_EXTERNAL_URL.replace(/\/$/, '')}/api/integrations/gmail/callback` : 'http://localhost:8000/api/integrations/gmail/callback'; } + return redirectUri; +}; + +// OAuth2 & Encryption config +const getOAuth2Client = (req?: Request) => { return new google.auth.OAuth2( process.env.GMAIL_CLIENT_ID, process.env.GMAIL_CLIENT_SECRET, - redirectUri + getRedirectUri(req) ); }; @@ -1267,6 +1273,11 @@ app.get( '/api/integrations/gmail/auth', requireAuth, (req: AuthenticatedRequest, res: Response) => { + if (IS_MOCK) { + const callbackUrl = + getRedirectUri(req) + `?code=mock-auth-code&state=${req.user?.userId}`; + return res.json({ url: callbackUrl }); + } const client = getOAuth2Client(req); const url = client.generateAuthUrl({ access_type: 'offline', @@ -1332,13 +1343,10 @@ app.get( } try { - const client = getOAuth2Client(req); - const { tokens } = await client.getToken(code); - client.setCredentials(tokens); - - const gmail = google.gmail({ version: 'v1', auth: client }); - const profile = await gmail.users.getProfile({ userId: 'me' }); - const emailAddress = profile.data.emailAddress; + const redirectUri = getRedirectUri(req); + const tokens = await gmailClient.getToken(code, redirectUri); + const profile = await gmailClient.getProfile(tokens, redirectUri); + const emailAddress = profile.emailAddress; if (!emailAddress) { return res @@ -1513,34 +1521,25 @@ app.post( }); } - // Build authenticated Gmail client with stored tokens - const userOAuth = new google.auth.OAuth2( - process.env.GMAIL_CLIENT_ID, - process.env.GMAIL_CLIENT_SECRET, - process.env.GMAIL_REDIRECT_URI - ); - userOAuth.setCredentials(tokens); - - // Auto-refresh token if expired - userOAuth.on('tokens', async (newTokens) => { + const redirectUri = process.env.GMAIL_REDIRECT_URI || ''; + const onTokensRefreshed = async (newTokens: any) => { const merged = { ...tokens, ...newTokens }; const encrypted = encrypt(JSON.stringify(merged)); await prisma.emailAccount.update({ where: { id: account.id }, data: { encryptedTokens: encrypted }, }); - }); - - const gmail = google.gmail({ version: 'v1', auth: userOAuth }); + }; // Fetch list of latest 50 messages - const listRes = await gmail.users.messages.list({ - userId: 'me', - maxResults: 50, - q: 'in:inbox', - }); + const listRes = await gmailClient.listMessages( + tokens, + redirectUri, + { maxResults: 50, q: 'in:inbox' }, + onTokensRefreshed + ); - const messages = listRes.data.messages || []; + const messages = listRes.messages || []; if (messages.length === 0) { await prisma.emailAccount.update({ where: { id: account.id }, @@ -1561,16 +1560,18 @@ app.post( if (existing) continue; try { - const fullMsg = await gmail.users.messages.get({ - userId: 'me', - id: msg.id, - format: 'full', - }); + const fullMsg = await gmailClient.getMessage( + tokens, + redirectUri, + msg.id, + onTokensRefreshed + ); const headers = fullMsg.data.payload?.headers || []; const getHeader = (name: string) => - headers.find((h) => h.name?.toLowerCase() === name.toLowerCase()) - ?.value || ''; + headers.find( + (h: any) => h.name?.toLowerCase() === name.toLowerCase() + )?.value || ''; const subject = getHeader('Subject') || '(no subject)'; const from = getHeader('From') || 'unknown@unknown.com'; diff --git a/backend/src/services/email-sender.service.ts b/backend/src/services/email-sender.service.ts index ce8cc7c..93f3cf6 100644 --- a/backend/src/services/email-sender.service.ts +++ b/backend/src/services/email-sender.service.ts @@ -1,7 +1,7 @@ import * as nodemailer from 'nodemailer'; import { PrismaClient } from '@prisma/client'; import { decrypt, encrypt } from '../utils/crypto'; -import { google } from 'googleapis'; +import { gmailClient } from './gmail/gmail-client'; import { logger } from '../utils/logger'; const prisma = new PrismaClient(); @@ -151,15 +151,7 @@ export class EmailSenderService { ? `${process.env.RENDER_EXTERNAL_URL.replace(/\/$/, '')}/api/integrations/gmail/callback` : 'http://localhost:8000/api/integrations/gmail/callback'); - const oauth2Client = new google.auth.OAuth2( - process.env.GMAIL_CLIENT_ID, - process.env.GMAIL_CLIENT_SECRET, - redirectUri - ); - oauth2Client.setCredentials(tokens); - - // Auto token refresh listener to save updated credentials - oauth2Client.on('tokens', async (newTokens) => { + const onTokensRefreshed = async (newTokens: any) => { if (newTokens.refresh_token || newTokens.access_token) { const updatedTokens = { ...tokens, ...newTokens }; const reEncrypted = encrypt(JSON.stringify(updatedTokens)); @@ -168,7 +160,7 @@ export class EmailSenderService { data: { encryptedTokens: reEncrypted }, }); } - }); + }; const mailOptions = { from: account.emailAddress, @@ -204,14 +196,14 @@ export class EmailSenderService { }); } - const gmail = google.gmail({ version: 'v1', auth: oauth2Client }); - let res: any; try { - res = await gmail.users.messages.send({ - userId: 'me', - requestBody: { raw: rawMime }, - }); + res = await gmailClient.sendMessage( + tokens, + redirectUri, + rawMime, + onTokensRefreshed + ); } catch (gmailErr: any) { // Classify OAuth errors into GmailAuthError; others bubble as-is const isAuthError = diff --git a/backend/src/services/gmail-sync.service.ts b/backend/src/services/gmail-sync.service.ts index 60a94fd..a4c6295 100644 --- a/backend/src/services/gmail-sync.service.ts +++ b/backend/src/services/gmail-sync.service.ts @@ -1,4 +1,4 @@ -import { google } from 'googleapis'; +import { gmailClient } from './gmail/gmail-client'; import { PrismaClient } from '@prisma/client'; import { decrypt, encrypt } from '../utils/crypto'; import { LinkAttachmentExtractorService } from './parser/link-attachment-extractor.service'; @@ -30,15 +30,7 @@ export class GmailSyncService { ? `${process.env.RENDER_EXTERNAL_URL.replace(/\/$/, '')}/api/integrations/gmail/callback` : 'http://localhost:8000/api/integrations/gmail/callback'); - const oauth2Client = new google.auth.OAuth2( - process.env.GMAIL_CLIENT_ID, - process.env.GMAIL_CLIENT_SECRET, - redirectUri - ); - oauth2Client.setCredentials(tokens); - - // Auto token refresh listener - oauth2Client.on('tokens', async (newTokens) => { + const onTokensRefreshed = async (newTokens: any) => { if (newTokens.refresh_token || newTokens.access_token) { const updatedTokens = { ...tokens, ...newTokens }; const reEncrypted = encrypt(JSON.stringify(updatedTokens)); @@ -47,17 +39,17 @@ export class GmailSyncService { data: { encryptedTokens: reEncrypted }, }); } - }); - - const gmail = google.gmail({ version: 'v1', auth: oauth2Client }); + }; // 3. Fetch latest 50 message IDs - const listRes = await gmail.users.messages.list({ - userId: 'me', - maxResults: 50, - }); + const listRes = await gmailClient.listMessages( + tokens, + redirectUri, + { maxResults: 50 }, + onTokensRefreshed + ); - const messages = listRes.data.messages || []; + const messages = listRes.messages || []; let syncedCount = 0; for (const msg of messages) { @@ -75,32 +67,35 @@ export class GmailSyncService { if (existing) continue; // 4. Fetch full payload - const msgData = await gmail.users.messages.get({ - userId: 'me', - id: msg.id, - format: 'full', - }); + const msgData = await gmailClient.getMessage( + tokens, + redirectUri, + msg.id, + onTokensRefreshed + ); const payload = msgData.data.payload; const headers = payload?.headers || []; const subject = - headers.find((h) => h.name?.toLowerCase() === 'subject')?.value || + headers.find((h: any) => h.name?.toLowerCase() === 'subject')?.value || 'No Subject'; const sender = - headers.find((h) => h.name?.toLowerCase() === 'from')?.value || + headers.find((h: any) => h.name?.toLowerCase() === 'from')?.value || 'Unknown Sender'; const recipient = - headers.find((h) => h.name?.toLowerCase() === 'to')?.value || + headers.find((h: any) => h.name?.toLowerCase() === 'to')?.value || account.emailAddress; const inReplyTo = - headers.find((h) => h.name?.toLowerCase() === 'in-reply-to')?.value || - null; + headers.find((h: any) => h.name?.toLowerCase() === 'in-reply-to') + ?.value || null; // Decode base64url body let bodyData = ''; if (payload?.parts && payload.parts.length > 0) { - const textPart = payload.parts.find((p) => p.mimeType === 'text/plain'); + const textPart = payload.parts.find( + (p: any) => p.mimeType === 'text/plain' + ); if (textPart && textPart.body?.data) { bodyData = Buffer.from(textPart.body.data, 'base64url').toString( 'utf-8' diff --git a/backend/src/services/gmail/gmail-client.ts b/backend/src/services/gmail/gmail-client.ts new file mode 100644 index 0000000..6713a59 --- /dev/null +++ b/backend/src/services/gmail/gmail-client.ts @@ -0,0 +1,205 @@ +/** + * NOTE FOR CONTRIBUTORS: + * If your work touches real Gmail integration (e.g. debugging OAuth flow or Gmail API client), + * you should not use shared credentials. Instead: + * 1. Ask a maintainer to add you as an individual test user on the project's Google Cloud OAuth consent screen. + * 2. Configure your local .env to use the project's client ID and client secret. + * 3. Authenticate with your own Google account during the local OAuth flow. + */ + +import { google } from 'googleapis'; +import * as fs from 'fs'; +import * as path from 'path'; +import { logger } from '../../utils/logger'; +import { IS_MOCK } from '../../config/environment'; + +export interface IGmailClient { + getToken(code: string, redirectUri: string): Promise; + getProfile( + tokens: any, + redirectUri: string, + onTokens?: (newTokens: any) => Promise + ): Promise<{ emailAddress: string }>; + listMessages( + tokens: any, + redirectUri: string, + options: { maxResults: number; q?: string }, + onTokens?: (newTokens: any) => Promise + ): Promise; + getMessage( + tokens: any, + redirectUri: string, + messageId: string, + onTokens?: (newTokens: any) => Promise + ): Promise; + sendMessage( + tokens: any, + redirectUri: string, + rawMime: string, + onTokens?: (newTokens: any) => Promise + ): Promise; +} + +function getRealOAuthClient( + tokens: any, + redirectUri: string, + onTokens?: (newTokens: any) => Promise +) { + const oauth2Client = new google.auth.OAuth2( + process.env.GMAIL_CLIENT_ID, + process.env.GMAIL_CLIENT_SECRET, + redirectUri + ); + oauth2Client.setCredentials(tokens); + if (onTokens) { + oauth2Client.on('tokens', async (newTokens) => { + try { + await onTokens(newTokens); + } catch (err) { + logger.error('[GmailClient] Error handling token refresh event:', err); + } + }); + } + return oauth2Client; +} + +class RealGmailClient implements IGmailClient { + async getToken(code: string, redirectUri: string): Promise { + const oauth2Client = new google.auth.OAuth2( + process.env.GMAIL_CLIENT_ID, + process.env.GMAIL_CLIENT_SECRET, + redirectUri + ); + const { tokens } = await oauth2Client.getToken(code); + return tokens; + } + + async getProfile( + tokens: any, + redirectUri: string, + onTokens?: (newTokens: any) => Promise + ): Promise<{ emailAddress: string }> { + const auth = getRealOAuthClient(tokens, redirectUri, onTokens); + const gmail = google.gmail({ version: 'v1', auth }); + const profile = await gmail.users.getProfile({ userId: 'me' }); + if (!profile.data.emailAddress) { + throw new Error('No email address found in profile'); + } + return { emailAddress: profile.data.emailAddress }; + } + + async listMessages( + tokens: any, + redirectUri: string, + options: { maxResults: number; q?: string }, + onTokens?: (newTokens: any) => Promise + ): Promise { + const auth = getRealOAuthClient(tokens, redirectUri, onTokens); + const gmail = google.gmail({ version: 'v1', auth }); + const res = await gmail.users.messages.list({ + userId: 'me', + ...options, + }); + return res.data; + } + + async getMessage( + tokens: any, + redirectUri: string, + messageId: string, + onTokens?: (newTokens: any) => Promise + ): Promise { + const auth = getRealOAuthClient(tokens, redirectUri, onTokens); + const gmail = google.gmail({ version: 'v1', auth }); + const res = await gmail.users.messages.get({ + userId: 'me', + id: messageId, + format: 'full', + }); + return res; + } + + async sendMessage( + tokens: any, + redirectUri: string, + rawMime: string, + onTokens?: (newTokens: any) => Promise + ): Promise { + const auth = getRealOAuthClient(tokens, redirectUri, onTokens); + const gmail = google.gmail({ version: 'v1', auth }); + const res = await gmail.users.messages.send({ + userId: 'me', + requestBody: { raw: rawMime }, + }); + return res; + } +} + +class MockGmailClient implements IGmailClient { + private loadFixture(filename: string): any { + const fixturePath = path.resolve( + __dirname, + '../../../fixtures/gmail', + filename + ); + if (!fs.existsSync(fixturePath)) { + throw new Error(`Fixture file not found: ${fixturePath}`); + } + const raw = fs.readFileSync(fixturePath, 'utf-8'); + return JSON.parse(raw); + } + + async getToken(code: string, _redirectUri: string): Promise { + logger.info(`[MockGmailClient] Fetching mock token for code: ${code}`); + return this.loadFixture('oauth_token.json'); + } + + async getProfile( + _tokens: any, + _redirectUri: string, + _onTokens?: (newTokens: any) => Promise + ): Promise<{ emailAddress: string }> { + logger.info(`[MockGmailClient] Fetching mock profile`); + const profile = this.loadFixture('profile.json'); + return { emailAddress: profile.emailAddress }; + } + + async listMessages( + _tokens: any, + _redirectUri: string, + _options: { maxResults: number; q?: string }, + _onTokens?: (newTokens: any) => Promise + ): Promise { + logger.info(`[MockGmailClient] Listing mock messages`); + return this.loadFixture('messages_list.json'); + } + + async getMessage( + _tokens: any, + _redirectUri: string, + messageId: string, + _onTokens?: (newTokens: any) => Promise + ): Promise { + logger.info( + `[MockGmailClient] Getting mock message detail for: ${messageId}` + ); + const detail = this.loadFixture('message_detail.json'); + // Keep message ID consistent + detail.id = messageId; + return { data: detail }; + } + + async sendMessage( + _tokens: any, + _redirectUri: string, + _rawMime: string, + _onTokens?: (newTokens: any) => Promise + ): Promise { + logger.info(`[MockGmailClient] Sending mock email`); + return { data: { id: `gmail-sent-${Date.now()}` } }; + } +} + +export const gmailClient: IGmailClient = IS_MOCK + ? new MockGmailClient() + : new RealGmailClient(); diff --git a/backend/src/worker.ts b/backend/src/worker.ts index b8e60ab..c81dcb1 100644 --- a/backend/src/worker.ts +++ b/backend/src/worker.ts @@ -17,6 +17,8 @@ import * as path from 'path'; // Load environment variables dotenv.config({ path: path.resolve(__dirname, '../../.env') }); +import './config/environment'; + const prisma = new PrismaClient(); // Wire BullMQ worker events for logging diff --git a/docs/setup/LOCAL_SETUP.md b/docs/setup/LOCAL_SETUP.md index cdd5b04..b1ebbf8 100644 --- a/docs/setup/LOCAL_SETUP.md +++ b/docs/setup/LOCAL_SETUP.md @@ -8,21 +8,12 @@ Welcome to the local development setup guide for **InboxOS**—a decision + exec Before you start, ensure you have the following installed on your local machine: -1. **Node.js (v18.0.0 or higher)** +1. **Node.js (v24.0.0 or higher)** - Used for the frontend client and the primary backend server. - [Download Node.js](https://nodejs.org/) -2. **PostgreSQL (v15.0 or higher)** - - Used as the persistent database layer. - - [Download PostgreSQL](https://www.postgresql.org/download/) -3. **Docker & Docker Compose (v2.0 or higher)** +2. **Docker & Docker Compose (v2.0 or higher)** - Highly recommended for spinning up PostgreSQL, Redis, and other services with a single command. - [Download Docker Desktop](https://www.docker.com/products/docker-desktop/) -4. **Python (v3.11 or higher)** *(Optional / API dependent)* - - Required if you are running the FastAPI Python API server manually. - - [Download Python](https://www.python.org/downloads/) -5. **Redis (v7.0 or higher)** *(Optional)* - - Used for caching and asynchronous Celery tasks. Required if running the backend manually without Docker. - - [Download Redis](https://redis.io/download/) --- @@ -32,98 +23,70 @@ Before you start, ensure you have the following installed on your local machine: Clone the repository and navigate into the project directory: ```bash -git clone https://github.com/inboxos/inboxos.git -cd inboxos +git clone https://github.com/CodeLabsAI29/Inbox_OS.git +cd Inbox_OS ``` --- ### Step 2: Configure Environment Variables -InboxOS reads configurations from environment variable files. You need to copy the template `.env.example` file and configure it: +InboxOS reads configurations from the `backend/.env` file. You need to copy the template `.env.example` file: ```bash -# Copy the example environment file to the active configuration location -cp infrastructure/config/env/.env.example infrastructure/config/env/.env +cd backend +cp .env.example .env ``` -Now, open the newly created [infrastructure/config/env/.env](../../infrastructure/config/env/.env) file in your editor and adjust the settings: +Now, open the newly created [backend/.env](../../backend/.env) file in your editor and adjust the settings. #### 🔑 Key Environment Variables Explained -Refer to [infrastructure/config/env/.env.example](../../infrastructure/config/env/.env.example) for reference: +Refer to [backend/.env.example](../../backend/.env.example) for reference: | Key | Description | Default / Example Value | | :--- | :--- | :--- | -| `DATABASE_URL` | Database connection string. If you are using PostgreSQL, configure it with your credentials. | `sqlite:///./inboxos.db` | -| `REDIS_URL` | Redis URL for message broker and cache storage. | `redis://localhost:6379/0` | -| `AI_PROVIDER` | Selected intelligence engine (`openai`, `gemini`, `ollama`, or `mock`). | `mock` | -| `OPENAI_API_KEY` | Your API key if `AI_PROVIDER` is set to `openai`. | `sk-proj-...` | -| `GEMINI_API_KEY` | Your API key if `AI_PROVIDER` is set to `gemini`. | `AIzaSy...` | -| `OLLAMA_BASE_URL` | The endpoint of your local Ollama server if `AI_PROVIDER` is `ollama`. | `http://localhost:11434` | -| `JWT_SECRET` | Cryptographic secret for signing API tokens. Change this to a random 32-char string. | `change-this-to-a-random-32-char-string-in-production` | -| `TELEGRAM_BOT_TOKEN` | Token for the Telegram Bot API credentials. | `123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ` | -| `TELEGRAM_WEBHOOK_SECRET` | Secret token to authenticate incoming requests from Telegram API. | `my-secure-webhook-secret-token` | -| `TELEGRAM_WEBHOOK_URL` | Public base URL of your API backend server to register webhooks (leave empty for background long-polling fallback). | `https://api.yourdomain.com` | -| `TELEGRAM_ALLOWED_CHAT_IDS` | Comma-separated whitelist list of Telegram chat IDs authorized to interact with the bot (leave empty to allow all). | `12345678,98765432` | - -> [!NOTE] -> For a step-by-step walkthrough on how to set up your own Telegram Bot for local development, refer to [TELEGRAM_SETUP.md](TELEGRAM_SETUP.md). +| `ENVIRONMENT` | Env tier mode (`local` \| `staging` \| `production`). | `local` | +| `MOCK_GMAIL` | Set to `true` to enable mock Gmail integrations. Requires `ENVIRONMENT=local` to boot. | `true` | +| `DATABASE_URL` | PostgreSQL connection string. Defaults to local compose container. | `postgresql://postgres:postgres@localhost:5432/inboxos?schema=public` | +| `REDIS_URL` | Redis URL for background BullMQ jobs. Defaults to local compose container. | `redis://localhost:6379/0` | +| `AI_PROVIDER` | Selected intelligence engine (`openai` \| `gemini` \| `ollama` \| `mock`). | `mock` | +| `JWT_SECRET` | Cryptographic secret for signing API tokens. | `replace_with_random_64_char_hex_string` | --- -### Step 3: Database Migrations -Prisma ORM is used by the Node.js backend. You must initialize your database schema before starting the application: +### Step 3: Run Infrastructure Container +Spin up your local PostgreSQL and Redis databases using Docker Compose in the root folder: ```bash -# Navigate to the backend folder -cd backend - -# Install the package dependencies -npm install - -# Run database migrations to construct the database schema -npx prisma migrate dev +# In the root repository directory: +docker compose up -d ``` -> [!NOTE] -> The schema is defined in [schema.prisma](../../backend/prisma/schema.prisma) and targets a PostgreSQL database. Ensure your connection URL is set correctly in your environment variables before running migrations. --- -### Step 4: Run the Application - -You can start the InboxOS stack using Docker Compose (Recommended) or run the individual services manually. - -#### Option A: Docker Compose Setup (Recommended) -This runs all microservices (Postgres, Redis, API Backend, Celery, and Frontend) in containerized environments: +### Step 4: Run Database Migrations +Run Prisma migrations to instantiate the database schema: ```bash -# Spin up all services using the compose file in infrastructure/docker/ -docker compose -f infrastructure/docker/docker-compose.yml up -d -``` - -To stop the containers: -```bash -docker compose -f infrastructure/docker/docker-compose.yml down +cd backend +npx prisma db push ``` --- -#### Option B: Manual Setup (Local Debugging) -Run this if you want to run the processes natively on your machine: +### Step 5: Start the Servers -##### 1. Start the Node.js Backend Server -In your first terminal window: +#### 1. Start the Node.js Backend Server +In your backend terminal window: ```bash cd backend -npm install -npm start +npm run dev ``` -##### 2. Start the Vite/React Frontend Client +#### 2. Start the React Frontend Client In a new terminal window: ```bash cd frontend -npm install npm run dev ``` @@ -132,11 +95,22 @@ npm run dev ## 🎯 Verification Once all services are running, verify they are working by accessing these URLs: +- **Frontend Application Dashboard:** [http://localhost:5173](http://localhost:5173) +- **Node.js Backend Server:** [http://localhost:8000](http://localhost:8000) -### For Docker Compose Setup (Recommended) -- **Frontend Application Dashboard:** [http://localhost](http://localhost) -- **Node.js Backend Server:** [http://localhost:8000](http://localhost:8000) (Metrics: [http://localhost:8000/metrics](http://localhost:8000/metrics)) +--- -### For Manual Setup (Local Debugging) -- **Frontend Application Dashboard:** [http://localhost:5173](http://localhost:5173) -- **Node.js Backend Server (if running):** [http://localhost:8000](http://localhost:8000) (Metrics: [http://localhost:8000/metrics](http://localhost:8000/metrics)) +## 🔒 Security & Environment Safeguards + +1. **Local Infra Guardrail**: The backend will refuse to boot in `local` mode if your `DATABASE_URL` or `REDIS_URL` contains known production hostnames (e.g. `supabase`, `upstash`, `render`). +2. **Mock Mode Protection**: Mock Gmail mode only activates if BOTH `ENVIRONMENT=local` and `MOCK_GMAIL=true` are configured. It will fail loudly at boot on other environments to prevent leakage of mock data. +3. **Commit Protections**: Pre-commit hooks are configured to prevent staging production credentials, secrets, or hostnames. Always review your staged changes before pushing. + +--- + +## 📧 Testing Real Gmail OAuth + +Mock mode covers 95%+ of typical contributions. If you specifically need to test real Gmail syncing or OAuth callback handlers: +1. Open an issue tagged `needs-real-oauth`. +2. A project maintainer will add your Google Account email as an authorized tester on the Google Cloud Console OAuth consent screen. +3. Replace the mock credentials in your local `.env` with the active project client credentials and run the OAuth flows with your own account.