Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -98,3 +98,11 @@ ENCRYPTION_KEY=
# Apache Tika Integration
# ONLY active if TIKA_URL is set
TIKA_URL=http://tika:9998

# --- Google OAuth (Gmail individual account connect) ---
# Create an OAuth 2.0 Client ID at console.cloud.google.com → APIs & Services → Credentials
# Set the authorized redirect URI to: <APP_URL>/v1/oauth/google/callback
# Enable the Gmail API under APIs & Services → Library
GOOGLE_OAUTH_CLIENT_ID=
GOOGLE_OAUTH_CLIENT_SECRET=
GOOGLE_OAUTH_REDIRECT_URI=http://localhost:4000/v1/oauth/google/callback
84 changes: 84 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project Overview

Open Archiver is a self-hosted email archiving platform. It ingests emails from Gmail, Microsoft 365, IMAP, PST, MBOX, and .eml sources; stores them encrypted in `.eml` format (local or S3); indexes full text via Meilisearch; and exposes a SvelteKit UI.

## Monorepo Structure

Managed with **pnpm workspaces** (`pnpm-workspace.yaml`). Two workspace roots:

- `apps/open-archiver/` — thin entry point that boots the backend (`index.ts`)
- `packages/backend/` — Express.js API, BullMQ workers, Drizzle ORM schemas, i18n
- `packages/frontend/` — SvelteKit 2 + Svelte 5 + TailwindCSS 4 UI
- `packages/types/` — shared TypeScript types (`@open-archiver/types`)

Path aliases (`@open-archiver/*`) are defined in `tsconfig.base.json`.

## Common Commands

All commands run from the repo root (`OpenArchiver/`).

### Development
```bash
pnpm dev:oss # Run frontend + backend in parallel (OSS mode)
pnpm start:workers:dev # Run ingestion, indexing, and sync-scheduler workers
```

### Build
```bash
pnpm build # Build all packages and apps
```

### Database (Drizzle ORM)
```bash
pnpm db:generate # Generate migration files from schema changes
pnpm db:migrate # Apply pending migrations to PostgreSQL
```

### Lint / Format
```bash
pnpm lint # Check formatting (Prettier)
pnpm format # Auto-fix formatting
```

### Docker (production)
```bash
cp .env.example .env # Configure environment first
docker compose up -d # Start full stack (PostgreSQL, Valkey, Meilisearch, Tika, app)
```

## Architecture

### Request Flow
Browser → SvelteKit frontend (`packages/frontend`) → Express REST API (`packages/backend`) → PostgreSQL (metadata/auth) + Meilisearch (search) + S3/local (file storage)

### Background Workers (`packages/backend/src/workers/`)
- **ingestion.worker.ts** — pulls emails from configured sources, parses, deduplicates, encrypts, stores
- **indexing.worker.ts** — extracts text from emails/attachments (via Apache Tika), pushes to Meilisearch
- **`src/jobs/schedulers/sync-scheduler.ts`** — schedules periodic re-sync for connected mailboxes

Workers communicate via **BullMQ** queues backed by **Valkey/Redis**.

### Database
Drizzle ORM with PostgreSQL. Schema files live in `packages/backend/src/database/`. Run `pnpm db:generate` after schema changes, then `pnpm db:migrate`.

### Authentication
JWT-based sessions (`jose`). OAuth providers: Google (googleapis), Microsoft (MSAL). Admin/user roles enforced at middleware level.

### Storage
Pluggable storage layer — local filesystem or S3-compatible (AWS S3, MinIO). Configured via `.env`. All stored files are encrypted at rest; SHA hashes are recorded for integrity verification.

### Search
Meilisearch handles full-text search across email bodies and attachment content (PDF, DOCX, XLSX extracted via Apache Tika and mammoth/pdf2json/xlsx).

## Key Environment Variables

See `.env.example` for the full list. Critical ones:
- `DATABASE_URL` — PostgreSQL connection string
- `REDIS_URL` — Valkey/Redis connection
- `MEILISEARCH_URL` / `MEILISEARCH_MASTER_KEY`
- `STORAGE_BACKEND` — `local` or `s3`
- `ENCRYPTION_KEY` — at-rest encryption key
4 changes: 2 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@ services:
container_name: open-archiver
restart: unless-stopped
ports:
- '3000:3000' # Frontend
- '3001:3000' # Frontend
env_file:
- .env
volumes:
- ${STORAGE_LOCAL_ROOT_PATH}:${STORAGE_LOCAL_ROOT_PATH}
- ${STORAGE_LOCAL_ROOT_PATH:-/var/data/open-archiver}:${STORAGE_LOCAL_ROOT_PATH:-/var/data/open-archiver}
depends_on:
- postgres
- valkey
Expand Down
160 changes: 160 additions & 0 deletions packages/backend/src/api/controllers/oauth.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import type { Request, Response } from 'express';
import { google } from 'googleapis';
import { createHmac, randomBytes } from 'crypto';
import { IngestionService } from '../../services/IngestionService';
import { UserService } from '../../services/UserService';
import { logger } from '../../config/logger';
import { googleOAuth } from '../../config/app';

const SCOPES = [
'https://www.googleapis.com/auth/gmail.readonly',
'https://www.googleapis.com/auth/userinfo.email',
];

function getOAuth2Client() {
return new google.auth.OAuth2(
googleOAuth.clientId,
googleOAuth.clientSecret,
googleOAuth.redirectUri
);
}

function signState(payload: object): string {
const data = Buffer.from(JSON.stringify(payload)).toString('base64');
const sig = createHmac('sha256', process.env.JWT_SECRET!)
.update(data)
.digest('hex');
return `${data}.${sig}`;
}

function verifyState(state: string): { userId: string; name: string } | null {
try {
const [data, sig] = state.split('.');
const expected = createHmac('sha256', process.env.JWT_SECRET!)
.update(data)
.digest('hex');
if (sig !== expected) return null;
return JSON.parse(Buffer.from(data, 'base64').toString('utf8'));
} catch {
return null;
}
}

export class OAuthController {
/**
* GET /v1/oauth/google/authorize?name=<source-name>
* Protected — user must be logged in.
* Redirects to Google OAuth consent screen.
*/
public googleAuthorize = async (req: Request, res: Response): Promise<void> => {
const { name } = req.query;
const userId = req.user?.sub;

if (!userId) {
res.status(401).json({ message: 'Unauthorized' });
return;
}

if (!name || typeof name !== 'string') {
res.status(400).json({ message: 'Missing required query parameter: name' });
return;
}

if (
!googleOAuth.clientId ||
!googleOAuth.clientSecret ||
!googleOAuth.redirectUri
) {
res.status(500).json({ message: 'Google OAuth is not configured on this server.' });
return;
}

const state = signState({ userId, name, nonce: randomBytes(8).toString('hex') });
const oauth2Client = getOAuth2Client();
const url = oauth2Client.generateAuthUrl({
access_type: 'offline',
prompt: 'consent',
scope: SCOPES,
state,
});

res.status(200).json({ url });
};

/**
* GET /v1/oauth/google/callback?code=...&state=...
* Public — Google redirects here after consent.
* Creates an ingestion source and redirects to dashboard.
*/
public googleCallback = async (req: Request, res: Response): Promise<void> => {
const frontendUrl = process.env.APP_URL || 'http://localhost:3000';
const { code, state, error } = req.query;

if (error) {
logger.warn({ error }, 'Google OAuth consent was denied or cancelled.');
res.redirect(`${frontendUrl}/dashboard/ingestions?error=oauth_cancelled`);
return;
}

if (!code || typeof code !== 'string' || !state || typeof state !== 'string') {
res.redirect(`${frontendUrl}/dashboard/ingestions?error=oauth_invalid_response`);
return;
}

const payload = verifyState(state);
if (!payload) {
logger.warn('Google OAuth callback received invalid state parameter.');
res.redirect(`${frontendUrl}/dashboard/ingestions?error=oauth_invalid_state`);
return;
}

const { userId, name } = payload;

try {
const oauth2Client = getOAuth2Client();
const { tokens } = await oauth2Client.getToken(code);

if (!tokens.access_token || !tokens.refresh_token) {
throw new Error('Google did not return required tokens.');
}

oauth2Client.setCredentials(tokens);

// Get the user's email address from Google
const oauth2 = google.oauth2({ version: 'v2', auth: oauth2Client });
const { data: userInfo } = await oauth2.userinfo.get();

if (!userInfo.email) {
throw new Error('Could not retrieve email from Google account.');
}

// Fetch the OpenArchiver user to pass as actor
const userService = new UserService();
const actor = await userService.findById(userId);
if (!actor) {
throw new Error('Could not find user account.');
}

await IngestionService.create(
{
name,
provider: 'google_oauth',
providerConfig: {
type: 'google_oauth',
email: userInfo.email,
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
},
},
userId,
actor,
req.ip || 'unknown'
);

res.redirect(`${frontendUrl}/dashboard/ingestions?connected=google`);
} catch (err) {
logger.error({ err }, 'Google OAuth callback failed.');
res.redirect(`${frontendUrl}/dashboard/ingestions?error=oauth_failed`);
}
};
}
26 changes: 26 additions & 0 deletions packages/backend/src/api/routes/oauth.routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { Router } from 'express';
import { OAuthController } from '../controllers/oauth.controller';
import { requireAuth } from '../middleware/requireAuth';
import type { AuthService } from '../../services/AuthService';

export const createOAuthRouter = (authService: AuthService): Router => {
const router = Router();
const controller = new OAuthController();

/**
* @route GET /v1/oauth/google/authorize?name=<source-name>
* @description Initiates the Google OAuth flow for Gmail individual account connection.
* @access Protected (JWT required)
*/
router.get('/google/authorize', requireAuth(authService), controller.googleAuthorize);

/**
* @route GET /v1/oauth/google/callback
* @description Handles the Google OAuth callback, exchanges code for tokens,
* creates an ingestion source, and redirects to the dashboard.
* @access Public (called by Google)
*/
router.get('/google/callback', controller.googleCallback);

return router;
};
3 changes: 3 additions & 0 deletions packages/backend/src/api/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { createSettingsRouter } from './routes/settings.routes';
import { apiKeyRoutes } from './routes/api-key.routes';
import { integrityRoutes } from './routes/integrity.routes';
import { createJobsRouter } from './routes/jobs.routes';
import { createOAuthRouter } from './routes/oauth.routes';
import { AuthService } from '../services/AuthService';
import { AuditService } from '../services/AuditService';
import { UserService } from '../services/UserService';
Expand Down Expand Up @@ -123,6 +124,7 @@ export async function createServer(modules: ArchiverModule[] = []): Promise<Expr
const apiKeyRouter = apiKeyRoutes(authService);
const integrityRouter = integrityRoutes(authService);
const jobsRouter = createJobsRouter(authService);
const oauthRouter = createOAuthRouter(authService);

// Middleware for all other routes
app.use((req, res, next) => {
Expand Down Expand Up @@ -154,6 +156,7 @@ export async function createServer(modules: ArchiverModule[] = []): Promise<Expr
app.use(`/${config.api.version}/api-keys`, apiKeyRouter);
app.use(`/${config.api.version}/integrity`, integrityRouter);
app.use(`/${config.api.version}/jobs`, jobsRouter);
app.use(`/${config.api.version}/oauth`, oauthRouter);

// Load all provided extension modules
for (const module of modules) {
Expand Down
6 changes: 6 additions & 0 deletions packages/backend/src/config/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,9 @@ export const app = {
allInclusiveArchive: process.env.ALL_INCLUSIVE_ARCHIVE === 'true',
isDemo: process.env.IS_DEMO === 'true',
};

export const googleOAuth = {
clientId: process.env.GOOGLE_OAUTH_CLIENT_ID,
clientSecret: process.env.GOOGLE_OAUTH_CLIENT_SECRET,
redirectUri: process.env.GOOGLE_OAUTH_REDIRECT_URI,
};
2 changes: 2 additions & 0 deletions packages/backend/src/database/schema/ingestion-sources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@ import { relations } from 'drizzle-orm';

export const ingestionProviderEnum = pgEnum('ingestion_provider', [
'google_workspace',
'google_oauth',
'microsoft_365',
'generic_imap',
'pst_import',
'eml_import',
'mbox_import',
'smtp_journaling',
]);

export const ingestionStatusEnum = pgEnum('ingestion_status', [
Expand Down
4 changes: 4 additions & 0 deletions packages/backend/src/services/EmailProviderFactory.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type {
IngestionSource,
GoogleWorkspaceCredentials,
GoogleOAuthCredentials,
Microsoft365Credentials,
GenericImapCredentials,
PSTImportCredentials,
Expand All @@ -11,6 +12,7 @@ import type {
MailboxUser,
} from '@open-archiver/types';
import { GoogleWorkspaceConnector } from './ingestion-connectors/GoogleWorkspaceConnector';
import { GoogleOAuthConnector } from './ingestion-connectors/GoogleOAuthConnector';
import { MicrosoftConnector } from './ingestion-connectors/MicrosoftConnector';
import { ImapConnector } from './ingestion-connectors/ImapConnector';
import { PSTConnector } from './ingestion-connectors/PSTConnector';
Expand Down Expand Up @@ -38,6 +40,8 @@ export class EmailProviderFactory {
switch (source.provider) {
case 'google_workspace':
return new GoogleWorkspaceConnector(credentials as GoogleWorkspaceCredentials);
case 'google_oauth':
return new GoogleOAuthConnector(credentials as GoogleOAuthCredentials);
case 'microsoft_365':
return new MicrosoftConnector(credentials as Microsoft365Credentials);
case 'generic_imap':
Expand Down
Loading
Loading