diff --git a/.env.example b/.env.example index 8c3997ea..42ca5610 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,8 @@ ORIGIN=$APP_URL SYNC_FREQUENCY='* * * * *' # Set to 'true' to include Junk and Trash folders in the email archive. Defaults to false. ALL_INCLUSIVE_ARCHIVE=false +# Number of mailbox jobs that run concurrently in the ingestion worker. Increase on servers with more RAM. +INGESTION_WORKER_CONCURRENCY=5 # --- Docker Compose Service Configuration --- # These variables are used by docker-compose.yml to configure the services. Leave them unchanged if you use Docker services for Postgresql, Valkey (Redis) and Meilisearch. If you decide to use your own instances of these services, you can substitute them with your own connection credentials. @@ -43,7 +45,11 @@ REDIS_USER=notdefaultuser # --- Storage Settings --- # Choose your storage backend. Valid options are 'local' or 's3'. STORAGE_TYPE=local -# The maximum request body size to accept in bytes including while streaming. The body size can also be specified with a unit suffix for kilobytes (K), megabytes (M), or gigabytes (G). For example, 512K or 1M. Defaults to 512kb. Or the value of Infinity if you don't want any upload limit. +# The maximum request body size the SvelteKit frontend server will accept (including file uploads via streaming). +# Accepts a numeric value in bytes, or a unit suffix: K (kilobytes), M (megabytes), G (gigabytes). +# Set to 'Infinity' to remove the limit entirely (recommended for archiving large PST/Mbox files). +# Examples: 512K, 100M, 5G, Infinity. Defaults to 512K if not set. +# For very large files (multi-GB), consider using the "Local Path" ingestion option which bypasses this limit entirely. BODY_SIZE_LIMIT=100M # --- Local Storage Settings --- diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 2944c849..9460f4d4 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -1,4 +1,6 @@ import { defineConfig } from 'vitepress'; +import { useSidebar } from 'vitepress-openapi'; +import spec from '../api/openapi.json'; export default defineConfig({ head: [ @@ -95,7 +97,12 @@ export default defineConfig({ { text: 'Integrity Check', link: '/api/integrity' }, { text: 'Search', link: '/api/search' }, { text: 'Storage', link: '/api/storage' }, + { text: 'Upload', link: '/api/upload' }, { text: 'Jobs', link: '/api/jobs' }, + { text: 'Users', link: '/api/users' }, + { text: 'IAM', link: '/api/iam' }, + { text: 'API Keys', link: '/api/api-keys' }, + { text: 'Settings', link: '/api/settings' }, ], }, { diff --git a/docs/.vitepress/theme/index.ts b/docs/.vitepress/theme/index.ts new file mode 100644 index 00000000..351561ef --- /dev/null +++ b/docs/.vitepress/theme/index.ts @@ -0,0 +1,19 @@ +import DefaultTheme from 'vitepress/theme'; +import type { EnhanceAppContext } from 'vitepress'; +import { theme, useOpenapi } from 'vitepress-openapi/client'; +import 'vitepress-openapi/dist/style.css'; +import spec from '../../api/openapi.json'; + +export default { + ...DefaultTheme, + enhanceApp({ app, router, siteData }: EnhanceAppContext) { + // Delegate to DefaultTheme first + DefaultTheme.enhanceApp?.({ app, router, siteData }); + + // Install vitepress-openapi theme: registers i18n plugin + all OA components + theme.enhanceApp({ app, router, siteData }); + + // Initialize the global OpenAPI spec + useOpenapi({ spec }); + }, +}; diff --git a/docs/api/api-keys.md b/docs/api/api-keys.md new file mode 100644 index 00000000..db576ecc --- /dev/null +++ b/docs/api/api-keys.md @@ -0,0 +1,19 @@ +--- +aside: false +--- + +# API Keys + +Generate and manage API keys for programmatic access to the Open Archiver API. API keys are scoped to the user that created them and carry the same permissions as that user. The raw key value is only shown once at creation time. + +## Generate an API Key + + + +## List API Keys + + + +## Delete an API Key + + diff --git a/docs/api/archived-email.md b/docs/api/archived-email.md index 601625c2..8d7099e2 100644 --- a/docs/api/archived-email.md +++ b/docs/api/archived-email.md @@ -1,107 +1,19 @@ -# Archived Email Service API +--- +aside: false +--- -The Archived Email Service is responsible for retrieving archived emails and their details from the database and storage. +# Archived Email API -## Endpoints +Endpoints for retrieving and deleting archived emails. All endpoints require authentication and the appropriate `archive` permission. -All endpoints in this service require authentication. +## List Emails for an Ingestion Source -### GET /api/v1/archived-emails/ingestion-source/:ingestionSourceId + -Retrieves a paginated list of archived emails for a specific ingestion source. +## Get a Single Email -**Access:** Authenticated + -#### URL Parameters +## Delete an Email -| Parameter | Type | Description | -| :------------------ | :----- | :------------------------------------------------ | -| `ingestionSourceId` | string | The ID of the ingestion source to get emails for. | - -#### Query Parameters - -| Parameter | Type | Description | Default | -| :-------- | :----- | :------------------------------ | :------ | -| `page` | number | The page number for pagination. | 1 | -| `limit` | number | The number of items per page. | 10 | - -#### Responses - -- **200 OK:** A paginated list of archived emails. - - ```json - { - "items": [ - { - "id": "email-id", - "subject": "Test Email", - "from": "sender@example.com", - "sentAt": "2023-10-27T10:00:00.000Z", - "hasAttachments": true, - "recipients": [{ "name": "Recipient 1", "email": "recipient1@example.com" }] - } - ], - "total": 100, - "page": 1, - "limit": 10 - } - ``` - -- **500 Internal Server Error:** An unexpected error occurred. - -### GET /api/v1/archived-emails/:id - -Retrieves a single archived email by its ID, including its raw content and attachments. - -**Access:** Authenticated - -#### URL Parameters - -| Parameter | Type | Description | -| :-------- | :----- | :---------------------------- | -| `id` | string | The ID of the archived email. | - -#### Responses - -- **200 OK:** The archived email details. - - ```json - { - "id": "email-id", - "subject": "Test Email", - "from": "sender@example.com", - "sentAt": "2023-10-27T10:00:00.000Z", - "hasAttachments": true, - "recipients": [{ "name": "Recipient 1", "email": "recipient1@example.com" }], - "raw": "...", - "attachments": [ - { - "id": "attachment-id", - "filename": "document.pdf", - "mimeType": "application/pdf", - "sizeBytes": 12345 - } - ] - } - ``` - -- **404 Not Found:** The archived email with the specified ID was not found. -- **500 Internal Server Error:** An unexpected error occurred. - -## Service Methods - -### `getArchivedEmails(ingestionSourceId: string, page: number, limit: number): Promise` - -Retrieves a paginated list of archived emails from the database for a given ingestion source. - -- **ingestionSourceId:** The ID of the ingestion source. -- **page:** The page number for pagination. -- **limit:** The number of items per page. -- **Returns:** A promise that resolves to a `PaginatedArchivedEmails` object. - -### `getArchivedEmailById(emailId: string): Promise` - -Retrieves a single archived email by its ID, including its raw content and attachments. - -- **emailId:** The ID of the archived email. -- **Returns:** A promise that resolves to an `ArchivedEmail` object or `null` if not found. + diff --git a/docs/api/auth.md b/docs/api/auth.md index 5a574c86..a47b4a28 100644 --- a/docs/api/auth.md +++ b/docs/api/auth.md @@ -1,84 +1,19 @@ -# Auth Service API +--- +aside: false +--- -The Auth Service is responsible for handling user authentication, including login and token verification. +# Auth API -## Endpoints +Handles user authentication including initial setup, login, and application setup status. -### POST /api/v1/auth/login +## Setup -Authenticates a user and returns a JWT if the credentials are valid. + -**Access:** Public +## Login -**Rate Limiting:** This endpoint is rate-limited to prevent brute-force attacks. + -#### Request Body +## Check Setup Status -| Field | Type | Description | -| :--------- | :----- | :------------------------ | -| `email` | string | The user's email address. | -| `password` | string | The user's password. | - -#### Responses - -- **200 OK:** Authentication successful. - - ```json - { - "accessToken": "your.jwt.token", - "user": { - "id": "user-id", - "email": "user@example.com", - "role": "user" - } - } - ``` - -- **400 Bad Request:** Email or password not provided. - - ```json - { - "message": "Email and password are required" - } - ``` - -- **401 Unauthorized:** Invalid credentials. - - ```json - { - "message": "Invalid credentials" - } - ``` - -- **500 Internal Server Error:** An unexpected error occurred. - - ```json - { - "message": "An internal server error occurred" - } - ``` - -## Service Methods - -### `verifyPassword(password: string, hash: string): Promise` - -Compares a plain-text password with a hashed password to verify its correctness. - -- **password:** The plain-text password. -- **hash:** The hashed password to compare against. -- **Returns:** A promise that resolves to `true` if the password is valid, otherwise `false`. - -### `login(email: string, password: string): Promise` - -Handles the user login process. It finds the user by email, verifies the password, and generates a JWT upon successful authentication. - -- **email:** The user's email. -- **password:** The user's password. -- **Returns:** A promise that resolves to a `LoginResponse` object containing the `accessToken` and `user` details, or `null` if authentication fails. - -### `verifyToken(token: string): Promise` - -Verifies the authenticity and expiration of a JWT. - -- **token:** The JWT string to verify. -- **Returns:** A promise that resolves to the token's `AuthTokenPayload` if valid, otherwise `null`. + diff --git a/docs/api/authentication.md b/docs/api/authentication.md index 0c5207ef..ed8b61a9 100644 --- a/docs/api/authentication.md +++ b/docs/api/authentication.md @@ -1,19 +1,25 @@ +--- +aside: false +--- + # API Authentication -To access protected API endpoints, you need to include a user-generated API key in the `X-API-KEY` header of your requests. +The API supports two authentication methods. Use whichever fits your use case. -## 1. Creating an API Key +## Method 1: JWT (User Login) -You can create, manage, and view your API keys through the application's user interface. +Obtain a short-lived JWT by calling `POST /v1/auth/login` with your email and password, then pass it as a Bearer token in the `Authorization` header. -1. Navigate to **Settings > API Keys** in the dashboard. -2. Click the **"Generate API Key"** button. -3. Provide a descriptive name for your key and select an expiration period. -4. The new API key will be displayed. **Copy this key immediately and store it in a secure location. You will not be able to see it again.** +**Example:** -## 2. Making Authenticated Requests +```http +GET /api/v1/dashboard/stats +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... +``` -Once you have your API key, you must include it in the `X-API-KEY` header of all subsequent requests to protected API endpoints. +## Method 2: API Key + +Long-lived API keys are suited for automated scripts and integrations. Create one in **Settings > API Keys**, then pass it in the `X-API-KEY` header. **Example:** @@ -22,4 +28,13 @@ GET /api/v1/dashboard/stats X-API-KEY: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 ``` -If the API key is missing, expired, or invalid, the API will respond with a `401 Unauthorized` status code. +### Creating an API Key + +1. Navigate to **Settings > API Keys** in the dashboard. +2. Click **"Generate API Key"**. +3. Provide a descriptive name and select an expiration period (max 2 years). +4. Copy the key immediately — it will not be shown again. + +--- + +If the token or API key is missing, expired, or invalid, the API responds with `401 Unauthorized`. diff --git a/docs/api/dashboard.md b/docs/api/dashboard.md index c3bc6e5a..e231c8fb 100644 --- a/docs/api/dashboard.md +++ b/docs/api/dashboard.md @@ -1,114 +1,27 @@ -# Dashboard Service API +--- +aside: false +--- -The Dashboard Service provides endpoints for retrieving statistics and data for the main dashboard. +# Dashboard API -## Endpoints +Aggregated statistics and summaries for the dashboard UI. Requires `read:dashboard` permission. -All endpoints in this service require authentication. +## Get Stats -### GET /api/v1/dashboard/stats + -Retrieves overall statistics, including the total number of archived emails, total storage used, and the number of failed ingestions in the last 7 days. +## Get Ingestion History -**Access:** Authenticated + -#### Responses +## Get Ingestion Source Summaries -- **200 OK:** An object containing the dashboard statistics. + - ```json - { - "totalEmailsArchived": 12345, - "totalStorageUsed": 54321098, - "failedIngestionsLast7Days": 3 - } - ``` +## Get Recent Syncs -### GET /api/v1/dashboard/ingestion-history + -Retrieves the email ingestion history for the last 30 days, grouped by day. +## Get Indexed Email Insights -**Access:** Authenticated - -#### Responses - -- **200 OK:** An object containing the ingestion history. - - ```json - { - "history": [ - { - "date": "2023-09-27T00:00:00.000Z", - "count": 150 - }, - { - "date": "2023-09-28T00:00:00.000Z", - "count": 200 - } - ] - } - ``` - -### GET /api/v1/dashboard/ingestion-sources - -Retrieves a list of all ingestion sources along with their status and storage usage. - -**Access:** Authenticated - -#### Responses - -- **200 OK:** An array of ingestion source objects. - - ```json - [ - { - "id": "source-id-1", - "name": "Google Workspace", - "provider": "google", - "status": "active", - "storageUsed": 12345678 - }, - { - "id": "source-id-2", - "name": "Microsoft 365", - "provider": "microsoft", - "status": "error", - "storageUsed": 87654321 - } - ] - ``` - -### GET /api/v1/dashboard/recent-syncs - -Retrieves a list of recent synchronization jobs. (Note: This is currently a placeholder and will return an empty array). - -**Access:** Authenticated - -#### Responses - -- **200 OK:** An empty array. - - ```json - [] - ``` - -### GET /api/v1/dashboard/indexed-insights - -Retrieves insights from the indexed email data, such as the top senders. - -**Access:** Authenticated - -#### Responses - -- **200 OK:** An object containing indexed insights. - - ```json - { - "topSenders": [ - { - "sender": "user@example.com", - "count": 42 - } - ] - } - ``` + diff --git a/docs/api/iam.md b/docs/api/iam.md new file mode 100644 index 00000000..24c48d9d --- /dev/null +++ b/docs/api/iam.md @@ -0,0 +1,27 @@ +--- +aside: false +--- + +# IAM API + +Manage Identity and Access Management roles and their CASL policy statements. Role management requires Super Admin (`manage:all`) permission. Reading roles requires `read:roles` permission. + +## List All Roles + + + +## Create a Role + + + +## Get a Role + + + +## Update a Role + + + +## Delete a Role + + diff --git a/docs/api/index.md b/docs/api/index.md index fc8ac307..f951da8a 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -1,3 +1,7 @@ +--- +aside: false +--- + # API Overview Welcome to the Open Archiver API documentation. This section provides detailed information about the available API endpoints. diff --git a/docs/api/ingestion.md b/docs/api/ingestion.md index a8eaad46..fb0baa75 100644 --- a/docs/api/ingestion.md +++ b/docs/api/ingestion.md @@ -1,196 +1,39 @@ -# Ingestion Service API +--- +aside: false +--- -The Ingestion Service manages ingestion sources, which are configurations for connecting to email providers and importing emails. +# Ingestion API -## Endpoints +Manage ingestion sources — the configured connections to email providers (Google Workspace, Microsoft 365, IMAP, and file imports). Credentials are never returned in responses. -All endpoints in this service require authentication. +## Create an Ingestion Source -### POST /api/v1/ingestion-sources + -Creates a new ingestion source. +## List Ingestion Sources -**Access:** Authenticated + -#### Request Body +## Get an Ingestion Source -The request body should be a `CreateIngestionSourceDto` object. + -```typescript -interface CreateIngestionSourceDto { - name: string; - provider: 'google_workspace' | 'microsoft_365' | 'generic_imap' | 'pst_import' | 'eml_import' | 'mbox_import'; - providerConfig: IngestionCredentials; -} -``` +## Update an Ingestion Source -#### Example: Creating an Mbox Import Source with File Upload + -```json -{ - "name": "My Mbox Import", - "provider": "mbox_import", - "providerConfig": { - "type": "mbox_import", - "uploadedFileName": "emails.mbox", - "uploadedFilePath": "open-archiver/tmp/uuid-emails.mbox" - } -} -``` +## Delete an Ingestion Source -#### Example: Creating an Mbox Import Source with Local File Path + -```json -{ - "name": "My Mbox Import", - "provider": "mbox_import", - "providerConfig": { - "type": "mbox_import", - "localFilePath": "/path/to/emails.mbox" - } -} -``` +## Trigger Initial Import -**Note:** When using `localFilePath`, the file will not be deleted after import. When using `uploadedFilePath` (via the upload API), the file will be automatically deleted after import. The same applies to `pst_import` and `eml_import` providers. + -**Important regarding `localFilePath`:** When running OpenArchiver in a Docker container (which is the standard deployment), `localFilePath` refers to the path **inside the Docker container**, not on the host machine. -To use a local file: -1. **Recommended:** Place your file inside the directory defined by `STORAGE_LOCAL_ROOT_PATH` (e.g., inside a `temp` folder). Since this directory is already mounted as a volume, the file will be accessible at the same path inside the container. -2. **Alternative:** Mount a specific directory containing your files as a volume in `docker-compose.yml`. For example, add `- /path/to/my/files:/imports` to the `volumes` section and use `/imports/myfile.pst` as the `localFilePath`. +## Pause an Ingestion Source -#### Responses + -- **201 Created:** The newly created ingestion source. -- **500 Internal Server Error:** An unexpected error occurred. +## Force Sync -### GET /api/v1/ingestion-sources - -Retrieves all ingestion sources. - -**Access:** Authenticated - -#### Responses - -- **200 OK:** An array of ingestion source objects. -- **500 Internal Server Error:** An unexpected error occurred. - -### GET /api/v1/ingestion-sources/:id - -Retrieves a single ingestion source by its ID. - -**Access:** Authenticated - -#### URL Parameters - -| Parameter | Type | Description | -| :-------- | :----- | :------------------------------ | -| `id` | string | The ID of the ingestion source. | - -#### Responses - -- **200 OK:** The ingestion source object. -- **404 Not Found:** Ingestion source not found. -- **500 Internal Server Error:** An unexpected error occurred. - -### PUT /api/v1/ingestion-sources/:id - -Updates an existing ingestion source. - -**Access:** Authenticated - -#### URL Parameters - -| Parameter | Type | Description | -| :-------- | :----- | :------------------------------ | -| `id` | string | The ID of the ingestion source. | - -#### Request Body - -The request body should be an `UpdateIngestionSourceDto` object. - -```typescript -interface UpdateIngestionSourceDto { - name?: string; - provider?: 'google' | 'microsoft' | 'generic_imap'; - providerConfig?: IngestionCredentials; - status?: 'pending_auth' | 'auth_success' | 'importing' | 'active' | 'paused' | 'error'; -} -``` - -#### Responses - -- **200 OK:** The updated ingestion source object. -- **404 Not Found:** Ingestion source not found. -- **500 Internal Server Error:** An unexpected error occurred. - -### DELETE /api/v1/ingestion-sources/:id - -Deletes an ingestion source and all associated data. - -**Access:** Authenticated - -#### URL Parameters - -| Parameter | Type | Description | -| :-------- | :----- | :------------------------------ | -| `id` | string | The ID of the ingestion source. | - -#### Responses - -- **204 No Content:** The ingestion source was deleted successfully. -- **404 Not Found:** Ingestion source not found. -- **500 Internal Server Error:** An unexpected error occurred. - -### POST /api/v1/ingestion-sources/:id/import - -Triggers the initial import process for an ingestion source. - -**Access:** Authenticated - -#### URL Parameters - -| Parameter | Type | Description | -| :-------- | :----- | :------------------------------ | -| `id` | string | The ID of the ingestion source. | - -#### Responses - -- **202 Accepted:** The initial import was triggered successfully. -- **404 Not Found:** Ingestion source not found. -- **500 Internal Server Error:** An unexpected error occurred. - -### POST /api/v1/ingestion-sources/:id/pause - -Pauses an active ingestion source. - -**Access:** Authenticated - -#### URL Parameters - -| Parameter | Type | Description | -| :-------- | :----- | :------------------------------ | -| `id` | string | The ID of the ingestion source. | - -#### Responses - -- **200 OK:** The updated ingestion source object with a `paused` status. -- **404 Not Found:** Ingestion source not found. -- **500 Internal Server Error:** An unexpected error occurred. - -### POST /api/v1/ingestion-sources/:id/sync - -Triggers a forced synchronization for an ingestion source. - -**Access:** Authenticated - -#### URL Parameters - -| Parameter | Type | Description | -| :-------- | :----- | :------------------------------ | -| `id` | string | The ID of the ingestion source. | - -#### Responses - -- **202 Accepted:** The force sync was triggered successfully. -- **404 Not Found:** Ingestion source not found. -- **500 Internal Server Error:** An unexpected error occurred. + diff --git a/docs/api/integrity.md b/docs/api/integrity.md index b3070d3f..1c5a69e4 100644 --- a/docs/api/integrity.md +++ b/docs/api/integrity.md @@ -1,51 +1,11 @@ +--- +aside: false +--- + # Integrity Check API -The Integrity Check API provides an endpoint to verify the cryptographic hash of an archived email and its attachments against the stored values in the database. This allows you to ensure that the stored files have not been tampered with or corrupted since they were archived. +Verify the SHA-256 hash of an archived email and all its attachments against the hashes stored at archival time. ## Check Email Integrity -Verifies the integrity of a specific archived email and all of its associated attachments. - -- **URL:** `/api/v1/integrity/:id` -- **Method:** `GET` -- **URL Params:** - - `id=[string]` (required) - The UUID of the archived email to check. -- **Permissions:** `read:archive` -- **Success Response:** - - **Code:** 200 OK - - **Content:** `IntegrityCheckResult[]` - -### Response Body `IntegrityCheckResult` - -An array of objects, each representing the result of an integrity check for a single file (either the email itself or an attachment). - -| Field | Type | Description | -| :--------- | :------------------------ | :-------------------------------------------------------------------------- | -| `type` | `'email' \| 'attachment'` | The type of the file being checked. | -| `id` | `string` | The UUID of the email or attachment. | -| `filename` | `string` (optional) | The filename of the attachment. This field is only present for attachments. | -| `isValid` | `boolean` | `true` if the current hash matches the stored hash, otherwise `false`. | -| `reason` | `string` (optional) | A reason for the failure. Only present if `isValid` is `false`. | - -### Example Response - -```json -[ - { - "type": "email", - "id": "a1b2c3d4-e5f6-7890-1234-567890abcdef", - "isValid": true - }, - { - "type": "attachment", - "id": "b2c3d4e5-f6a7-8901-2345-67890abcdef1", - "filename": "document.pdf", - "isValid": false, - "reason": "Stored hash does not match current hash." - } -] -``` - -- **Error Response:** - - **Code:** 404 Not Found - - **Content:** `{ "message": "Archived email not found" }` + diff --git a/docs/api/jobs.md b/docs/api/jobs.md index 7b63f68a..27995471 100644 --- a/docs/api/jobs.md +++ b/docs/api/jobs.md @@ -1,128 +1,20 @@ -# Jobs API - -The Jobs API provides endpoints for monitoring the job queues and the jobs within them. - -## Overview - -Open Archiver uses a job queue system to handle asynchronous tasks like email ingestion and indexing. The system is built on Redis and BullMQ and uses a producer-consumer pattern. - -### Job Statuses - -Jobs can have one of the following statuses: - -- **active:** The job is currently being processed. -- **completed:** The job has been completed successfully. -- **failed:** The job has failed after all retry attempts. -- **delayed:** The job is delayed and will be processed at a later time. -- **waiting:** The job is waiting to be processed. -- **paused:** The job is paused and will not be processed until it is resumed. +--- +aside: false +--- -### Errors - -When a job fails, the `failedReason` and `stacktrace` fields will contain information about the error. The `error` field will also be populated with the `failedReason` for easier access. - -### Job Preservation - -Jobs are preserved for a limited time after they are completed or failed. This means that the job counts and the jobs that you see in the API are for a limited time. - -- **Completed jobs:** The last 1000 completed jobs are preserved. -- **Failed jobs:** The last 5000 failed jobs are preserved. - -## Get All Queues +# Jobs API -- **Endpoint:** `GET /v1/jobs/queues` -- **Description:** Retrieves a list of all job queues and their job counts. -- **Permissions:** `manage:all` -- **Responses:** - - `200 OK`: Returns a list of queue overviews. - - `401 Unauthorized`: If the user is not authenticated. - - `403 Forbidden`: If the user does not have the required permissions. +Monitor BullMQ job queues for asynchronous tasks such as email ingestion, indexing, and sync scheduling. Requires Super Admin (`manage:all`) permission. -### Response Body +There are two queues: -```json -{ - "queues": [ - { - "name": "ingestion", - "counts": { - "active": 0, - "completed": 56, - "failed": 4, - "delayed": 3, - "waiting": 0, - "paused": 0 - } - }, - { - "name": "indexing", - "counts": { - "active": 0, - "completed": 0, - "failed": 0, - "delayed": 0, - "waiting": 0, - "paused": 0 - } - } - ] -} -``` +- **`ingestion`** — handles all email ingestion and sync jobs (`initial-import`, `continuous-sync`, `process-mailbox`, `sync-cycle-finished`, `schedule-continuous-sync`) +- **`indexing`** — handles batched Meilisearch document indexing (`index-email-batch`) -## Get Queue Jobs +## List All Queues -- **Endpoint:** `GET /v1/jobs/queues/:queueName` -- **Description:** Retrieves a list of jobs within a specific queue, with pagination and filtering by status. -- **Permissions:** `manage:all` -- **URL Parameters:** - - `queueName` (string, required): The name of the queue to retrieve jobs from. -- **Query Parameters:** - - `status` (string, optional): The status of the jobs to retrieve. Can be one of `active`, `completed`, `failed`, `delayed`, `waiting`, `paused`. Defaults to `failed`. - - `page` (number, optional): The page number to retrieve. Defaults to `1`. - - `limit` (number, optional): The number of jobs to retrieve per page. Defaults to `10`. -- **Responses:** - - `200 OK`: Returns a detailed view of the queue, including a paginated list of jobs. - - `401 Unauthorized`: If the user is not authenticated. - - `403 Forbidden`: If the user does not have the required permissions. - - `404 Not Found`: If the specified queue does not exist. + -### Response Body +## Get Jobs in a Queue -```json -{ - "name": "ingestion", - "counts": { - "active": 0, - "completed": 56, - "failed": 4, - "delayed": 3, - "waiting": 0, - "paused": 0 - }, - "jobs": [ - { - "id": "1", - "name": "initial-import", - "data": { - "ingestionSourceId": "clx1y2z3a0000b4d2e5f6g7h8" - }, - "state": "failed", - "failedReason": "Error: Connection timed out", - "timestamp": 1678886400000, - "processedOn": 1678886401000, - "finishedOn": 1678886402000, - "attemptsMade": 5, - "stacktrace": ["..."], - "returnValue": null, - "ingestionSourceId": "clx1y2z3a0000b4d2e5f6g7h8", - "error": "Error: Connection timed out" - } - ], - "pagination": { - "currentPage": 1, - "totalPages": 1, - "totalJobs": 4, - "limit": 10 - } -} -``` + diff --git a/docs/api/openapi.json b/docs/api/openapi.json new file mode 100644 index 00000000..46d8ccb3 --- /dev/null +++ b/docs/api/openapi.json @@ -0,0 +1,3301 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Open Archiver API", + "version": "1.0.0", + "description": "REST API for Open Archiver — an open-source email archiving platform. All authenticated endpoints require a Bearer JWT token obtained from `POST /v1/auth/login`, or an API key passed as a Bearer token.", + "license": { + "name": "SEE LICENSE IN LICENSE", + "url": "https://github.com/LogicLabs-OU/OpenArchiver/blob/main/LICENSE" + }, + "contact": { + "name": "Open Archiver", + "url": "https://openarchiver.com" + } + }, + "servers": [ + { + "url": "http://localhost:3001", + "description": "Local development" + } + ], + "security": [ + { + "bearerAuth": [] + }, + { + "apiKeyAuth": [] + } + ], + "components": { + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT", + "description": "JWT obtained from `POST /v1/auth/login`. Pass as `Authorization: Bearer `." + }, + "apiKeyAuth": { + "type": "apiKey", + "in": "header", + "name": "X-API-KEY", + "description": "API key generated via `POST /v1/api-keys`. Pass as `X-API-KEY: `." + } + }, + "responses": { + "Unauthorized": { + "description": "Authentication is required or the token is invalid/expired.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorMessage" + }, + "example": { + "message": "Unauthorized" + } + } + } + }, + "Forbidden": { + "description": "The authenticated user does not have permission to perform this action.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorMessage" + }, + "example": { + "message": "Forbidden" + } + } + } + }, + "NotFound": { + "description": "The requested resource was not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorMessage" + }, + "example": { + "message": "Not found" + } + } + } + }, + "InternalServerError": { + "description": "An unexpected error occurred on the server.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorMessage" + }, + "example": { + "message": "Internal server error" + } + } + } + } + }, + "schemas": { + "ErrorMessage": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Human-readable error description.", + "example": "An error occurred." + } + }, + "required": ["message"] + }, + "MessageResponse": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "Operation completed successfully." + } + }, + "required": ["message"] + }, + "ValidationError": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "Request body is invalid." + }, + "errors": { + "type": "string", + "description": "Zod validation error details." + } + }, + "required": ["message"] + }, + "LoginResponse": { + "type": "object", + "properties": { + "accessToken": { + "type": "string", + "description": "JWT for authenticating subsequent requests.", + "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + }, + "user": { + "$ref": "#/components/schemas/User" + } + }, + "required": ["accessToken", "user"] + }, + "User": { + "type": "object", + "properties": { + "id": { + "type": "string", + "example": "clx1y2z3a0000b4d2" + }, + "first_name": { + "type": "string", + "nullable": true, + "example": "Jane" + }, + "last_name": { + "type": "string", + "nullable": true, + "example": "Doe" + }, + "email": { + "type": "string", + "format": "email", + "example": "jane.doe@example.com" + }, + "role": { + "$ref": "#/components/schemas/Role", + "nullable": true + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + }, + "required": ["id", "email", "createdAt"] + }, + "Role": { + "type": "object", + "properties": { + "id": { + "type": "string", + "example": "clx1y2z3a0000b4d2" + }, + "slug": { + "type": "string", + "nullable": true, + "example": "predefined_super_admin" + }, + "name": { + "type": "string", + "example": "Super Admin" + }, + "policies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CaslPolicy" + } + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + }, + "required": ["id", "name", "policies", "createdAt", "updatedAt"] + }, + "CaslPolicy": { + "type": "object", + "description": "An CASL-style permission policy statement. `action` and `subject` can be strings or arrays of strings. `conditions` optionally restricts access to specific resource attributes.", + "properties": { + "action": { + "oneOf": [ + { + "type": "string", + "example": "read" + }, + { + "type": "array", + "items": { + "type": "string" + }, + "example": ["read", "search"] + } + ] + }, + "subject": { + "oneOf": [ + { + "type": "string", + "example": "archive" + }, + { + "type": "array", + "items": { + "type": "string" + }, + "example": ["archive", "ingestion"] + } + ] + }, + "conditions": { + "type": "object", + "description": "Optional attribute-level conditions. Supports `${user.id}` interpolation.", + "example": { + "userId": "${user.id}" + } + } + }, + "required": ["action", "subject"] + }, + "ApiKey": { + "type": "object", + "properties": { + "id": { + "type": "string", + "example": "clx1y2z3a0000b4d2" + }, + "name": { + "type": "string", + "example": "CI/CD Pipeline Key" + }, + "key": { + "type": "string", + "description": "Partial/masked key — the raw value is only available at creation time.", + "example": "oa_live_abc1..." + }, + "expiresAt": { + "type": "string", + "format": "date-time" + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + }, + "required": ["id", "name", "expiresAt", "createdAt"] + }, + "SafeIngestionSource": { + "type": "object", + "description": "An ingestion source with sensitive credential fields removed.", + "properties": { + "id": { + "type": "string", + "example": "clx1y2z3a0000b4d2" + }, + "name": { + "type": "string", + "example": "Company Google Workspace" + }, + "provider": { + "type": "string", + "enum": [ + "google_workspace", + "microsoft_365", + "generic_imap", + "pst_import", + "eml_import", + "mbox_import" + ], + "example": "google_workspace" + }, + "status": { + "type": "string", + "enum": [ + "active", + "paused", + "error", + "pending_auth", + "syncing", + "importing", + "auth_success", + "imported" + ], + "example": "active" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "lastSyncStartedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "lastSyncFinishedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "lastSyncStatusMessage": { + "type": "string", + "nullable": true + } + }, + "required": ["id", "name", "provider", "status", "createdAt", "updatedAt"] + }, + "CreateIngestionSourceDto": { + "type": "object", + "required": ["name", "provider", "providerConfig"], + "properties": { + "name": { + "type": "string", + "example": "Company Google Workspace" + }, + "provider": { + "type": "string", + "enum": [ + "google_workspace", + "microsoft_365", + "generic_imap", + "pst_import", + "eml_import", + "mbox_import" + ] + }, + "providerConfig": { + "type": "object", + "description": "Provider-specific configuration. See the ingestion source guides for the required fields per provider.", + "example": { + "serviceAccountKeyJson": "{\"type\":\"service_account\",...}", + "impersonatedAdminEmail": "admin@example.com" + } + } + } + }, + "UpdateIngestionSourceDto": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "provider": { + "type": "string", + "enum": [ + "google_workspace", + "microsoft_365", + "generic_imap", + "pst_import", + "eml_import", + "mbox_import" + ] + }, + "status": { + "type": "string", + "enum": [ + "active", + "paused", + "error", + "pending_auth", + "syncing", + "importing", + "auth_success", + "imported" + ] + }, + "providerConfig": { + "type": "object" + } + } + }, + "Recipient": { + "type": "object", + "properties": { + "name": { + "type": "string", + "nullable": true, + "example": "John Doe" + }, + "email": { + "type": "string", + "format": "email", + "example": "john.doe@example.com" + } + }, + "required": ["email"] + }, + "Attachment": { + "type": "object", + "properties": { + "id": { + "type": "string", + "example": "clx1y2z3a0000b4d2" + }, + "filename": { + "type": "string", + "example": "invoice.pdf" + }, + "mimeType": { + "type": "string", + "nullable": true, + "example": "application/pdf" + }, + "sizeBytes": { + "type": "integer", + "example": 204800 + }, + "storagePath": { + "type": "string", + "example": "open-archiver/attachments/abc123.pdf" + } + }, + "required": ["id", "filename", "sizeBytes", "storagePath"] + }, + "ThreadEmail": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "ArchivedEmail ID.", + "example": "clx1y2z3a0000b4d2" + }, + "subject": { + "type": "string", + "nullable": true, + "example": "Re: Q4 Invoice" + }, + "sentAt": { + "type": "string", + "format": "date-time" + }, + "senderEmail": { + "type": "string", + "format": "email", + "example": "finance@vendor.com" + } + }, + "required": ["id", "sentAt", "senderEmail"] + }, + "ArchivedEmail": { + "type": "object", + "properties": { + "id": { + "type": "string", + "example": "clx1y2z3a0000b4d2" + }, + "ingestionSourceId": { + "type": "string", + "example": "clx1y2z3a0000b4d2" + }, + "userEmail": { + "type": "string", + "format": "email", + "example": "user@company.com" + }, + "messageIdHeader": { + "type": "string", + "nullable": true + }, + "sentAt": { + "type": "string", + "format": "date-time" + }, + "subject": { + "type": "string", + "nullable": true, + "example": "Q4 Invoice" + }, + "senderName": { + "type": "string", + "nullable": true, + "example": "Finance Dept" + }, + "senderEmail": { + "type": "string", + "format": "email", + "example": "finance@vendor.com" + }, + "recipients": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Recipient" + } + }, + "storagePath": { + "type": "string" + }, + "storageHashSha256": { + "type": "string", + "description": "SHA-256 hash of the raw email file, stored at archival time." + }, + "sizeBytes": { + "type": "integer" + }, + "isIndexed": { + "type": "boolean" + }, + "hasAttachments": { + "type": "boolean" + }, + "isOnLegalHold": { + "type": "boolean" + }, + "archivedAt": { + "type": "string", + "format": "date-time" + }, + "attachments": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Attachment" + } + }, + "thread": { + "type": "array", + "description": "Other emails in the same thread, ordered by sentAt. Only present on single-email GET responses.", + "items": { + "$ref": "#/components/schemas/ThreadEmail" + } + }, + "path": { + "type": "string", + "nullable": true + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + } + }, + "required": [ + "id", + "ingestionSourceId", + "userEmail", + "sentAt", + "senderEmail", + "recipients", + "storagePath", + "storageHashSha256", + "sizeBytes", + "isIndexed", + "hasAttachments", + "isOnLegalHold", + "archivedAt" + ] + }, + "PaginatedArchivedEmails": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ArchivedEmail" + } + }, + "total": { + "type": "integer", + "example": 1234 + }, + "page": { + "type": "integer", + "example": 1 + }, + "limit": { + "type": "integer", + "example": 10 + } + }, + "required": ["items", "total", "page", "limit"] + }, + "SearchResults": { + "type": "object", + "properties": { + "hits": { + "type": "array", + "description": "Array of matching archived email objects, potentially with highlighted fields.", + "items": { + "type": "object" + } + }, + "total": { + "type": "integer", + "example": 42 + }, + "page": { + "type": "integer", + "example": 1 + }, + "limit": { + "type": "integer", + "example": 10 + }, + "totalPages": { + "type": "integer", + "example": 5 + }, + "processingTimeMs": { + "type": "integer", + "description": "Meilisearch query processing time in milliseconds.", + "example": 12 + } + }, + "required": ["hits", "total", "page", "limit", "totalPages", "processingTimeMs"] + }, + "IntegrityCheckResult": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["email", "attachment"], + "description": "Whether this result is for the email itself or one of its attachments." + }, + "id": { + "type": "string", + "example": "clx1y2z3a0000b4d2" + }, + "filename": { + "type": "string", + "description": "Attachment filename. Only present when `type` is `attachment`.", + "example": "invoice.pdf" + }, + "isValid": { + "type": "boolean", + "description": "True if the stored and computed hashes match." + }, + "reason": { + "type": "string", + "description": "Human-readable explanation if `isValid` is false." + }, + "storedHash": { + "type": "string", + "description": "SHA-256 hash stored at archival time.", + "example": "a3f1b2c4..." + }, + "computedHash": { + "type": "string", + "description": "SHA-256 hash computed during this verification run.", + "example": "a3f1b2c4..." + } + }, + "required": ["type", "id", "isValid", "storedHash", "computedHash"] + }, + "QueueCounts": { + "type": "object", + "properties": { + "active": { + "type": "integer", + "example": 0 + }, + "completed": { + "type": "integer", + "example": 56 + }, + "failed": { + "type": "integer", + "example": 4 + }, + "delayed": { + "type": "integer", + "example": 0 + }, + "waiting": { + "type": "integer", + "example": 0 + }, + "paused": { + "type": "integer", + "example": 0 + } + }, + "required": ["active", "completed", "failed", "delayed", "waiting", "paused"] + }, + "QueueOverview": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "ingestion" + }, + "counts": { + "$ref": "#/components/schemas/QueueCounts" + } + }, + "required": ["name", "counts"] + }, + "Job": { + "type": "object", + "properties": { + "id": { + "type": "string", + "nullable": true, + "example": "1" + }, + "name": { + "type": "string", + "example": "initial-import" + }, + "data": { + "type": "object", + "description": "Job payload data.", + "example": { + "ingestionSourceId": "clx1y2z3a0000b4d2" + } + }, + "state": { + "type": "string", + "enum": ["active", "completed", "failed", "delayed", "waiting", "paused"], + "example": "failed" + }, + "failedReason": { + "type": "string", + "nullable": true, + "example": "Error: Connection timed out" + }, + "timestamp": { + "type": "integer", + "example": 1678886400000 + }, + "processedOn": { + "type": "integer", + "nullable": true, + "example": 1678886401000 + }, + "finishedOn": { + "type": "integer", + "nullable": true, + "example": 1678886402000 + }, + "attemptsMade": { + "type": "integer", + "example": 5 + }, + "stacktrace": { + "type": "array", + "items": { + "type": "string" + } + }, + "returnValue": { + "nullable": true + }, + "ingestionSourceId": { + "type": "string", + "nullable": true + }, + "error": { + "description": "Shorthand copy of `failedReason` for easier access.", + "nullable": true + } + }, + "required": [ + "id", + "name", + "data", + "state", + "timestamp", + "attemptsMade", + "stacktrace" + ] + }, + "QueueDetails": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "ingestion" + }, + "counts": { + "$ref": "#/components/schemas/QueueCounts" + }, + "jobs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Job" + } + }, + "pagination": { + "type": "object", + "properties": { + "currentPage": { + "type": "integer", + "example": 1 + }, + "totalPages": { + "type": "integer", + "example": 3 + }, + "totalJobs": { + "type": "integer", + "example": 25 + }, + "limit": { + "type": "integer", + "example": 10 + } + }, + "required": ["currentPage", "totalPages", "totalJobs", "limit"] + } + }, + "required": ["name", "counts", "jobs", "pagination"] + }, + "DashboardStats": { + "type": "object", + "properties": { + "totalEmailsArchived": { + "type": "integer", + "example": 125000 + }, + "totalStorageUsed": { + "type": "integer", + "description": "Total storage used by all archived emails in bytes.", + "example": 5368709120 + }, + "failedIngestionsLast7Days": { + "type": "integer", + "description": "Number of ingestion sources in error state updated in the last 7 days.", + "example": 2 + } + } + }, + "IngestionSourceStats": { + "type": "object", + "description": "Summary of an ingestion source including its storage usage.", + "properties": { + "id": { + "type": "string", + "example": "clx1y2z3a0000b4d2" + }, + "name": { + "type": "string", + "example": "Company Google Workspace" + }, + "provider": { + "type": "string", + "example": "google_workspace" + }, + "status": { + "type": "string", + "example": "active" + }, + "storageUsed": { + "type": "integer", + "description": "Total bytes stored for emails from this ingestion source.", + "example": 1073741824 + } + }, + "required": ["id", "name", "provider", "status", "storageUsed"] + }, + "RecentSync": { + "type": "object", + "description": "Summary of a recent sync session.", + "properties": { + "id": { + "type": "string", + "example": "clx1y2z3a0000b4d2" + }, + "sourceName": { + "type": "string", + "example": "Company Google Workspace" + }, + "startTime": { + "type": "string", + "format": "date-time" + }, + "duration": { + "type": "integer", + "description": "Duration in milliseconds.", + "example": 4500 + }, + "emailsProcessed": { + "type": "integer", + "example": 120 + }, + "status": { + "type": "string", + "example": "completed" + } + }, + "required": [ + "id", + "sourceName", + "startTime", + "duration", + "emailsProcessed", + "status" + ] + }, + "IndexedInsights": { + "type": "object", + "description": "Insights derived from the search index.", + "properties": { + "topSenders": { + "type": "array", + "items": { + "type": "object", + "properties": { + "sender": { + "type": "string", + "example": "finance@vendor.com" + }, + "count": { + "type": "integer", + "example": 342 + } + }, + "required": ["sender", "count"] + } + } + }, + "required": ["topSenders"] + }, + "SystemSettings": { + "type": "object", + "description": "Non-sensitive system configuration values.", + "properties": { + "language": { + "type": "string", + "enum": ["en", "es", "fr", "de", "it", "pt", "nl", "ja", "et", "el"], + "example": "en", + "description": "Default UI language code." + }, + "theme": { + "type": "string", + "enum": ["light", "dark", "system"], + "example": "system", + "description": "Default color theme." + }, + "supportEmail": { + "type": "string", + "format": "email", + "nullable": true, + "example": "support@example.com", + "description": "Public-facing support email address." + } + } + } + } + }, + "paths": { + "/v1/api-keys": { + "post": { + "summary": "Generate an API key", + "description": "Generates a new API key for the authenticated user. The raw key value is only returned once at creation time. The key name must be between 1–255 characters. Expiry is required and must be within 730 days (2 years). Disabled in demo mode.\n", + "operationId": "generateApiKey", + "tags": ["API Keys"], + "security": [ + { + "bearerAuth": [] + }, + { + "apiKeyAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["name", "expiresInDays"], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "example": "CI/CD Pipeline Key" + }, + "expiresInDays": { + "type": "integer", + "minimum": 1, + "maximum": 730, + "example": 90 + } + } + } + } + } + }, + "responses": { + "201": { + "description": "API key created. The raw `key` value is only shown once.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "The raw API key. Store this securely — it will not be shown again.", + "example": "oa_live_abc123..." + } + } + } + } + } + }, + "400": { + "description": "Validation error (name too short/long, expiry out of range).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationError" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "description": "Disabled in demo mode.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorMessage" + } + } + } + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + } + } + }, + "get": { + "summary": "List API keys", + "description": "Returns all API keys belonging to the currently authenticated user. The raw key value is not included.", + "operationId": "getApiKeys", + "tags": ["API Keys"], + "security": [ + { + "bearerAuth": [] + }, + { + "apiKeyAuth": [] + } + ], + "responses": { + "200": { + "description": "List of API keys (without raw key values).", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ApiKey" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + } + } + }, + "/v1/api-keys/{id}": { + "delete": { + "summary": "Delete an API key", + "description": "Permanently revokes and deletes an API key by ID. Only the owning user can delete their own keys. Disabled in demo mode.", + "operationId": "deleteApiKey", + "tags": ["API Keys"], + "security": [ + { + "bearerAuth": [] + }, + { + "apiKeyAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The ID of the API key to delete.", + "schema": { + "type": "string", + "example": "clx1y2z3a0000b4d2" + } + } + ], + "responses": { + "204": { + "description": "API key deleted. No content returned." + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "description": "Disabled in demo mode.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorMessage" + } + } + } + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + } + } + } + }, + "/v1/archived-emails/ingestion-source/{ingestionSourceId}": { + "get": { + "summary": "List archived emails for an ingestion source", + "description": "Returns a paginated list of archived emails belonging to the specified ingestion source. Requires `read:archive` permission.", + "operationId": "getArchivedEmails", + "tags": ["Archived Emails"], + "security": [ + { + "bearerAuth": [] + }, + { + "apiKeyAuth": [] + } + ], + "parameters": [ + { + "name": "ingestionSourceId", + "in": "path", + "required": true, + "description": "The ID of the ingestion source to retrieve emails for.", + "schema": { + "type": "string", + "example": "clx1y2z3a0000b4d2" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "description": "Page number for pagination.", + "schema": { + "type": "integer", + "default": 1, + "example": 1 + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Number of items per page.", + "schema": { + "type": "integer", + "default": 10, + "example": 10 + } + } + ], + "responses": { + "200": { + "description": "Paginated list of archived emails.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedArchivedEmails" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + } + } + } + }, + "/v1/archived-emails/{id}": { + "get": { + "summary": "Get a single archived email", + "description": "Retrieves the full details of a single archived email by ID, including attachments and thread. Requires `read:archive` permission.", + "operationId": "getArchivedEmailById", + "tags": ["Archived Emails"], + "security": [ + { + "bearerAuth": [] + }, + { + "apiKeyAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The ID of the archived email.", + "schema": { + "type": "string", + "example": "clx1y2z3a0000b4d2" + } + } + ], + "responses": { + "200": { + "description": "Archived email details.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchivedEmail" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + } + } + }, + "delete": { + "summary": "Delete an archived email", + "description": "Permanently deletes an archived email by ID. Deletion must be enabled in system settings and the email must not be on legal hold. Requires `delete:archive` permission.", + "operationId": "deleteArchivedEmail", + "tags": ["Archived Emails"], + "security": [ + { + "bearerAuth": [] + }, + { + "apiKeyAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The ID of the archived email to delete.", + "schema": { + "type": "string", + "example": "clx1y2z3a0000b4d2" + } + } + ], + "responses": { + "204": { + "description": "Email deleted successfully. No content returned." + }, + "400": { + "description": "Deletion is disabled in system settings, or the email is blocked by a retention policy / legal hold.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorMessage" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + } + } + } + }, + "/v1/auth/setup": { + "post": { + "summary": "Initial setup", + "description": "Creates the initial administrator user. Can only be called once when no users exist.", + "operationId": "authSetup", + "tags": ["Auth"], + "security": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["email", "password", "first_name", "last_name"], + "properties": { + "email": { + "type": "string", + "format": "email", + "example": "admin@example.com" + }, + "password": { + "type": "string", + "format": "password", + "example": "securepassword123" + }, + "first_name": { + "type": "string", + "example": "Admin" + }, + "last_name": { + "type": "string", + "example": "User" + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Admin user created and logged in successfully.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginResponse" + } + } + } + }, + "400": { + "description": "All fields are required.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorMessage" + } + } + } + }, + "403": { + "description": "Setup has already been completed (users already exist).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorMessage" + } + } + } + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + } + } + } + }, + "/v1/auth/login": { + "post": { + "summary": "Login", + "description": "Authenticates a user with email and password and returns a JWT access token.", + "operationId": "authLogin", + "tags": ["Auth"], + "security": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["email", "password"], + "properties": { + "email": { + "type": "string", + "format": "email", + "example": "user@example.com" + }, + "password": { + "type": "string", + "format": "password", + "example": "securepassword123" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Authentication successful.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginResponse" + } + } + } + }, + "400": { + "description": "Email and password are required.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorMessage" + } + } + } + }, + "401": { + "description": "Invalid credentials.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorMessage" + } + } + } + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + } + } + } + }, + "/v1/auth/status": { + "get": { + "summary": "Check setup status", + "description": "Returns whether the application has been set up (i.e., whether an admin user exists).", + "operationId": "authStatus", + "tags": ["Auth"], + "security": [], + "responses": { + "200": { + "description": "Setup status returned.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "needsSetup": { + "type": "boolean", + "description": "True if no admin user exists and setup is required.", + "example": false + } + } + } + } + } + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + } + } + } + }, + "/v1/dashboard/stats": { + "get": { + "summary": "Get dashboard stats", + "description": "Returns high-level statistics including total archived emails, total storage used, and failed ingestions in the last 7 days. Requires `read:dashboard` permission.", + "operationId": "getDashboardStats", + "tags": ["Dashboard"], + "security": [ + { + "bearerAuth": [] + }, + { + "apiKeyAuth": [] + } + ], + "responses": { + "200": { + "description": "Dashboard statistics.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DashboardStats" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + } + }, + "/v1/dashboard/ingestion-history": { + "get": { + "summary": "Get ingestion history", + "description": "Returns time-series data of email ingestion counts for the last 30 days. Requires `read:dashboard` permission.", + "operationId": "getIngestionHistory", + "tags": ["Dashboard"], + "security": [ + { + "bearerAuth": [] + }, + { + "apiKeyAuth": [] + } + ], + "responses": { + "200": { + "description": "Ingestion history wrapped in a `history` array.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "history": { + "type": "array", + "items": { + "type": "object", + "properties": { + "date": { + "type": "string", + "format": "date-time", + "description": "Truncated to day precision (UTC)." + }, + "count": { + "type": "integer" + } + } + } + } + }, + "required": ["history"] + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + } + }, + "/v1/dashboard/ingestion-sources": { + "get": { + "summary": "Get ingestion source summaries", + "description": "Returns a summary list of ingestion sources with their storage usage. Requires `read:dashboard` permission.", + "operationId": "getDashboardIngestionSources", + "tags": ["Dashboard"], + "security": [ + { + "bearerAuth": [] + }, + { + "apiKeyAuth": [] + } + ], + "responses": { + "200": { + "description": "List of ingestion source summaries.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IngestionSourceStats" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + } + }, + "/v1/dashboard/recent-syncs": { + "get": { + "summary": "Get recent sync activity", + "description": "Returns the most recent sync sessions across all ingestion sources. Requires `read:dashboard` permission.", + "operationId": "getRecentSyncs", + "tags": ["Dashboard"], + "security": [ + { + "bearerAuth": [] + }, + { + "apiKeyAuth": [] + } + ], + "responses": { + "200": { + "description": "List of recent sync sessions.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RecentSync" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + } + }, + "/v1/dashboard/indexed-insights": { + "get": { + "summary": "Get indexed email insights", + "description": "Returns top-sender statistics from the search index. Requires `read:dashboard` permission.", + "operationId": "getIndexedInsights", + "tags": ["Dashboard"], + "security": [ + { + "bearerAuth": [] + }, + { + "apiKeyAuth": [] + } + ], + "responses": { + "200": { + "description": "Indexed email insights.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IndexedInsights" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + } + }, + "/v1/iam/roles": { + "get": { + "summary": "List all roles", + "description": "Returns all IAM roles. If predefined roles do not yet exist, they are created automatically. Requires `read:roles` permission.", + "operationId": "getRoles", + "tags": ["IAM"], + "security": [ + { + "bearerAuth": [] + }, + { + "apiKeyAuth": [] + } + ], + "responses": { + "200": { + "description": "List of roles.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Role" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + } + } + }, + "post": { + "summary": "Create a role", + "description": "Creates a new IAM role with the given name and CASL policies. Requires `manage:all` (Super Admin) permission.", + "operationId": "createRole", + "tags": ["IAM"], + "security": [ + { + "bearerAuth": [] + }, + { + "apiKeyAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["name", "policies"], + "properties": { + "name": { + "type": "string", + "example": "Compliance Officer" + }, + "policies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CaslPolicy" + } + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Role created.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Role" + } + } + } + }, + "400": { + "description": "Missing fields or invalid policy.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorMessage" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + } + } + } + }, + "/v1/iam/roles/{id}": { + "get": { + "summary": "Get a role", + "description": "Returns a single IAM role by ID. Requires `read:roles` permission.", + "operationId": "getRoleById", + "tags": ["IAM"], + "security": [ + { + "bearerAuth": [] + }, + { + "apiKeyAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "clx1y2z3a0000b4d2" + } + } + ], + "responses": { + "200": { + "description": "Role details.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Role" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + } + } + }, + "delete": { + "summary": "Delete a role", + "description": "Permanently deletes an IAM role. Requires `manage:all` (Super Admin) permission.", + "operationId": "deleteRole", + "tags": ["IAM"], + "security": [ + { + "bearerAuth": [] + }, + { + "apiKeyAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "clx1y2z3a0000b4d2" + } + } + ], + "responses": { + "204": { + "description": "Role deleted. No content returned." + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + } + } + }, + "put": { + "summary": "Update a role", + "description": "Updates the name or policies of an IAM role. Requires `manage:all` (Super Admin) permission.", + "operationId": "updateRole", + "tags": ["IAM"], + "security": [ + { + "bearerAuth": [] + }, + { + "apiKeyAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "clx1y2z3a0000b4d2" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "Senior Compliance Officer" + }, + "policies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CaslPolicy" + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Updated role.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Role" + } + } + } + }, + "400": { + "description": "No update fields provided or invalid policy.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorMessage" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + } + } + } + }, + "/v1/ingestion-sources": { + "post": { + "summary": "Create an ingestion source", + "description": "Creates a new ingestion source and validates the connection. Returns the created source without credentials. Requires `create:ingestion` permission.", + "operationId": "createIngestionSource", + "tags": ["Ingestion"], + "security": [ + { + "bearerAuth": [] + }, + { + "apiKeyAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateIngestionSourceDto" + } + } + } + }, + "responses": { + "201": { + "description": "Ingestion source created successfully.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SafeIngestionSource" + } + } + } + }, + "400": { + "description": "Invalid input or connection test failed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorMessage" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + } + }, + "get": { + "summary": "List ingestion sources", + "description": "Returns all ingestion sources accessible to the authenticated user. Credentials are excluded from the response. Requires `read:ingestion` permission.", + "operationId": "listIngestionSources", + "tags": ["Ingestion"], + "security": [ + { + "bearerAuth": [] + }, + { + "apiKeyAuth": [] + } + ], + "responses": { + "200": { + "description": "Array of ingestion sources.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SafeIngestionSource" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + } + } + } + }, + "/v1/ingestion-sources/{id}": { + "get": { + "summary": "Get an ingestion source", + "description": "Returns a single ingestion source by ID. Credentials are excluded. Requires `read:ingestion` permission.", + "operationId": "getIngestionSourceById", + "tags": ["Ingestion"], + "security": [ + { + "bearerAuth": [] + }, + { + "apiKeyAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "clx1y2z3a0000b4d2" + } + } + ], + "responses": { + "200": { + "description": "Ingestion source details.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SafeIngestionSource" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + } + } + }, + "put": { + "summary": "Update an ingestion source", + "description": "Updates configuration for an existing ingestion source. Requires `update:ingestion` permission.", + "operationId": "updateIngestionSource", + "tags": ["Ingestion"], + "security": [ + { + "bearerAuth": [] + }, + { + "apiKeyAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "clx1y2z3a0000b4d2" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateIngestionSourceDto" + } + } + } + }, + "responses": { + "200": { + "description": "Updated ingestion source.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SafeIngestionSource" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + } + } + }, + "delete": { + "summary": "Delete an ingestion source", + "description": "Permanently deletes an ingestion source. Deletion must be enabled in system settings. Requires `delete:ingestion` permission.", + "operationId": "deleteIngestionSource", + "tags": ["Ingestion"], + "security": [ + { + "bearerAuth": [] + }, + { + "apiKeyAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "clx1y2z3a0000b4d2" + } + } + ], + "responses": { + "204": { + "description": "Ingestion source deleted. No content returned." + }, + "400": { + "description": "Deletion disabled or constraint error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorMessage" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + } + } + } + }, + "/v1/ingestion-sources/{id}/import": { + "post": { + "summary": "Trigger initial import", + "description": "Enqueues an initial import job for the ingestion source. This imports all historical emails. Requires `create:ingestion` permission.", + "operationId": "triggerInitialImport", + "tags": ["Ingestion"], + "security": [ + { + "bearerAuth": [] + }, + { + "apiKeyAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "clx1y2z3a0000b4d2" + } + } + ], + "responses": { + "202": { + "description": "Initial import job accepted and queued.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MessageResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + } + } + } + }, + "/v1/ingestion-sources/{id}/pause": { + "post": { + "summary": "Pause an ingestion source", + "description": "Sets the ingestion source status to `paused`, stopping continuous sync. Requires `update:ingestion` permission.", + "operationId": "pauseIngestionSource", + "tags": ["Ingestion"], + "security": [ + { + "bearerAuth": [] + }, + { + "apiKeyAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "clx1y2z3a0000b4d2" + } + } + ], + "responses": { + "200": { + "description": "Ingestion source paused. Returns the updated source.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SafeIngestionSource" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + } + } + } + }, + "/v1/ingestion-sources/{id}/sync": { + "post": { + "summary": "Force sync", + "description": "Triggers an out-of-schedule continuous sync for the ingestion source. Requires `sync:ingestion` permission.", + "operationId": "triggerForceSync", + "tags": ["Ingestion"], + "security": [ + { + "bearerAuth": [] + }, + { + "apiKeyAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "clx1y2z3a0000b4d2" + } + } + ], + "responses": { + "202": { + "description": "Force sync job accepted and queued.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MessageResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + } + } + } + }, + "/v1/integrity/{id}": { + "get": { + "summary": "Check email integrity", + "description": "Verifies the SHA-256 hash of an archived email and all its attachments against the hashes stored at archival time. Returns per-item integrity results. Requires `read:archive` permission.", + "operationId": "checkIntegrity", + "tags": ["Integrity"], + "security": [ + { + "bearerAuth": [] + }, + { + "apiKeyAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "UUID of the archived email to verify.", + "schema": { + "type": "string", + "format": "uuid", + "example": "550e8400-e29b-41d4-a716-446655440000" + } + } + ], + "responses": { + "200": { + "description": "Integrity check results for the email and its attachments.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IntegrityCheckResult" + } + } + } + } + }, + "400": { + "description": "Invalid UUID format.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationError" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + } + } + } + }, + "/v1/jobs/queues": { + "get": { + "summary": "List all queues", + "description": "Returns all BullMQ job queues and their current job counts broken down by status. Requires `manage:all` (Super Admin) permission.", + "operationId": "getQueues", + "tags": ["Jobs"], + "security": [ + { + "bearerAuth": [] + }, + { + "apiKeyAuth": [] + } + ], + "responses": { + "200": { + "description": "List of queue overviews.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "queues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QueueOverview" + } + } + }, + "example": { + "queues": [ + { + "name": "ingestion", + "counts": { + "active": 0, + "completed": 56, + "failed": 4, + "delayed": 3, + "waiting": 0, + "paused": 0 + } + }, + { + "name": "indexing", + "counts": { + "active": 0, + "completed": 0, + "failed": 0, + "delayed": 0, + "waiting": 0, + "paused": 0 + } + } + ] + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + } + } + } + }, + "/v1/jobs/queues/{queueName}": { + "get": { + "summary": "Get jobs in a queue", + "description": "Returns a paginated list of jobs within a specific queue, filtered by status. Requires `manage:all` (Super Admin) permission.", + "operationId": "getQueueJobs", + "tags": ["Jobs"], + "security": [ + { + "bearerAuth": [] + }, + { + "apiKeyAuth": [] + } + ], + "parameters": [ + { + "name": "queueName", + "in": "path", + "required": true, + "description": "The name of the queue (e.g. `ingestion` or `indexing`).", + "schema": { + "type": "string", + "example": "ingestion" + } + }, + { + "name": "status", + "in": "query", + "required": false, + "description": "Filter jobs by status.", + "schema": { + "type": "string", + "enum": [ + "active", + "completed", + "failed", + "delayed", + "waiting", + "paused" + ], + "default": "failed" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 1 + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 10 + } + } + ], + "responses": { + "200": { + "description": "Detailed view of the queue including paginated jobs.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QueueDetails" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "description": "Queue not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorMessage" + } + } + } + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + } + } + } + }, + "/v1/search": { + "get": { + "summary": "Search archived emails", + "description": "Performs a full-text search across indexed archived emails using Meilisearch. Requires `search:archive` permission.", + "operationId": "searchEmails", + "tags": ["Search"], + "security": [ + { + "bearerAuth": [] + }, + { + "apiKeyAuth": [] + } + ], + "parameters": [ + { + "name": "keywords", + "in": "query", + "required": true, + "description": "The search query string.", + "schema": { + "type": "string", + "example": "invoice Q4" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "description": "Page number for pagination.", + "schema": { + "type": "integer", + "default": 1, + "example": 1 + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Number of results per page.", + "schema": { + "type": "integer", + "default": 10, + "example": 10 + } + }, + { + "name": "matchingStrategy", + "in": "query", + "required": false, + "description": "Meilisearch matching strategy. `last` returns results containing at least one keyword; `all` requires all keywords; `frequency` sorts by keyword frequency.", + "schema": { + "type": "string", + "enum": ["last", "all", "frequency"], + "default": "last" + } + } + ], + "responses": { + "200": { + "description": "Search results.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SearchResults" + } + } + } + }, + "400": { + "description": "Keywords parameter is required.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorMessage" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + } + } + } + }, + "/v1/settings/system": { + "get": { + "summary": "Get system settings", + "description": "Returns non-sensitive system settings such as language, timezone, and feature flags. This endpoint is public — no authentication required. Sensitive settings are never exposed.\n", + "operationId": "getSystemSettings", + "tags": ["Settings"], + "responses": { + "200": { + "description": "Current system settings.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SystemSettings" + } + } + } + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + } + } + }, + "put": { + "summary": "Update system settings", + "description": "Updates system settings. Requires `manage:settings` permission.", + "operationId": "updateSystemSettings", + "tags": ["Settings"], + "security": [ + { + "bearerAuth": [] + }, + { + "apiKeyAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SystemSettings" + } + } + } + }, + "responses": { + "200": { + "description": "Updated system settings.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SystemSettings" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + } + } + } + }, + "/v1/storage/download": { + "get": { + "summary": "Download a stored file", + "description": "Downloads a file from the configured storage backend (local filesystem or S3-compatible). The path is sanitized to prevent directory traversal attacks. Requires `read:archive` permission.\n", + "operationId": "downloadFile", + "tags": ["Storage"], + "security": [ + { + "bearerAuth": [] + }, + { + "apiKeyAuth": [] + } + ], + "parameters": [ + { + "name": "path", + "in": "query", + "required": true, + "description": "The relative storage path of the file to download.", + "schema": { + "type": "string", + "example": "open-archiver/emails/abc123.eml" + } + } + ], + "responses": { + "200": { + "description": "The file content as a binary stream. The `Content-Disposition` header is set to trigger a browser download.", + "headers": { + "Content-Disposition": { + "description": "Attachment filename.", + "schema": { + "type": "string", + "example": "attachment; filename=\"abc123.eml\"" + } + } + }, + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "description": "File path is required or invalid.", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "description": "File not found in storage.", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + } + } + } + }, + "/v1/upload": { + "post": { + "summary": "Upload a file", + "description": "Uploads a file (PST, EML, MBOX, or other) to temporary storage for subsequent use in an ingestion source. Returns the storage path, which should be passed as `uploadedFilePath` when creating a file-based ingestion source. Requires `create:ingestion` permission.\n", + "operationId": "uploadFile", + "tags": ["Upload"], + "security": [ + { + "bearerAuth": [] + }, + { + "apiKeyAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "file": { + "type": "string", + "format": "binary", + "description": "The file to upload." + } + } + } + } + } + }, + "responses": { + "200": { + "description": "File uploaded successfully. Returns the storage path.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "filePath": { + "type": "string", + "description": "The storage path of the uploaded file. Use this as `uploadedFilePath` when creating a file-based ingestion source.", + "example": "open-archiver/tmp/uuid-filename.pst" + } + } + } + } + } + }, + "400": { + "description": "Invalid multipart request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorMessage" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + } + } + } + }, + "/v1/users": { + "get": { + "summary": "List all users", + "description": "Returns all user accounts in the system. Requires `read:users` permission.", + "operationId": "getUsers", + "tags": ["Users"], + "security": [ + { + "bearerAuth": [] + }, + { + "apiKeyAuth": [] + } + ], + "responses": { + "200": { + "description": "List of users.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/User" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + } + }, + "post": { + "summary": "Create a user", + "description": "Creates a new user account and optionally assigns a role. Requires `manage:all` (Super Admin) permission.", + "operationId": "createUser", + "tags": ["Users"], + "security": [ + { + "bearerAuth": [] + }, + { + "apiKeyAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["email", "first_name", "last_name", "password"], + "properties": { + "email": { + "type": "string", + "format": "email", + "example": "jane.doe@example.com" + }, + "first_name": { + "type": "string", + "example": "Jane" + }, + "last_name": { + "type": "string", + "example": "Doe" + }, + "password": { + "type": "string", + "format": "password", + "example": "securepassword123" + }, + "roleId": { + "type": "string", + "description": "Optional role ID to assign to the user.", + "example": "clx1y2z3a0000b4d2" + } + } + } + } + } + }, + "responses": { + "201": { + "description": "User created.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + } + }, + "/v1/users/profile": { + "get": { + "summary": "Get current user profile", + "description": "Returns the profile of the currently authenticated user.", + "operationId": "getProfile", + "tags": ["Users"], + "security": [ + { + "bearerAuth": [] + }, + { + "apiKeyAuth": [] + } + ], + "responses": { + "200": { + "description": "Current user's profile.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + }, + "patch": { + "summary": "Update current user profile", + "description": "Updates the email, first name, or last name of the currently authenticated user. Disabled in demo mode.", + "operationId": "updateProfile", + "tags": ["Users"], + "security": [ + { + "bearerAuth": [] + }, + { + "apiKeyAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email" + }, + "first_name": { + "type": "string" + }, + "last_name": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Updated user profile.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "description": "Disabled in demo mode.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorMessage" + } + } + } + } + } + } + }, + "/v1/users/profile/password": { + "post": { + "summary": "Update password", + "description": "Updates the password of the currently authenticated user. The current password must be provided for verification. Disabled in demo mode.", + "operationId": "updatePassword", + "tags": ["Users"], + "security": [ + { + "bearerAuth": [] + }, + { + "apiKeyAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["currentPassword", "newPassword"], + "properties": { + "currentPassword": { + "type": "string", + "format": "password" + }, + "newPassword": { + "type": "string", + "format": "password" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Password updated successfully.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MessageResponse" + } + } + } + }, + "400": { + "description": "Current password is incorrect.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorMessage" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "description": "Disabled in demo mode.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorMessage" + } + } + } + } + } + } + }, + "/v1/users/{id}": { + "get": { + "summary": "Get a user", + "description": "Returns a single user by ID. Requires `read:users` permission.", + "operationId": "getUser", + "tags": ["Users"], + "security": [ + { + "bearerAuth": [] + }, + { + "apiKeyAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "clx1y2z3a0000b4d2" + } + } + ], + "responses": { + "200": { + "description": "User details.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + }, + "put": { + "summary": "Update a user", + "description": "Updates a user's email, name, or role assignment. Requires `manage:all` (Super Admin) permission.", + "operationId": "updateUser", + "tags": ["Users"], + "security": [ + { + "bearerAuth": [] + }, + { + "apiKeyAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "clx1y2z3a0000b4d2" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email" + }, + "first_name": { + "type": "string" + }, + "last_name": { + "type": "string" + }, + "roleId": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Updated user.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + }, + "delete": { + "summary": "Delete a user", + "description": "Permanently deletes a user. Cannot delete the last remaining user. Requires `manage:all` (Super Admin) permission.", + "operationId": "deleteUser", + "tags": ["Users"], + "security": [ + { + "bearerAuth": [] + }, + { + "apiKeyAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "clx1y2z3a0000b4d2" + } + } + ], + "responses": { + "204": { + "description": "User deleted. No content returned." + }, + "400": { + "description": "Cannot delete the only remaining user.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorMessage" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + } + } + }, + "tags": [] +} diff --git a/docs/api/rate-limiting.md b/docs/api/rate-limiting.md index 8a18e3fd..9add9381 100644 --- a/docs/api/rate-limiting.md +++ b/docs/api/rate-limiting.md @@ -1,3 +1,7 @@ +--- +aside: false +--- + # Rate Limiting The API implements rate limiting as a security measure to protect your instance from denial-of-service (DoS) and brute-force attacks. This is a crucial feature for maintaining the security and stability of the application. diff --git a/docs/api/search.md b/docs/api/search.md index e46d0de9..a830d930 100644 --- a/docs/api/search.md +++ b/docs/api/search.md @@ -1,50 +1,11 @@ -# Search Service API +--- +aside: false +--- -The Search Service provides an endpoint for searching indexed emails. +# Search API -## Endpoints +Full-text search over indexed archived emails, powered by Meilisearch. -All endpoints in this service require authentication. +## Search Emails -### GET /api/v1/search - -Performs a search query against the indexed emails. - -**Access:** Authenticated - -#### Query Parameters - -| Parameter | Type | Description | Default | -| :----------------- | :----- | :--------------------------------------------------------------------- | :------ | -| `keywords` | string | The search query. | | -| `page` | number | The page number for pagination. | 1 | -| `limit` | number | The number of items per page. | 10 | -| `matchingStrategy` | string | The matching strategy to use (`all` or `last`). | `last` | -| `filters` | object | Key-value pairs for filtering results (e.g., `from=user@example.com`). | | - -#### Responses - -- **200 OK:** A search result object. - - ```json - { - "hits": [ - { - "id": "email-id", - "subject": "Test Email", - "from": "sender@example.com", - "_formatted": { - "subject": "Test Email" - } - } - ], - "total": 1, - "page": 1, - "limit": 10, - "totalPages": 1, - "processingTimeMs": 5 - } - ``` - -- **400 Bad Request:** Keywords are required. -- **500 Internal Server Error:** An unexpected error occurred. + diff --git a/docs/api/settings.md b/docs/api/settings.md new file mode 100644 index 00000000..26b9d86a --- /dev/null +++ b/docs/api/settings.md @@ -0,0 +1,15 @@ +--- +aside: false +--- + +# Settings API + +Read and update system-wide configuration. The `GET` endpoint is public. The `PUT` endpoint requires `manage:settings` permission. + +## Get System Settings + + + +## Update System Settings + + diff --git a/docs/api/storage.md b/docs/api/storage.md index 199c38db..9468814f 100644 --- a/docs/api/storage.md +++ b/docs/api/storage.md @@ -1,26 +1,11 @@ -# Storage Service API +--- +aside: false +--- -The Storage Service provides an endpoint for downloading files from the configured storage provider. +# Storage API -## Endpoints +Download files from the configured storage backend (local filesystem or S3-compatible). Requires `read:archive` permission. -All endpoints in this service require authentication. +## Download a File -### GET /api/v1/storage/download - -Downloads a file from the storage. - -**Access:** Authenticated - -#### Query Parameters - -| Parameter | Type | Description | -| :-------- | :----- | :------------------------------------------------ | -| `path` | string | The path to the file within the storage provider. | - -#### Responses - -- **200 OK:** The file stream. -- **400 Bad Request:** File path is required or invalid. -- **404 Not Found:** File not found. -- **500 Internal Server Error:** An unexpected error occurred. + diff --git a/docs/api/upload.md b/docs/api/upload.md new file mode 100644 index 00000000..e82a88b3 --- /dev/null +++ b/docs/api/upload.md @@ -0,0 +1,11 @@ +--- +aside: false +--- + +# Upload API + +Upload files (PST, EML, MBOX) to temporary storage before creating a file-based ingestion source. The returned `filePath` should be passed as `uploadedFilePath` in the ingestion source `providerConfig`. + +## Upload a File + + diff --git a/docs/api/users.md b/docs/api/users.md new file mode 100644 index 00000000..bcde47fa --- /dev/null +++ b/docs/api/users.md @@ -0,0 +1,39 @@ +--- +aside: false +--- + +# Users API + +Manage user accounts. Creating, updating, and deleting users requires Super Admin (`manage:all`) permission. + +## List All Users + + + +## Create a User + + + +## Get a User + + + +## Update a User + + + +## Delete a User + + + +## Get Current User Profile + + + +## Update Current User Profile + + + +## Update Password + + diff --git a/docs/enterprise/legal-holds/api.md b/docs/enterprise/legal-holds/api.md index 786fec9c..d1775f43 100644 --- a/docs/enterprise/legal-holds/api.md +++ b/docs/enterprise/legal-holds/api.md @@ -23,26 +23,26 @@ Retrieves all legal holds ordered by creation date ascending, each annotated wit ```json [ - { - "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", - "name": "Project Titan Litigation — 2026", - "reason": "Preservation order received 2026-01-15 re: IP dispute", - "isActive": true, - "caseId": null, - "emailCount": 4821, - "createdAt": "2026-01-15T10:30:00.000Z", - "updatedAt": "2026-01-15T10:30:00.000Z" - }, - { - "id": "b2c3d4e5-f6a7-8901-bcde-f23456789012", - "name": "SEC Investigation Q3 2025", - "reason": null, - "isActive": false, - "caseId": "c3d4e5f6-a7b8-9012-cdef-345678901234", - "emailCount": 310, - "createdAt": "2025-09-01T08:00:00.000Z", - "updatedAt": "2025-11-20T16:45:00.000Z" - } + { + "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "name": "Project Titan Litigation — 2026", + "reason": "Preservation order received 2026-01-15 re: IP dispute", + "isActive": true, + "caseId": null, + "emailCount": 4821, + "createdAt": "2026-01-15T10:30:00.000Z", + "updatedAt": "2026-01-15T10:30:00.000Z" + }, + { + "id": "b2c3d4e5-f6a7-8901-bcde-f23456789012", + "name": "SEC Investigation Q3 2025", + "reason": null, + "isActive": false, + "caseId": "c3d4e5f6-a7b8-9012-cdef-345678901234", + "emailCount": 310, + "createdAt": "2025-09-01T08:00:00.000Z", + "updatedAt": "2025-11-20T16:45:00.000Z" + } ] ``` @@ -59,9 +59,9 @@ Retrieves a single legal hold by its UUID. #### Path Parameters -| Parameter | Type | Description | -| --------- | ------ | ----------------------------- | -| `id` | `uuid` | The UUID of the hold to get. | +| Parameter | Type | Description | +| --------- | ------ | ---------------------------- | +| `id` | `uuid` | The UUID of the hold to get. | #### Response @@ -80,19 +80,19 @@ Creates a new legal hold. Holds are always created in the **active** state. #### Request Body -| Field | Type | Required | Description | -| -------- | -------- | -------- | ----------------------------------------------------------- | -| `name` | `string` | Yes | Unique hold name. Max 255 characters. | +| Field | Type | Required | Description | +| -------- | -------- | -------- | -------------------------------------------------------------- | +| `name` | `string` | Yes | Unique hold name. Max 255 characters. | | `reason` | `string` | No | Legal basis or description for the hold. Max 2 000 characters. | -| `caseId` | `uuid` | No | Optional UUID of an `ediscovery_cases` record to link to. | +| `caseId` | `uuid` | No | Optional UUID of an `ediscovery_cases` record to link to. | #### Example Request ```json { - "name": "Project Titan Litigation — 2026", - "reason": "Preservation notice received from outside counsel on 2026-01-15 regarding IP dispute with ExCorp.", - "caseId": null + "name": "Project Titan Litigation — 2026", + "reason": "Preservation notice received from outside counsel on 2026-01-15 regarding IP dispute with ExCorp.", + "caseId": null } ``` @@ -115,25 +115,25 @@ Updates the name, reason, or `isActive` state of a hold. Only the fields provide #### Path Parameters -| Parameter | Type | Description | -| --------- | ------ | -------------------------------- | -| `id` | `uuid` | The UUID of the hold to update. | +| Parameter | Type | Description | +| --------- | ------ | ------------------------------- | +| `id` | `uuid` | The UUID of the hold to update. | #### Request Body All fields are optional. At least one must be provided. -| Field | Type | Description | -| ---------- | --------- | -------------------------------------------------- | -| `name` | `string` | New hold name. Max 255 characters. | -| `reason` | `string` | Updated reason/description. Max 2 000 characters. | +| Field | Type | Description | +| ---------- | --------- | --------------------------------------------------- | +| `name` | `string` | New hold name. Max 255 characters. | +| `reason` | `string` | Updated reason/description. Max 2 000 characters. | | `isActive` | `boolean` | Set to `false` to deactivate, `true` to reactivate. | #### Example — Deactivate a Hold ```json { - "isActive": false + "isActive": false } ``` @@ -158,9 +158,9 @@ Permanently deletes a legal hold and (via database CASCADE) all associated `emai #### Path Parameters -| Parameter | Type | Description | -| --------- | ------ | -------------------------------- | -| `id` | `uuid` | The UUID of the hold to delete. | +| Parameter | Type | Description | +| --------- | ------ | ------------------------------- | +| `id` | `uuid` | The UUID of the hold to delete. | #### Response @@ -185,37 +185,37 @@ Applies a legal hold to **all emails matching a Meilisearch query**. The operati #### Path Parameters -| Parameter | Type | Description | -| --------- | ------ | ----------------------------------- | -| `id` | `uuid` | The UUID of the hold to apply. | +| Parameter | Type | Description | +| --------- | ------ | ------------------------------ | +| `id` | `uuid` | The UUID of the hold to apply. | #### Request Body -| Field | Type | Required | Description | -| ------------- | -------- | -------- | ------------------------------------------------------------------- | -| `searchQuery` | `object` | Yes | A Meilisearch query object (see structure below). | +| Field | Type | Required | Description | +| ------------- | -------- | -------- | ------------------------------------------------- | +| `searchQuery` | `object` | Yes | A Meilisearch query object (see structure below). | ##### `searchQuery` Object -| Field | Type | Required | Description | -| ------------------ | -------- | -------- | ------------------------------------------------------------------ | -| `query` | `string` | Yes | Full-text search string. Pass `""` to match all documents. | -| `filters` | `object` | No | Key-value filter object (e.g., `{ "from": "user@corp.com" }`). | +| Field | Type | Required | Description | +| ------------------ | -------- | -------- | ------------------------------------------------------------------- | +| `query` | `string` | Yes | Full-text search string. Pass `""` to match all documents. | +| `filters` | `object` | No | Key-value filter object (e.g., `{ "from": "user@corp.com" }`). | | `matchingStrategy` | `string` | No | Meilisearch matching strategy: `"last"`, `"all"`, or `"frequency"`. | #### Example Request ```json { - "searchQuery": { - "query": "Project Titan confidential", - "filters": { - "from": "john.doe@acme.com", - "startDate": "2023-01-01", - "endDate": "2025-12-31" - }, - "matchingStrategy": "all" - } + "searchQuery": { + "query": "Project Titan confidential", + "filters": { + "from": "john.doe@acme.com", + "startDate": "2023-01-01", + "endDate": "2025-12-31" + }, + "matchingStrategy": "all" + } } ``` @@ -223,17 +223,17 @@ Applies a legal hold to **all emails matching a Meilisearch query**. The operati ```json { - "legalHoldId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", - "emailsLinked": 1247, - "queryUsed": { - "query": "Project Titan confidential", - "filters": { - "from": "john.doe@acme.com", - "startDate": "2023-01-01", - "endDate": "2025-12-31" - }, - "matchingStrategy": "all" - } + "legalHoldId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "emailsLinked": 1247, + "queryUsed": { + "query": "Project Titan confidential", + "filters": { + "from": "john.doe@acme.com", + "startDate": "2023-01-01", + "endDate": "2025-12-31" + }, + "matchingStrategy": "all" + } } ``` @@ -260,15 +260,15 @@ Removes all `email_legal_holds` associations for the given hold in a single oper #### Path Parameters -| Parameter | Type | Description | -| --------- | ------ | ------------------------------------ | -| `id` | `uuid` | The UUID of the hold to release. | +| Parameter | Type | Description | +| --------- | ------ | -------------------------------- | +| `id` | `uuid` | The UUID of the hold to release. | #### Response Body ```json { - "emailsReleased": 4821 + "emailsReleased": 4821 } ``` @@ -294,9 +294,9 @@ Returns all legal holds currently linked to a specific archived email, including #### Path Parameters -| Parameter | Type | Description | -| --------- | ------ | ---------------------------------------- | -| `emailId` | `uuid` | The UUID of the archived email. | +| Parameter | Type | Description | +| --------- | ------ | ------------------------------- | +| `emailId` | `uuid` | The UUID of the archived email. | #### Response Body @@ -304,20 +304,20 @@ Returns an empty array `[]` if no holds are applied, or an array of hold-link ob ```json [ - { - "legalHoldId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", - "holdName": "Project Titan Litigation — 2026", - "isActive": true, - "appliedAt": "2026-01-15T11:00:00.000Z", - "appliedByUserId": "user-uuid-here" - }, - { - "legalHoldId": "b2c3d4e5-f6a7-8901-bcde-f23456789012", - "holdName": "SEC Investigation Q3 2025", - "isActive": false, - "appliedAt": "2025-09-05T09:15:00.000Z", - "appliedByUserId": null - } + { + "legalHoldId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "holdName": "Project Titan Litigation — 2026", + "isActive": true, + "appliedAt": "2026-01-15T11:00:00.000Z", + "appliedByUserId": "user-uuid-here" + }, + { + "legalHoldId": "b2c3d4e5-f6a7-8901-bcde-f23456789012", + "holdName": "SEC Investigation Q3 2025", + "isActive": false, + "appliedAt": "2025-09-05T09:15:00.000Z", + "appliedByUserId": null + } ] ``` @@ -338,21 +338,21 @@ Links a single archived email to an active legal hold. The operation is idempote #### Path Parameters -| Parameter | Type | Description | -| --------- | ------ | ---------------------------------------- | -| `emailId` | `uuid` | The UUID of the archived email. | +| Parameter | Type | Description | +| --------- | ------ | ------------------------------- | +| `emailId` | `uuid` | The UUID of the archived email. | #### Request Body -| Field | Type | Required | Description | -| -------- | ------ | -------- | ------------------------------------ | -| `holdId` | `uuid` | Yes | The UUID of the hold to apply. | +| Field | Type | Required | Description | +| -------- | ------ | -------- | ------------------------------ | +| `holdId` | `uuid` | Yes | The UUID of the hold to apply. | #### Example Request ```json { - "holdId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + "holdId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890" } ``` @@ -362,11 +362,11 @@ Returns the hold-link object with the DB-authoritative `appliedAt` timestamp: ```json { - "legalHoldId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", - "holdName": "Project Titan Litigation — 2026", - "isActive": true, - "appliedAt": "2026-01-16T14:22:00.000Z", - "appliedByUserId": "user-uuid-here" + "legalHoldId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "holdName": "Project Titan Litigation — 2026", + "isActive": true, + "appliedAt": "2026-01-16T14:22:00.000Z", + "appliedByUserId": "user-uuid-here" } ``` @@ -390,16 +390,16 @@ Unlinks a specific legal hold from a specific archived email. The hold itself is #### Path Parameters -| Parameter | Type | Description | -| --------- | ------ | ---------------------------------------- | -| `emailId` | `uuid` | The UUID of the archived email. | -| `holdId` | `uuid` | The UUID of the hold to remove. | +| Parameter | Type | Description | +| --------- | ------ | ------------------------------- | +| `emailId` | `uuid` | The UUID of the archived email. | +| `holdId` | `uuid` | The UUID of the hold to remove. | #### Response Body ```json { - "message": "Hold removed from email successfully." + "message": "Hold removed from email successfully." } ``` @@ -416,10 +416,10 @@ All endpoints use the standard error response format: ```json { - "status": "error", - "statusCode": 409, - "message": "Cannot delete an active legal hold. Deactivate it first to explicitly lift legal protection before deletion.", - "errors": null + "status": "error", + "statusCode": 409, + "message": "Cannot delete an active legal hold. Deactivate it first to explicitly lift legal protection before deletion.", + "errors": null } ``` @@ -427,15 +427,15 @@ For validation errors (`422 Unprocessable Entity`): ```json { - "status": "error", - "statusCode": 422, - "message": "Invalid input provided.", - "errors": [ - { - "field": "name", - "message": "Name is required." - } - ] + "status": "error", + "statusCode": 422, + "message": "Invalid input provided.", + "errors": [ + { + "field": "name", + "message": "Name is required." + } + ] } ``` @@ -443,12 +443,12 @@ For validation errors (`422 Unprocessable Entity`): ## Validation Constraints -| Field | Constraint | -| -------------- | ----------------------------------------------- | -| Hold name | 1–255 characters. | -| Reason | Max 2 000 characters. | -| `caseId` | Must be a valid UUID if provided. | -| `holdId` | Must be a valid UUID. | -| `emailId` | Must be a valid UUID. | -| Search `query` | String (may be empty `""`). | -| `matchingStrategy` | One of `"last"`, `"all"`, `"frequency"`. | +| Field | Constraint | +| ------------------ | ---------------------------------------- | +| Hold name | 1–255 characters. | +| Reason | Max 2 000 characters. | +| `caseId` | Must be a valid UUID if provided. | +| `holdId` | Must be a valid UUID. | +| `emailId` | Must be a valid UUID. | +| Search `query` | String (may be empty `""`). | +| `matchingStrategy` | One of `"last"`, `"all"`, `"frequency"`. | diff --git a/docs/enterprise/legal-holds/guide.md b/docs/enterprise/legal-holds/guide.md index a9706bf2..df0fa51c 100644 --- a/docs/enterprise/legal-holds/guide.md +++ b/docs/enterprise/legal-holds/guide.md @@ -14,8 +14,8 @@ The main page displays a table of all legal holds with the following columns: - **Reason:** A short excerpt of the hold's reason/description. Shows _"No reason provided"_ if omitted. - **Emails:** A badge showing how many archived emails are currently linked to this hold. - **Status:** A badge indicating whether the hold is: - - **Active** (red badge): The hold is currently granting deletion immunity to linked emails. - - **Inactive** (gray badge): The hold is deactivated; linked emails are no longer immune. + - **Active** (red badge): The hold is currently granting deletion immunity to linked emails. + - **Inactive** (gray badge): The hold is deactivated; linked emails are no longer immune. - **Created At:** The date the hold was created, in local date format. - **Actions:** Dropdown menu with options depending on the hold's state (see below). @@ -43,13 +43,14 @@ Click **Edit** from the actions dropdown to modify the hold's name or reason. Th The **Deactivate** / **Activate** option appears inline in the actions dropdown. Changing the active state does not remove any email links — it only determines whether those links grant deletion immunity. -> **Important:** Deactivating a hold means that all emails linked *solely* to this hold lose their deletion immunity immediately. If any such emails have an expired retention period, they will be permanently deleted on the very next lifecycle worker cycle. +> **Important:** Deactivating a hold means that all emails linked _solely_ to this hold lose their deletion immunity immediately. If any such emails have an expired retention period, they will be permanently deleted on the very next lifecycle worker cycle. ## Deleting a Hold A hold **cannot be deleted while it is active**. Attempting to delete an active hold returns a `409 Conflict` error with the message: _"Cannot delete an active legal hold. Deactivate it first..."_ To delete a hold: + 1. **Deactivate** it first using the Activate/Deactivate action. 2. Click **Delete** from the actions dropdown. 3. Confirm in the dialog. @@ -81,6 +82,7 @@ At least one of these fields must be filled before the **Apply Hold** button bec ### Bulk Apply and the Audit Log The audit log entry for a bulk apply contains: + - `action: "BulkApplyHold"` - `searchQuery`: the exact JSON query used - `emailsLinked`: number of emails newly linked @@ -99,6 +101,7 @@ A confirmation dialog is shown before the operation proceeds. On success, a noti ### Viewing Holds on a Specific Email On any archived email's detail page, the **Legal Holds** card lists all holds currently applied to that email, showing: + - Hold name and active/inactive badge - Date the hold was applied @@ -140,18 +143,22 @@ The **Delete Email** button on the email detail page is not disabled in the UI, ## Troubleshooting ### Cannot Delete Hold — "Cannot delete an active legal hold" + **Cause:** The hold is still active. **Solution:** Use the **Deactivate** option from the actions dropdown first. ### Bulk Apply Returns 0 Emails + **Cause 1:** The search query matched no documents in the Meilisearch index. **Solution:** Verify the query in the main Search page to preview results before applying. **Cause 2:** All Meilisearch results were stale (emails deleted from the archive before this operation). **Solution:** This is a data state issue; the stale index entries will be cleaned up on the next index rebuild. ### Delete Email Returns an Error Instead of Deleting + **Cause:** The email is under one or more active legal holds. **Solution:** This is expected behavior. Deactivate or remove the hold(s) from this email before deleting. ### Hold Emails Count Shows 0 After Bulk Apply + **Cause:** The `emailCount` field is fetched when the page loads. If the bulk operation was just completed, refresh the page to see the updated count. diff --git a/docs/enterprise/legal-holds/index.md b/docs/enterprise/legal-holds/index.md index 7f58bcd7..0c74db0c 100644 --- a/docs/enterprise/legal-holds/index.md +++ b/docs/enterprise/legal-holds/index.md @@ -61,17 +61,17 @@ Holds can optionally be linked to an `ediscovery_cases` record (`caseId` field) ## Architecture Overview -| Component | Location | Description | -| --------------- | ------------------------------------------------------------------------------ | -------------------------------------------------------------- | -| Types | `packages/types/src/retention.types.ts` | `LegalHold`, `EmailLegalHoldInfo`, `BulkApplyHoldResult` types | -| Database Schema | `packages/backend/src/database/schema/compliance.ts` | `legal_holds` and `email_legal_holds` table definitions | -| Service | `packages/enterprise/src/modules/legal-holds/LegalHoldService.ts` | All business logic for CRUD, linkage, and bulk operations | -| Controller | `packages/enterprise/src/modules/legal-holds/legal-hold.controller.ts` | Express request handlers with Zod validation | -| Routes | `packages/enterprise/src/modules/legal-holds/legal-hold.routes.ts` | Route registration with auth and feature guards | -| Module | `packages/enterprise/src/modules/legal-holds/legal-hold.module.ts` | App-startup integration and `RetentionHook` registration | -| Frontend Page | `packages/frontend/src/routes/dashboard/compliance/legal-holds/` | SvelteKit management page for holds | -| Email Detail | `packages/frontend/src/routes/dashboard/archived-emails/[id]/` | Per-email hold card in the email detail view | -| Lifecycle Guard | `packages/backend/src/hooks/RetentionHook.ts` | Static hook that blocks deletion if a hold is active | +| Component | Location | Description | +| --------------- | ---------------------------------------------------------------------- | -------------------------------------------------------------- | +| Types | `packages/types/src/retention.types.ts` | `LegalHold`, `EmailLegalHoldInfo`, `BulkApplyHoldResult` types | +| Database Schema | `packages/backend/src/database/schema/compliance.ts` | `legal_holds` and `email_legal_holds` table definitions | +| Service | `packages/enterprise/src/modules/legal-holds/LegalHoldService.ts` | All business logic for CRUD, linkage, and bulk operations | +| Controller | `packages/enterprise/src/modules/legal-holds/legal-hold.controller.ts` | Express request handlers with Zod validation | +| Routes | `packages/enterprise/src/modules/legal-holds/legal-hold.routes.ts` | Route registration with auth and feature guards | +| Module | `packages/enterprise/src/modules/legal-holds/legal-hold.module.ts` | App-startup integration and `RetentionHook` registration | +| Frontend Page | `packages/frontend/src/routes/dashboard/compliance/legal-holds/` | SvelteKit management page for holds | +| Email Detail | `packages/frontend/src/routes/dashboard/archived-emails/[id]/` | Per-email hold card in the email detail view | +| Lifecycle Guard | `packages/backend/src/hooks/RetentionHook.ts` | Static hook that blocks deletion if a hold is active | ## Data Model @@ -89,12 +89,12 @@ Holds can optionally be linked to an `ediscovery_cases` record (`caseId` field) ### `email_legal_holds` Join Table -| Column | Type | Description | -| --------------------- | ------------- | --------------------------------------------------------------- | -| `email_id` | `uuid` (FK) | Reference to `archived_emails.id`. Cascades on delete. | -| `legal_hold_id` | `uuid` (FK) | Reference to `legal_holds.id`. Cascades on delete. | -| `applied_at` | `timestamptz` | DB-server timestamp of when the link was created. | -| `applied_by_user_id` | `uuid` (FK) | User who applied the hold (nullable for system operations). | +| Column | Type | Description | +| -------------------- | ------------- | ----------------------------------------------------------- | +| `email_id` | `uuid` (FK) | Reference to `archived_emails.id`. Cascades on delete. | +| `legal_hold_id` | `uuid` (FK) | Reference to `legal_holds.id`. Cascades on delete. | +| `applied_at` | `timestamptz` | DB-server timestamp of when the link was created. | +| `applied_by_user_id` | `uuid` (FK) | User who applied the hold (nullable for system operations). | The table uses a composite primary key of `(email_id, legal_hold_id)`, enforcing uniqueness at the database level. Duplicate inserts use `ON CONFLICT DO NOTHING` for idempotency. @@ -112,14 +112,14 @@ The lifecycle worker calls `legalHoldService.isEmailUnderActiveHold(emailId)` as All legal hold operations generate entries in `audit_logs`: -| Action | `actionType` | `targetType` | `targetId` | -| -------------------------------- | ------------ | --------------- | ----------------- | -| Hold created | `CREATE` | `LegalHold` | hold ID | -| Hold updated / deactivated | `UPDATE` | `LegalHold` | hold ID | -| Hold deleted | `DELETE` | `LegalHold` | hold ID | -| Email linked to hold (individual)| `UPDATE` | `ArchivedEmail` | email ID | -| Email unlinked from hold | `UPDATE` | `ArchivedEmail` | email ID | -| Bulk apply via search | `UPDATE` | `LegalHold` | hold ID + query JSON | -| All emails released from hold | `UPDATE` | `LegalHold` | hold ID | +| Action | `actionType` | `targetType` | `targetId` | +| --------------------------------- | ------------ | --------------- | -------------------- | +| Hold created | `CREATE` | `LegalHold` | hold ID | +| Hold updated / deactivated | `UPDATE` | `LegalHold` | hold ID | +| Hold deleted | `DELETE` | `LegalHold` | hold ID | +| Email linked to hold (individual) | `UPDATE` | `ArchivedEmail` | email ID | +| Email unlinked from hold | `UPDATE` | `ArchivedEmail` | email ID | +| Bulk apply via search | `UPDATE` | `LegalHold` | hold ID + query JSON | +| All emails released from hold | `UPDATE` | `LegalHold` | hold ID | Individual email link/unlink events target `ArchivedEmail` so that a per-email audit search surfaces the complete hold history for that email. diff --git a/docs/enterprise/retention-labels/api.md b/docs/enterprise/retention-labels/api.md index 38dd4ade..c8e8bdef 100644 --- a/docs/enterprise/retention-labels/api.md +++ b/docs/enterprise/retention-labels/api.md @@ -23,22 +23,22 @@ Retrieves all retention labels, ordered by creation date ascending. ```json [ - { - "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", - "name": "Legal Hold - Litigation ABC", - "description": "Extended retention for emails related to litigation ABC vs Company", - "retentionPeriodDays": 2555, - "isDisabled": false, - "createdAt": "2025-10-01T00:00:00.000Z" - }, - { - "id": "b2c3d4e5-f6a7-8901-bcde-f23456789012", - "name": "Executive Communications", - "description": null, - "retentionPeriodDays": 3650, - "isDisabled": true, - "createdAt": "2025-09-15T12:30:00.000Z" - } + { + "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "name": "Legal Hold - Litigation ABC", + "description": "Extended retention for emails related to litigation ABC vs Company", + "retentionPeriodDays": 2555, + "isDisabled": false, + "createdAt": "2025-10-01T00:00:00.000Z" + }, + { + "id": "b2c3d4e5-f6a7-8901-bcde-f23456789012", + "name": "Executive Communications", + "description": null, + "retentionPeriodDays": 3650, + "isDisabled": true, + "createdAt": "2025-09-15T12:30:00.000Z" + } ] ``` @@ -55,9 +55,9 @@ Retrieves a single retention label by its UUID. #### Path Parameters -| Parameter | Type | Description | -| --------- | ------ | ------------------------------ | -| `id` | `uuid` | The UUID of the label to get. | +| Parameter | Type | Description | +| --------- | ------ | ----------------------------- | +| `id` | `uuid` | The UUID of the label to get. | #### Response Body @@ -76,19 +76,19 @@ Creates a new retention label. The label name must be unique across the system. #### Request Body -| Field | Type | Required | Description | -| -------------------- | --------- | -------- | -------------------------------------------------------------- | -| `name` | `string` | Yes | Unique label name. Max 255 characters. | -| `description` | `string` | No | Human-readable description. Max 1000 characters. | -| `retentionPeriodDays` | `integer` | Yes | Number of days to retain emails with this label. Minimum 1. | +| Field | Type | Required | Description | +| --------------------- | --------- | -------- | ----------------------------------------------------------- | +| `name` | `string` | Yes | Unique label name. Max 255 characters. | +| `description` | `string` | No | Human-readable description. Max 1000 characters. | +| `retentionPeriodDays` | `integer` | Yes | Number of days to retain emails with this label. Minimum 1. | #### Example Request ```json { - "name": "Financial Records - Q4 2025", - "description": "Extended retention for Q4 2025 financial correspondence per regulatory requirements", - "retentionPeriodDays": 2555 + "name": "Financial Records - Q4 2025", + "description": "Extended retention for Q4 2025 financial correspondence per regulatory requirements", + "retentionPeriodDays": 2555 } ``` @@ -111,9 +111,9 @@ Updates an existing retention label. Only the fields included in the request bod #### Path Parameters -| Parameter | Type | Description | -| --------- | ------ | --------------------------------- | -| `id` | `uuid` | The UUID of the label to update. | +| Parameter | Type | Description | +| --------- | ------ | -------------------------------- | +| `id` | `uuid` | The UUID of the label to update. | #### Request Body @@ -125,8 +125,8 @@ All fields from the create endpoint are accepted, and all are optional. Only pro ```json { - "name": "Financial Records - Q4 2025 (Updated)", - "description": "Updated description for Q4 2025 financial records retention" + "name": "Financial Records - Q4 2025 (Updated)", + "description": "Updated description for Q4 2025 financial records retention" } ``` @@ -150,9 +150,9 @@ Deletes or disables a retention label depending on its usage status. #### Path Parameters -| Parameter | Type | Description | -| --------- | ------ | --------------------------------- | -| `id` | `uuid` | The UUID of the label to delete. | +| Parameter | Type | Description | +| --------- | ------ | -------------------------------- | +| `id` | `uuid` | The UUID of the label to delete. | #### Deletion Logic @@ -163,7 +163,7 @@ Deletes or disables a retention label depending on its usage status. ```json { - "action": "deleted" + "action": "deleted" } ``` @@ -171,7 +171,7 @@ or ```json { - "action": "disabled" + "action": "disabled" } ``` @@ -195,9 +195,9 @@ Retrieves the retention label currently applied to a specific archived email. #### Path Parameters -| Parameter | Type | Description | -| --------- | ------ | ------------------------------------- | -| `emailId` | `uuid` | The UUID of the archived email. | +| Parameter | Type | Description | +| --------- | ------ | ------------------------------- | +| `emailId` | `uuid` | The UUID of the archived email. | #### Response Body @@ -211,11 +211,11 @@ Or the label information if a label is applied: ```json { - "labelId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", - "labelName": "Legal Hold - Litigation ABC", - "retentionPeriodDays": 2555, - "appliedAt": "2025-10-15T14:30:00.000Z", - "appliedByUserId": "user123" + "labelId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "labelName": "Legal Hold - Litigation ABC", + "retentionPeriodDays": 2555, + "appliedAt": "2025-10-15T14:30:00.000Z", + "appliedByUserId": "user123" } ``` @@ -237,21 +237,21 @@ Applies a retention label to an archived email. If the email already has a label #### Path Parameters -| Parameter | Type | Description | -| --------- | ------ | ------------------------------------- | -| `emailId` | `uuid` | The UUID of the archived email. | +| Parameter | Type | Description | +| --------- | ------ | ------------------------------- | +| `emailId` | `uuid` | The UUID of the archived email. | #### Request Body -| Field | Type | Required | Description | -| --------- | ------ | -------- | ------------------------------------ | -| `labelId` | `uuid` | Yes | The UUID of the label to apply. | +| Field | Type | Required | Description | +| --------- | ------ | -------- | ------------------------------- | +| `labelId` | `uuid` | Yes | The UUID of the label to apply. | #### Example Request ```json { - "labelId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + "labelId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890" } ``` @@ -259,11 +259,11 @@ Applies a retention label to an archived email. If the email already has a label ```json { - "labelId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", - "labelName": "Legal Hold - Litigation ABC", - "retentionPeriodDays": 2555, - "appliedAt": "2025-10-15T14:30:00.000Z", - "appliedByUserId": "user123" + "labelId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "labelName": "Legal Hold - Litigation ABC", + "retentionPeriodDays": 2555, + "appliedAt": "2025-10-15T14:30:00.000Z", + "appliedByUserId": "user123" } ``` @@ -287,9 +287,9 @@ Removes the retention label from an archived email if one is applied. #### Path Parameters -| Parameter | Type | Description | -| --------- | ------ | ------------------------------------- | -| `emailId` | `uuid` | The UUID of the archived email. | +| Parameter | Type | Description | +| --------- | ------ | ------------------------------- | +| `emailId` | `uuid` | The UUID of the archived email. | #### Response Body @@ -297,7 +297,7 @@ If a label was removed: ```json { - "message": "Label removed successfully." + "message": "Label removed successfully." } ``` @@ -305,7 +305,7 @@ If no label was applied: ```json { - "message": "No label was applied to this email." + "message": "No label was applied to this email." } ``` @@ -322,10 +322,10 @@ All endpoints use the standard error response format: ```json { - "status": "error", - "statusCode": 404, - "message": "The requested resource could not be found.", - "errors": null + "status": "error", + "statusCode": 404, + "message": "The requested resource could not be found.", + "errors": null } ``` @@ -333,28 +333,28 @@ For validation errors (`422 Unprocessable Entity`): ```json { - "status": "error", - "statusCode": 422, - "message": "Invalid input provided.", - "errors": [ - { - "field": "name", - "message": "Name is required." - }, - { - "field": "retentionPeriodDays", - "message": "Retention period must be at least 1 day." - } - ] + "status": "error", + "statusCode": 422, + "message": "Invalid input provided.", + "errors": [ + { + "field": "name", + "message": "Name is required." + }, + { + "field": "retentionPeriodDays", + "message": "Retention period must be at least 1 day." + } + ] } ``` ## Validation Constraints -| Field | Constraint | -| -------------------- | --------------------------------------------- | -| Label name | 1–255 characters, must be unique. | -| Description | Max 1000 characters. | -| Retention period | Positive integer (≥ 1 day). | -| Label ID (UUID) | Must be a valid UUID format. | -| Email ID (UUID) | Must be a valid UUID format. | \ No newline at end of file +| Field | Constraint | +| ---------------- | --------------------------------- | +| Label name | 1–255 characters, must be unique. | +| Description | Max 1000 characters. | +| Retention period | Positive integer (≥ 1 day). | +| Label ID (UUID) | Must be a valid UUID format. | +| Email ID (UUID) | Must be a valid UUID format. | diff --git a/docs/enterprise/retention-labels/automated-tagging.md b/docs/enterprise/retention-labels/automated-tagging.md index 4fefb615..7fbf416d 100644 --- a/docs/enterprise/retention-labels/automated-tagging.md +++ b/docs/enterprise/retention-labels/automated-tagging.md @@ -9,41 +9,51 @@ Automated retention label application allows external systems and services to pr ## Common Use Cases ### 1. Financial Document Classification + **Scenario**: Automatically identify and tag financial documents (invoices, receipts, payment confirmations) with extended retention periods for regulatory compliance. -**Implementation**: +**Implementation**: + - Monitor newly ingested emails for financial keywords in subject lines or attachment names - Apply "Financial Records" label (typically 7+ years retention) to matching emails - Use content analysis to identify financial document types ### 2. Legal and Compliance Tagging + **Scenario**: Apply legal hold labels to emails related to ongoing litigation or regulatory investigations. **Implementation**: -- Scan emails for legal-related keywords or specific case references + +- Scan emails for legal-related keywords or specific case references - Tag emails from/to legal departments with "Legal Hold" labels - Apply extended retention periods to preserve evidence ### 3. Executive Communication Preservation + **Scenario**: Ensure important communications involving executive leadership are retained beyond standard policies. **Implementation**: + - Identify emails from C-level executives (CEO, CFO, CTO, etc.) - Apply "Executive Communications" labels with extended retention - Preserve strategic business communications for historical reference ### 4. Data Classification Integration + **Scenario**: Integrate with existing data classification systems to apply retention labels based on content sensitivity. **Implementation**: + - Use AI/ML classification results to determine retention requirements - Apply labels like "Confidential", "Public", or "Restricted" with appropriate retention periods - Automate compliance with data protection regulations ### 5. Project-Based Retention + **Scenario**: Apply specific retention periods to emails related to particular projects or contracts. **Implementation**: + - Identify project-related emails using subject line patterns or participant lists - Tag with project-specific labels (e.g., "Project Alpha - 5 Year Retention") - Ensure project documentation meets contractual retention requirements @@ -51,29 +61,36 @@ Automated retention label application allows external systems and services to pr ## API Workflow ### Step 1: Authentication Setup + Create an API key with appropriate permissions: + - Navigate to **Dashboard → Admin → Roles/Users** - Create a user with `read:archive` and `delete:archive` permissions (minimum required) - Generate an API for the newly created user - Securely store the API key for use in automated systems ### Step 2: Identify Target Emails + Use the archived emails API to find emails that need labeling: **Get Recent Emails**: + ``` GET /api/v1/archived-emails?limit=100&sort=archivedAt:desc ``` **Search for Specific Emails**: + ``` GET /api/v1/archived-emails/search?query=subject:invoice&limit=50 ``` ### Step 3: Check Current Label Status + Before applying a new label, verify the email's current state: **Check Email Label**: + ``` GET /api/v1/enterprise/retention-policy/email/{emailId}/label ``` @@ -81,9 +98,11 @@ GET /api/v1/enterprise/retention-policy/email/{emailId}/label This returns `null` if no label is applied, or the current label information if one exists. ### Step 4: Apply Retention Label + Apply the appropriate label to the email: **Apply Label**: + ``` POST /api/v1/enterprise/retention-policy/email/{emailId}/label Content-Type: application/json @@ -94,11 +113,13 @@ Content-Type: application/json ``` ### Step 5: Verify Application + Confirm the label was successfully applied by checking the response or making another GET request. ## Label Management ### Getting Available Labels + List all available retention labels to identify which ones to use: ``` @@ -108,6 +129,7 @@ GET /api/v1/enterprise/retention-policy/labels This returns all labels with their IDs, names, retention periods, and status (enabled/disabled). ### Label Selection Strategy + - **Pre-create labels** through the UI with appropriate names and retention periods - **Map business rules** to specific label IDs in your automation logic - **Cache label information** to avoid repeated API calls @@ -116,6 +138,7 @@ This returns all labels with their IDs, names, retention periods, and status (en ## Implementation Patterns ### Pattern 1: Post-Ingestion Processing + Apply labels after emails have been fully ingested and indexed: 1. Monitor for newly ingested emails (via webhooks or polling) @@ -124,6 +147,7 @@ Apply labels after emails have been fully ingested and indexed: 4. Apply the label via API ### Pattern 2: Batch Processing + Process emails in scheduled batches: 1. Query for unlabeled emails periodically (daily/weekly) @@ -132,6 +156,7 @@ Process emails in scheduled batches: 4. Log results for audit and monitoring ### Pattern 3: Event-Driven Tagging + React to specific events or triggers: 1. Receive notification of specific events (legal hold notice, project start, etc.) @@ -142,13 +167,16 @@ React to specific events or triggers: ## Authentication and Security ### API Key Management + - **Use dedicated API keys** for automated systems (not user accounts) - **Assign minimal required permissions** (`delete:archive` for label application) - **Rotate API keys regularly** as part of security best practices - **Store keys securely** using environment variables or secret management systems ### Request Authentication + Include the API key in all requests: + ``` Authorization: Bearer your-api-key-here Content-Type: application/json @@ -157,12 +185,14 @@ Content-Type: application/json ## Error Handling ### Common Error Scenarios + - **404 Email Not Found**: The specified email ID doesn't exist - **404 Label Not Found**: The label ID is invalid or label has been deleted - **409 Conflict**: Attempting to apply a disabled label - **422 Validation Error**: Invalid request format or missing required fields ### Best Practices + - **Check response status codes** and handle errors appropriately - **Implement retry logic** for temporary failures (5xx errors) - **Log all operations** for audit trails and debugging @@ -171,11 +201,13 @@ Content-Type: application/json ## Performance Considerations ### Rate Limiting + - **Process emails in batches** rather than individually when possible - **Add delays between API calls** to avoid overwhelming the server - **Monitor API response times** and adjust batch sizes accordingly ### Efficiency Tips + - **Cache label information** to reduce API calls - **Check existing labels** before applying new ones to avoid unnecessary operations - **Use search API** to filter emails rather than processing all emails @@ -184,12 +216,15 @@ Content-Type: application/json ## Monitoring and Auditing ### Logging Recommendations + - **Log all label applications** with email ID, label ID, and timestamp - **Track success/failure rates** for monitoring system health - **Record business rule matches** for compliance reporting ### Audit Trail + All automated label applications are recorded in the system audit log with: + - Actor identified as the API key name - Target email and applied label details - Timestamp of the operation @@ -199,18 +234,21 @@ This ensures full traceability of automated retention decisions. ## Integration Examples ### Scenario: Invoice Processing System + 1. **Trigger**: New email arrives with invoice attachment 2. **Analysis**: System identifies invoice keywords or attachment types 3. **Action**: Apply "Financial Records - 7 Year" label via API 4. **Result**: Email retained for regulatory compliance period ### Scenario: Legal Hold Implementation + 1. **Trigger**: Legal department issues hold notice for specific matter 2. **Search**: Find all emails matching case criteria (participants, keywords, date range) 3. **Action**: Apply "Legal Hold - Matter XYZ" label to all matching emails 4. **Result**: All relevant emails preserved indefinitely ### Scenario: Data Classification Integration + 1. **Trigger**: Content classification system processes new emails 2. **Analysis**: ML system categorizes email as "Confidential Financial Data" 3. **Mapping**: Business rules map category to "Financial Confidential - 10 Year" label @@ -226,4 +264,4 @@ This ensures full traceability of automated retention decisions. 5. **Deploy your automation** with proper error handling and monitoring 6. **Monitor results** and adjust your classification rules as needed -This automated approach ensures consistent retention policy enforcement while reducing manual administrative overhead. \ No newline at end of file +This automated approach ensures consistent retention policy enforcement while reducing manual administrative overhead. diff --git a/docs/enterprise/retention-labels/guide.md b/docs/enterprise/retention-labels/guide.md index e57bc1c4..c04583d1 100644 --- a/docs/enterprise/retention-labels/guide.md +++ b/docs/enterprise/retention-labels/guide.md @@ -13,8 +13,8 @@ The main page displays a table of all retention labels with the following column - **Name:** The label name and its UUID displayed underneath for reference. If a description is provided, it appears below the name in smaller text. - **Retention Period:** The number of days emails with this label are retained, displayed as "X days". - **Status:** A badge indicating whether the label is: - - **Enabled** (green badge): The label can be applied to new emails - - **Disabled** (gray badge): The label cannot be applied to new emails but continues to govern already-labeled emails + - **Enabled** (green badge): The label can be applied to new emails + - **Disabled** (gray badge): The label cannot be applied to new emails but continues to govern already-labeled emails - **Created At:** The date the label was created, displayed in local date format. - **Actions:** Dropdown menu with Edit and Delete options for each label. @@ -81,12 +81,16 @@ Click the **Delete** option from the actions dropdown to open the deletion confi The system uses intelligent deletion logic: #### Hard Delete + If the label has **never been applied** to any emails: + - The label is permanently removed from the system - Success message: "Label deleted successfully" -#### Soft Disable +#### Soft Disable + If the label is **currently applied** to one or more emails: + - The label is marked as "Disabled" instead of being deleted - The label remains in the table with a "Disabled" status badge - Existing emails keep their retention schedule based on this label @@ -96,6 +100,7 @@ If the label is **currently applied** to one or more emails: ### Confirmation Dialog The deletion dialog shows: + - **Title**: "Delete Retention Label" - **Description**: Explains that this action cannot be undone and may disable the label if it's in use - **Cancel** button to abort the operation @@ -111,8 +116,8 @@ Retention labels can be applied to individual archived emails through the email 2. Look for the "Retention Label" section in the email metadata 3. If no label is applied, you'll see an "Apply Label" button (requires `delete:archive` permission) 4. If a label is already applied, you'll see: - - The current label name and retention period - - "Change Label" and "Remove Label" buttons + - The current label name and retention period + - "Change Label" and "Remove Label" buttons ### Label Application Process @@ -121,10 +126,10 @@ Retention labels can be applied to individual archived emails through the email 3. Select the desired label 4. Confirm the application 5. The system: - - Removes any existing label from the email - - Applies the new label - - Records the action in the audit log - - Updates the email's retention schedule + - Removes any existing label from the email + - Applies the new label + - Records the action in the audit log + - Updates the email's retention schedule ### One Label Per Email Rule @@ -135,21 +140,25 @@ Each email can have at most one retention label. When you apply a new label to a Different operations require different permission levels: ### Label Management + - **Create, Edit, Delete Labels**: Requires `manage:all` permission - **View Labels Table**: Requires `manage:all` permission -### Email Label Operations +### Email Label Operations + - **View Email Labels**: Requires `read:archive` permission - **Apply/Remove Email Labels**: Requires `delete:archive` permission ## Status Indicators ### Enabled Labels (Green Badge) + - Can be applied to new emails - Appears in label selection dropdowns - Fully functional for all operations ### Disabled Labels (Gray Badge) + - Cannot be applied to new emails - Does not appear in label selection dropdowns - Continues to govern retention for already-labeled emails @@ -159,24 +168,28 @@ Different operations require different permission levels: ## Best Practices ### Naming Conventions + - Use descriptive names that indicate purpose: "Legal Hold - Case XYZ", "Executive - Q4 Review" - Include time periods or case references where relevant - Maintain consistent naming patterns across your organization ### Descriptions + - Always provide descriptions for complex or specialized labels - Include the business reason or legal requirement driving the retention period - Reference specific regulations, policies, or legal matters where applicable ### Retention Periods + - Consider your organization's legal and regulatory requirements - Common periods: - - **3 years (1095 days)**: Standard business records - - **7 years (2555 days)**: Financial and tax records - - **10 years (3650 days)**: Legal holds and critical business documents - - **Permanent retention**: Use very large numbers (e.g., 36500 days = 100 years) + - **3 years (1095 days)**: Standard business records + - **7 years (2555 days)**: Financial and tax records + - **10 years (3650 days)**: Legal holds and critical business documents + - **Permanent retention**: Use very large numbers (e.g., 36500 days = 100 years) ### Label Lifecycle + - Review labels periodically to identify unused or obsolete labels - Disabled labels can accumulate over time - consider cleanup procedures - Document the purpose and expected lifecycle of each label for future administrators @@ -184,23 +197,28 @@ Different operations require different permission levels: ## Troubleshooting ### Cannot Edit Retention Period + **Problem**: Edit dialog shows retention period as locked or returns conflict error **Cause**: The label is currently applied to one or more emails **Solution**: Create a new label with the desired retention period instead of modifying the existing one ### Label Not Appearing in Email Application Dropdown + **Problem**: A label doesn't show up when trying to apply it to an email **Cause**: The label is disabled **Solution**: Check the labels table - disabled labels show a gray "Disabled" badge ### Cannot Delete Label + **Problem**: Deletion results in label being disabled instead of removed **Cause**: The label is currently applied to emails **Solution**: This is expected behavior to preserve retention integrity. The label can only be hard-deleted if it has never been used. ### Permission Denied Errors + **Problem**: Cannot access label management or apply labels to emails **Cause**: Insufficient permissions **Solution**: Contact your system administrator to verify you have the required permissions: + - `manage:all` for label management -- `delete:archive` for email label operations \ No newline at end of file +- `delete:archive` for email label operations diff --git a/docs/enterprise/retention-labels/index.md b/docs/enterprise/retention-labels/index.md index 125dbaa7..9d2223fe 100644 --- a/docs/enterprise/retention-labels/index.md +++ b/docs/enterprise/retention-labels/index.md @@ -62,37 +62,37 @@ Apply custom retention periods to emails related to specific projects, contracts The feature is composed of the following components: -| Component | Location | Description | -| -------------------- | --------------------------------------------------------------------- | ------------------------------------------------------------ | -| Types | `packages/types/src/retention.types.ts` | Shared TypeScript types for labels and email label info. | -| Database Schema | `packages/backend/src/database/schema/compliance.ts` | Drizzle ORM table definitions for retention labels. | -| Label Service | `packages/enterprise/src/modules/retention-policy/RetentionLabelService.ts` | CRUD operations and label application logic. | -| API Controller | `packages/enterprise/src/modules/retention-policy/retention-label.controller.ts` | Express request handlers with Zod validation. | -| API Routes | `packages/enterprise/src/modules/retention-policy/retention-policy.routes.ts` | Route registration with auth and feature guards. | -| Frontend Page | `packages/frontend/src/routes/dashboard/compliance/retention-labels/` | SvelteKit page for label management. | -| Email Integration | Individual archived email pages | Label application UI in email detail views. | +| Component | Location | Description | +| ----------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------- | +| Types | `packages/types/src/retention.types.ts` | Shared TypeScript types for labels and email label info. | +| Database Schema | `packages/backend/src/database/schema/compliance.ts` | Drizzle ORM table definitions for retention labels. | +| Label Service | `packages/enterprise/src/modules/retention-policy/RetentionLabelService.ts` | CRUD operations and label application logic. | +| API Controller | `packages/enterprise/src/modules/retention-policy/retention-label.controller.ts` | Express request handlers with Zod validation. | +| API Routes | `packages/enterprise/src/modules/retention-policy/retention-policy.routes.ts` | Route registration with auth and feature guards. | +| Frontend Page | `packages/frontend/src/routes/dashboard/compliance/retention-labels/` | SvelteKit page for label management. | +| Email Integration | Individual archived email pages | Label application UI in email detail views. | ## Data Model ### Retention Labels Table -| Column | Type | Description | -| -------------------- | ------------- | --------------------------------------------------------------- | -| `id` | `uuid` (PK) | Auto-generated unique identifier. | -| `name` | `varchar(255)` | Human-readable label name (unique constraint). | -| `retention_period_days` | `integer` | Number of days to retain emails with this label. | -| `description` | `text` | Optional description of the label's purpose. | -| `is_disabled` | `boolean` | Whether the label is disabled (cannot be applied to new emails). | -| `created_at` | `timestamptz` | Creation timestamp. | +| Column | Type | Description | +| ----------------------- | -------------- | ---------------------------------------------------------------- | +| `id` | `uuid` (PK) | Auto-generated unique identifier. | +| `name` | `varchar(255)` | Human-readable label name (unique constraint). | +| `retention_period_days` | `integer` | Number of days to retain emails with this label. | +| `description` | `text` | Optional description of the label's purpose. | +| `is_disabled` | `boolean` | Whether the label is disabled (cannot be applied to new emails). | +| `created_at` | `timestamptz` | Creation timestamp. | ### Email Label Applications Table -| Column | Type | Description | -| -------------------- | ------------- | --------------------------------------------------------------- | -| `email_id` | `uuid` (FK) | Reference to the archived email. | -| `label_id` | `uuid` (FK) | Reference to the retention label. | -| `applied_at` | `timestamptz` | Timestamp when the label was applied. | -| `applied_by_user_id` | `uuid` (FK) | User who applied the label (nullable for API key operations). | +| Column | Type | Description | +| -------------------- | ------------- | ------------------------------------------------------------- | +| `email_id` | `uuid` (FK) | Reference to the archived email. | +| `label_id` | `uuid` (FK) | Reference to the retention label. | +| `applied_at` | `timestamptz` | Timestamp when the label was applied. | +| `applied_by_user_id` | `uuid` (FK) | User who applied the label (nullable for API key operations). | The table uses a composite primary key of `(email_id, label_id)` to enforce the one-label-per-email constraint at the database level. @@ -107,11 +107,11 @@ The lifecycle worker queries the `email_retention_labels` table during email eva All retention label operations generate audit log entries: - **Label Creation**: Action type `CREATE`, target type `RetentionLabel` -- **Label Updates**: Action type `UPDATE`, target type `RetentionLabel` +- **Label Updates**: Action type `UPDATE`, target type `RetentionLabel` - **Label Deletion/Disabling**: Action type `DELETE` or `UPDATE`, target type `RetentionLabel` - **Label Application**: Action type `UPDATE`, target type `ArchivedEmail`, details include label information - **Label Removal**: Action type `UPDATE`, target type `ArchivedEmail`, details include removed label information ### Email Detail Pages -Individual archived email pages display any applied retention label and provide controls for users with appropriate permissions to apply or remove labels. \ No newline at end of file +Individual archived email pages display any applied retention label and provide controls for users with appropriate permissions to apply or remove labels. diff --git a/docs/enterprise/retention-policy/api.md b/docs/enterprise/retention-policy/api.md index 32a18ba1..48848546 100644 --- a/docs/enterprise/retention-policy/api.md +++ b/docs/enterprise/retention-policy/api.md @@ -21,18 +21,18 @@ Retrieves all retention policies, ordered by priority ascending. ```json [ - { - "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", - "name": "Default 7-Year Retention", - "description": "Retain all emails for 7 years per regulatory requirements.", - "priority": 1, - "conditions": null, - "ingestionScope": null, - "retentionPeriodDays": 2555, - "isActive": true, - "createdAt": "2025-10-01T00:00:00.000Z", - "updatedAt": "2025-10-01T00:00:00.000Z" - } + { + "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "name": "Default 7-Year Retention", + "description": "Retain all emails for 7 years per regulatory requirements.", + "priority": 1, + "conditions": null, + "ingestionScope": null, + "retentionPeriodDays": 2555, + "isActive": true, + "createdAt": "2025-10-01T00:00:00.000Z", + "updatedAt": "2025-10-01T00:00:00.000Z" + } ] ``` @@ -70,34 +70,34 @@ Creates a new retention policy. The policy name must be unique across the system ### Request Body -| Field | Type | Required | Description | -| ------------------- | --------------------- | -------- | ---------------------------------------------------------------------------------------------- | -| `name` | `string` | Yes | Unique policy name. Max 255 characters. | -| `description` | `string` | No | Human-readable description. Max 1000 characters. | -| `priority` | `integer` | Yes | Positive integer. Lower values indicate higher priority. | -| `retentionPeriodDays` | `integer` | Yes | Number of days to retain matching emails. Minimum 1. | -| `actionOnExpiry` | `string` | Yes | Action to take when the retention period expires. Currently only `"delete_permanently"`. | -| `isEnabled` | `boolean` | No | Whether the policy is active. Defaults to `true`. | -| `conditions` | `RuleGroup \| null` | No | Condition rules for targeting specific emails. `null` matches all emails. | -| `ingestionScope` | `string[] \| null` | No | Array of ingestion source UUIDs to scope the policy to. `null` applies to all sources. | +| Field | Type | Required | Description | +| --------------------- | ------------------- | -------- | ---------------------------------------------------------------------------------------- | +| `name` | `string` | Yes | Unique policy name. Max 255 characters. | +| `description` | `string` | No | Human-readable description. Max 1000 characters. | +| `priority` | `integer` | Yes | Positive integer. Lower values indicate higher priority. | +| `retentionPeriodDays` | `integer` | Yes | Number of days to retain matching emails. Minimum 1. | +| `actionOnExpiry` | `string` | Yes | Action to take when the retention period expires. Currently only `"delete_permanently"`. | +| `isEnabled` | `boolean` | No | Whether the policy is active. Defaults to `true`. | +| `conditions` | `RuleGroup \| null` | No | Condition rules for targeting specific emails. `null` matches all emails. | +| `ingestionScope` | `string[] \| null` | No | Array of ingestion source UUIDs to scope the policy to. `null` applies to all sources. | #### Conditions (RuleGroup) Schema ```json { - "logicalOperator": "AND", - "rules": [ - { - "field": "sender", - "operator": "domain_match", - "value": "example.com" - }, - { - "field": "subject", - "operator": "contains", - "value": "invoice" - } - ] + "logicalOperator": "AND", + "rules": [ + { + "field": "sender", + "operator": "domain_match", + "value": "example.com" + }, + { + "field": "subject", + "operator": "contains", + "value": "invoice" + } + ] } ``` @@ -105,18 +105,19 @@ Creates a new retention policy. The policy name must be unique across the system **Supported operators:** -| Operator | Description | -| -------------- | ------------------------------------------------------------------ | -| `equals` | Exact case-insensitive match. | -| `not_equals` | Inverse of `equals`. | -| `contains` | Case-insensitive substring match. | -| `not_contains` | Inverse of `contains`. | -| `starts_with` | Case-insensitive prefix match. | -| `ends_with` | Case-insensitive suffix match. | -| `domain_match` | Matches when an email address ends with `@`. | -| `regex_match` | ECMAScript regex (case-insensitive). Max pattern length: 200 chars.| +| Operator | Description | +| -------------- | ------------------------------------------------------------------- | +| `equals` | Exact case-insensitive match. | +| `not_equals` | Inverse of `equals`. | +| `contains` | Case-insensitive substring match. | +| `not_contains` | Inverse of `contains`. | +| `starts_with` | Case-insensitive prefix match. | +| `ends_with` | Case-insensitive suffix match. | +| `domain_match` | Matches when an email address ends with `@`. | +| `regex_match` | ECMAScript regex (case-insensitive). Max pattern length: 200 chars. | **Validation limits:** + - Maximum 50 rules per group. - Rule `value` must be between 1 and 500 characters. @@ -124,27 +125,27 @@ Creates a new retention policy. The policy name must be unique across the system ```json { - "name": "Finance Department - 10 Year", - "description": "Extended retention for finance-related correspondence.", - "priority": 2, - "retentionPeriodDays": 3650, - "actionOnExpiry": "delete_permanently", - "conditions": { - "logicalOperator": "OR", - "rules": [ - { - "field": "sender", - "operator": "domain_match", - "value": "finance.acme.com" - }, - { - "field": "recipient", - "operator": "domain_match", - "value": "finance.acme.com" - } - ] - }, - "ingestionScope": ["b2c3d4e5-f6a7-8901-bcde-f23456789012"] + "name": "Finance Department - 10 Year", + "description": "Extended retention for finance-related correspondence.", + "priority": 2, + "retentionPeriodDays": 3650, + "actionOnExpiry": "delete_permanently", + "conditions": { + "logicalOperator": "OR", + "rules": [ + { + "field": "sender", + "operator": "domain_match", + "value": "finance.acme.com" + }, + { + "field": "recipient", + "operator": "domain_match", + "value": "finance.acme.com" + } + ] + }, + "ingestionScope": ["b2c3d4e5-f6a7-8901-bcde-f23456789012"] } ``` @@ -220,25 +221,25 @@ Evaluates a set of email metadata against all active policies and returns the ap ### Request Body -| Field | Type | Required | Description | -| ---------------------------------- | ---------- | -------- | -------------------------------------------------------- | -| `emailMetadata.sender` | `string` | Yes | Sender email address. Max 500 characters. | -| `emailMetadata.recipients` | `string[]` | Yes | Recipient email addresses. Max 500 entries. | -| `emailMetadata.subject` | `string` | Yes | Email subject line. Max 2000 characters. | -| `emailMetadata.attachmentTypes` | `string[]` | Yes | File extensions (e.g., `[".pdf", ".xml"]`). Max 100. | -| `emailMetadata.ingestionSourceId` | `uuid` | No | Optional ingestion source UUID for scope-aware evaluation.| +| Field | Type | Required | Description | +| --------------------------------- | ---------- | -------- | ---------------------------------------------------------- | +| `emailMetadata.sender` | `string` | Yes | Sender email address. Max 500 characters. | +| `emailMetadata.recipients` | `string[]` | Yes | Recipient email addresses. Max 500 entries. | +| `emailMetadata.subject` | `string` | Yes | Email subject line. Max 2000 characters. | +| `emailMetadata.attachmentTypes` | `string[]` | Yes | File extensions (e.g., `[".pdf", ".xml"]`). Max 100. | +| `emailMetadata.ingestionSourceId` | `uuid` | No | Optional ingestion source UUID for scope-aware evaluation. | ### Example Request ```json { - "emailMetadata": { - "sender": "cfo@finance.acme.com", - "recipients": ["legal@acme.com"], - "subject": "Q4 Invoice Reconciliation", - "attachmentTypes": [".pdf", ".xlsx"], - "ingestionSourceId": "b2c3d4e5-f6a7-8901-bcde-f23456789012" - } + "emailMetadata": { + "sender": "cfo@finance.acme.com", + "recipients": ["legal@acme.com"], + "subject": "Q4 Invoice Reconciliation", + "attachmentTypes": [".pdf", ".xlsx"], + "ingestionSourceId": "b2c3d4e5-f6a7-8901-bcde-f23456789012" + } } ``` @@ -246,12 +247,12 @@ Evaluates a set of email metadata against all active policies and returns the ap ```json { - "appliedRetentionDays": 3650, - "actionOnExpiry": "delete_permanently", - "matchingPolicyIds": [ - "a1b2c3d4-e5f6-7890-abcd-ef1234567890", - "c3d4e5f6-a7b8-9012-cdef-345678901234" - ] + "appliedRetentionDays": 3650, + "actionOnExpiry": "delete_permanently", + "matchingPolicyIds": [ + "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "c3d4e5f6-a7b8-9012-cdef-345678901234" + ] } ``` diff --git a/docs/enterprise/retention-policy/guide.md b/docs/enterprise/retention-policy/guide.md index 15e90370..efad6b1e 100644 --- a/docs/enterprise/retention-policy/guide.md +++ b/docs/enterprise/retention-policy/guide.md @@ -47,16 +47,16 @@ Conditions define which emails the policy targets. If no conditions are added, t ### Supported Operators -| Operator | Display Name | Description | -| -------------- | ------------- | ----------------------------------------------------------- | -| `equals` | Equals | Exact case-insensitive match. | -| `not_equals` | Not Equals | Inverse of equals. | -| `contains` | Contains | Case-insensitive substring match. | -| `not_contains` | Not Contains | Inverse of contains. | -| `starts_with` | Starts With | Case-insensitive prefix match. | -| `ends_with` | Ends With | Case-insensitive suffix match. | -| `domain_match` | Domain Match | Matches when an email address ends with `@`. | -| `regex_match` | Regex Match | ECMAScript regular expression (case-insensitive, max 200 chars). | +| Operator | Display Name | Description | +| -------------- | ------------ | ---------------------------------------------------------------- | +| `equals` | Equals | Exact case-insensitive match. | +| `not_equals` | Not Equals | Inverse of equals. | +| `contains` | Contains | Case-insensitive substring match. | +| `not_contains` | Not Contains | Inverse of contains. | +| `starts_with` | Starts With | Case-insensitive prefix match. | +| `ends_with` | Ends With | Case-insensitive suffix match. | +| `domain_match` | Domain Match | Matches when an email address ends with `@`. | +| `regex_match` | Regex Match | ECMAScript regular expression (case-insensitive, max 200 chars). | ### Policy Status diff --git a/docs/enterprise/retention-policy/index.md b/docs/enterprise/retention-policy/index.md index 6c12cfa4..971572ac 100644 --- a/docs/enterprise/retention-policy/index.md +++ b/docs/enterprise/retention-policy/index.md @@ -43,13 +43,13 @@ The Retention Policy Engine requires: The feature is composed of the following components: -| Component | Location | Description | -| -------------------- | --------------------------------------------------------------------- | ------------------------------------------------------------ | -| Types | `packages/types/src/retention.types.ts` | Shared TypeScript types for policies, rules, and evaluation. | -| Database Schema | `packages/backend/src/database/schema/compliance.ts` | Drizzle ORM table definition for `retention_policies`. | -| Retention Service | `packages/enterprise/src/modules/retention-policy/RetentionService.ts`| CRUD operations and the evaluation engine. | -| API Controller | `packages/enterprise/src/modules/retention-policy/retention-policy.controller.ts` | Express request handlers with Zod validation. | -| API Routes | `packages/enterprise/src/modules/retention-policy/retention-policy.routes.ts` | Route registration with auth and feature guards. | -| Module | `packages/enterprise/src/modules/retention-policy/retention-policy.module.ts` | Enterprise module bootstrap. | -| Lifecycle Worker | `packages/enterprise/src/workers/lifecycle.worker.ts` | BullMQ worker for automated retention enforcement. | -| Frontend Page | `packages/frontend/src/routes/dashboard/compliance/retention-policies/` | SvelteKit page for policy management and simulation. | +| Component | Location | Description | +| ----------------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------ | +| Types | `packages/types/src/retention.types.ts` | Shared TypeScript types for policies, rules, and evaluation. | +| Database Schema | `packages/backend/src/database/schema/compliance.ts` | Drizzle ORM table definition for `retention_policies`. | +| Retention Service | `packages/enterprise/src/modules/retention-policy/RetentionService.ts` | CRUD operations and the evaluation engine. | +| API Controller | `packages/enterprise/src/modules/retention-policy/retention-policy.controller.ts` | Express request handlers with Zod validation. | +| API Routes | `packages/enterprise/src/modules/retention-policy/retention-policy.routes.ts` | Route registration with auth and feature guards. | +| Module | `packages/enterprise/src/modules/retention-policy/retention-policy.module.ts` | Enterprise module bootstrap. | +| Lifecycle Worker | `packages/enterprise/src/workers/lifecycle.worker.ts` | BullMQ worker for automated retention enforcement. | +| Frontend Page | `packages/frontend/src/routes/dashboard/compliance/retention-policies/` | SvelteKit page for policy management and simulation. | diff --git a/docs/enterprise/retention-policy/lifecycle-worker.md b/docs/enterprise/retention-policy/lifecycle-worker.md index a05efb43..7dc9b1fc 100644 --- a/docs/enterprise/retention-policy/lifecycle-worker.md +++ b/docs/enterprise/retention-policy/lifecycle-worker.md @@ -13,7 +13,9 @@ The lifecycle worker is the automated enforcement component of the retention pol The lifecycle worker is registered as a repeatable BullMQ cron job on the `compliance-lifecycle` queue. It is scheduled to run daily at **02:00 UTC** by default. The cron schedule is configured via: ```typescript -repeat: { pattern: '0 2 * * *' } // daily at 02:00 UTC +repeat: { + pattern: '0 2 * * *'; +} // daily at 02:00 UTC ``` The `scheduleLifecycleJob()` function is called once during enterprise application startup to register the repeatable job with BullMQ. @@ -62,12 +64,12 @@ If the entire job fails, BullMQ records the failure and the job ID and error are Automated deletions are attributed to a synthetic system actor in the audit log: -| Field | Value | -| ------------ | ------------------------------------ | -| ID | `system:lifecycle-worker` | -| Email | `system@open-archiver.internal` | -| Name | System Lifecycle Worker | -| Actor IP | `system` | +| Field | Value | +| -------- | ------------------------------- | +| ID | `system:lifecycle-worker` | +| Email | `system@open-archiver.internal` | +| Name | System Lifecycle Worker | +| Actor IP | `system` | This well-known identifier can be filtered in the [Audit Log](../audit-log/index.md) to view all retention-based deletions. @@ -85,18 +87,18 @@ This ensures that every automated deletion is fully traceable back to the specif ## Configuration -| Environment Variable | Description | Default | -| ------------------------- | ---------------------------------------------------- | ------- | -| `RETENTION_BATCH_SIZE` | Number of emails to process per batch iteration. | — | +| Environment Variable | Description | Default | +| ---------------------- | ------------------------------------------------ | ------- | +| `RETENTION_BATCH_SIZE` | Number of emails to process per batch iteration. | — | ## BullMQ Worker Settings -| Setting | Value | Description | -| -------------------- | ---------------------- | -------------------------------------------------- | -| Queue name | `compliance-lifecycle` | The BullMQ queue name. | -| Job ID | `lifecycle-daily` | Stable job ID for the repeatable cron job. | -| `removeOnComplete` | Keep last 10 | Completed jobs retained for monitoring. | -| `removeOnFail` | Keep last 50 | Failed jobs retained for debugging. | +| Setting | Value | Description | +| ------------------ | ---------------------- | ------------------------------------------ | +| Queue name | `compliance-lifecycle` | The BullMQ queue name. | +| Job ID | `lifecycle-daily` | Stable job ID for the repeatable cron job. | +| `removeOnComplete` | Keep last 10 | Completed jobs retained for monitoring. | +| `removeOnFail` | Keep last 50 | Failed jobs retained for debugging. | ## Integration with Deletion Guard diff --git a/docs/enterprise/retention-policy/retention-service.md b/docs/enterprise/retention-policy/retention-service.md index 42139a40..a6c9cd12 100644 --- a/docs/enterprise/retention-policy/retention-service.md +++ b/docs/enterprise/retention-policy/retention-service.md @@ -6,19 +6,19 @@ The backend implementation of the retention policy engine is handled by the `Ret The `retention_policies` table is defined in `packages/backend/src/database/schema/compliance.ts` using Drizzle ORM: -| Column | Type | Description | -| --------------------- | -------------------------- | --------------------------------------------------------------------------- | -| `id` | `uuid` (PK) | Auto-generated unique identifier. | -| `name` | `text` (unique, not null) | Human-readable policy name. | -| `description` | `text` | Optional description. | -| `priority` | `integer` (not null) | Priority for ordering. Lower = higher priority. | -| `retention_period_days` | `integer` (not null) | Number of days to retain matching emails. | -| `action_on_expiry` | `enum` (not null) | Action on expiry (`delete_permanently`). | -| `is_enabled` | `boolean` (default: true) | Whether the policy is active. | -| `conditions` | `jsonb` | Serialized `RetentionRuleGroup` or null (null = matches all). | -| `ingestion_scope` | `jsonb` | Array of ingestion source UUIDs or null (null = all sources). | -| `created_at` | `timestamptz` | Creation timestamp. | -| `updated_at` | `timestamptz` | Last update timestamp. | +| Column | Type | Description | +| ----------------------- | ------------------------- | ------------------------------------------------------------- | +| `id` | `uuid` (PK) | Auto-generated unique identifier. | +| `name` | `text` (unique, not null) | Human-readable policy name. | +| `description` | `text` | Optional description. | +| `priority` | `integer` (not null) | Priority for ordering. Lower = higher priority. | +| `retention_period_days` | `integer` (not null) | Number of days to retain matching emails. | +| `action_on_expiry` | `enum` (not null) | Action on expiry (`delete_permanently`). | +| `is_enabled` | `boolean` (default: true) | Whether the policy is active. | +| `conditions` | `jsonb` | Serialized `RetentionRuleGroup` or null (null = matches all). | +| `ingestion_scope` | `jsonb` | Array of ingestion source UUIDs or null (null = all sources). | +| `created_at` | `timestamptz` | Creation timestamp. | +| `updated_at` | `timestamptz` | Last update timestamp. | ## CRUD Operations @@ -53,6 +53,7 @@ The evaluation engine is the core logic that determines which policies apply to ### `evaluateEmail(metadata)` This is the primary evaluation method. It accepts email metadata and returns: + - `appliedRetentionDays`: The longest matching retention period (max-duration-wins). - `matchingPolicyIds`: UUIDs of all policies that matched. - `actionOnExpiry`: Always `"delete_permanently"` in the current implementation. @@ -68,6 +69,7 @@ The evaluation flow: ### `_evaluateRuleGroup(group, metadata)` Evaluates a `RetentionRuleGroup` using AND or OR logic: + - **AND:** Every rule in the group must pass. - **OR:** At least one rule must pass. - An empty rules array evaluates to `true`. @@ -76,27 +78,27 @@ Evaluates a `RetentionRuleGroup` using AND or OR logic: Evaluates a single rule against the email metadata. All string comparisons are case-insensitive (both sides are lowercased before comparison). The behavior depends on the field: -| Field | Behavior | -| ----------------- | ------------------------------------------------------------------------ | -| `sender` | Compares against the sender email address. | -| `recipient` | Passes if **any** recipient matches the operator. | -| `subject` | Compares against the email subject. | -| `attachment_type` | Passes if **any** attachment file extension matches (e.g., `.pdf`). | +| Field | Behavior | +| ----------------- | ------------------------------------------------------------------- | +| `sender` | Compares against the sender email address. | +| `recipient` | Passes if **any** recipient matches the operator. | +| `subject` | Compares against the email subject. | +| `attachment_type` | Passes if **any** attachment file extension matches (e.g., `.pdf`). | ### `_applyOperator(haystack, operator, needle)` Applies a string-comparison operator between two pre-lowercased strings: -| Operator | Implementation | -| -------------- | ----------------------------------------------------------------------------- | -| `equals` | `haystack === needle` | -| `not_equals` | `haystack !== needle` | -| `contains` | `haystack.includes(needle)` | -| `not_contains` | `!haystack.includes(needle)` | -| `starts_with` | `haystack.startsWith(needle)` | -| `ends_with` | `haystack.endsWith(needle)` | -| `domain_match` | `haystack.endsWith('@' + needle)` (auto-prepends `@` if missing) | -| `regex_match` | `new RegExp(needle, 'i').test(haystack)` with safety guards (see below) | +| Operator | Implementation | +| -------------- | ----------------------------------------------------------------------- | +| `equals` | `haystack === needle` | +| `not_equals` | `haystack !== needle` | +| `contains` | `haystack.includes(needle)` | +| `not_contains` | `!haystack.includes(needle)` | +| `starts_with` | `haystack.startsWith(needle)` | +| `ends_with` | `haystack.endsWith(needle)` | +| `domain_match` | `haystack.endsWith('@' + needle)` (auto-prepends `@` if missing) | +| `regex_match` | `new RegExp(needle, 'i').test(haystack)` with safety guards (see below) | ### Security: `regex_match` Safeguards @@ -133,6 +135,7 @@ The `RetentionPolicyModule` (`retention-policy.module.ts`) implements the `Archi ``` All routes are protected by: + 1. `requireAuth` — Ensures the request includes a valid authentication token. 2. `featureEnabled(OpenArchiverFeature.RETENTION_POLICY)` — Ensures the enterprise license includes the retention policy feature. 3. `requirePermission('manage', 'all')` — Ensures the user has administrative permissions. diff --git a/docs/index.md b/docs/index.md index cd8f41cf..8d6f02ba 100644 --- a/docs/index.md +++ b/docs/index.md @@ -2,13 +2,13 @@ Welcome to Open Archiver! This guide will help you get started with setting up and using the platform. -## What is Open Archiver? 🛡️ +## What is Open Archiver? **A secure, sovereign, and affordable open-source platform for email archiving and eDiscovery.** Open Archiver provides a robust, self-hosted solution for archiving, storing, indexing, and searching emails from major platforms, including Google Workspace (Gmail), Microsoft 365, as well as generic IMAP-enabled email inboxes. Use Open Archiver to keep a permanent, tamper-proof record of your communication history, free from vendor lock-in. -## Key Features ✨ +## Key Features - **Universal Ingestion**: Connect to Google Workspace, Microsoft 365, and standard IMAP servers to perform initial bulk imports and maintain continuous, real-time synchronization. - **Secure & Efficient Storage**: Emails are stored in the standard `.eml` format. The system uses deduplication and compression to minimize storage costs. All data is encrypted at rest. @@ -17,7 +17,7 @@ Open Archiver provides a robust, self-hosted solution for archiving, storing, in - **Compliance & Retention**: Define granular retention policies to automatically manage the lifecycle of your data. Place legal holds on communications to prevent deletion during litigation (TBD). - **Comprehensive Auditing**: An immutable audit trail logs all system activities, ensuring you have a clear record of who accessed what and when (TBD). -## Installation 🚀 +## Installation To get your own instance of Open Archiver running, follow our detailed installation guide: @@ -31,7 +31,7 @@ After deploying the application, you will need to configure one or more ingestio - [Connecting to Microsoft 365](./user-guides/email-providers/microsoft-365.md) - [Connecting to a Generic IMAP Server](./user-guides/email-providers/imap.md) -## Contributing ❤️ +## Contributing We welcome contributions from the community! diff --git a/docs/services/job-queue.md b/docs/services/job-queue.md new file mode 100644 index 00000000..8a2f9db9 --- /dev/null +++ b/docs/services/job-queue.md @@ -0,0 +1,81 @@ +# Job Queue Service + +This document describes the architecture of the job queue system, including the sync cycle coordination mechanism and relevant configuration options. + +## Architecture + +The job queue system is built on [BullMQ](https://docs.bullmq.io/) backed by Redis (Valkey). Two worker processes run independently: + +- **Ingestion worker** (`ingestion.worker.ts`) — processes the `ingestion` queue +- **Indexing worker** (`indexing.worker.ts`) — processes the `indexing` queue + +### Queues + +| Queue | Jobs | Purpose | +| ----------- | --------------------------------------------------------------------------------------------------------- | -------------------------------------- | +| `ingestion` | `schedule-continuous-sync`, `continuous-sync`, `initial-import`, `process-mailbox`, `sync-cycle-finished` | Email ingestion and sync orchestration | +| `indexing` | `index-email-batch` | Meilisearch document indexing | + +### Job Flow + +``` +[schedule-continuous-sync] (repeating cron) + └→ [continuous-sync] (per ingestion source) + └→ [process-mailbox] × N (one per user mailbox) + └→ [index-email-batch] (batched, on indexing queue) + └→ [sync-cycle-finished] (dispatched by the last mailbox job) +``` + +For initial imports, `initial-import` triggers the same `process-mailbox` → `sync-cycle-finished` flow. + +## Sync Cycle Coordination + +Sync cycle completion (knowing when all mailboxes in a sync have finished) is coordinated via the `sync_sessions` PostgreSQL table rather than BullMQ's built-in flow/parent-child system. + +**Why:** BullMQ's `FlowProducer` stores the entire parent/child relationship in Redis atomically. For large tenants with thousands of mailboxes, this creates large Redis writes and requires loading all child job return values into memory at once for aggregation. + +**How it works:** + +1. When `initial-import` or `continuous-sync` starts, it creates a `sync_sessions` row with `total_mailboxes = N`. +2. Each `process-mailbox` job atomically increments `completed_mailboxes` or `failed_mailboxes` when it finishes, and merges its `SyncState` into `ingestion_sources.sync_state` using PostgreSQL's `||` jsonb operator. +3. The job that brings `completed + failed` to equal `total` dispatches the `sync-cycle-finished` job. +4. `sync-cycle-finished` reads the aggregated results from the session row and finalizes the source status. +5. The session row is deleted after finalization. + +### Session Heartbeat + +Each `process-mailbox` job updates `last_activity_at` on the session every time it flushes an email batch to the indexing queue. This prevents the stale session detector from treating an actively processing large mailbox as stuck. + +### Stale Session Detection + +The `schedule-continuous-sync` job runs `SyncSessionService.cleanStaleSessions()` on every tick. A session is considered stale when `last_activity_at` has not been updated for 30 minutes, indicating the worker that created it has crashed before all mailbox jobs were enqueued. + +When a stale session is detected: + +1. The associated ingestion source is set to `status: 'error'` with a descriptive message. +2. The session row is deleted. +3. On the next scheduler tick, the source is picked up as an `error` source and a new `continuous-sync` job is dispatched. + +Already-ingested emails from the partial sync are preserved. The next sync skips them via duplicate detection (`checkDuplicate()`). + +## Configuration + +| Environment Variable | Default | Description | +| ------------------------------ | ----------- | ----------------------------------------------------- | +| `SYNC_FREQUENCY` | `* * * * *` | Cron pattern for continuous sync scheduling | +| `INGESTION_WORKER_CONCURRENCY` | `5` | Number of `process-mailbox` jobs that run in parallel | +| `MEILI_INDEXING_BATCH` | `500` | Number of emails per `index-email-batch` job | + +### Tuning `INGESTION_WORKER_CONCURRENCY` + +Each `process-mailbox` job holds at most one parsed email in memory at a time during the ingestion loop. At typical email sizes (~50KB average), memory pressure per concurrent job is low. Increase this value on servers with more RAM to process multiple mailboxes in parallel and reduce total sync time. + +### Tuning `MEILI_INDEXING_BATCH` + +Each `index-email-batch` job loads the `.eml` file and all attachments from storage into memory for text extraction before sending to Meilisearch. Reduce this value if the indexing worker experiences memory pressure on deployments with large attachments. + +## Resilience + +- **Job retries:** All jobs are configured with 5 retry attempts using exponential backoff (starting at 1 second). This handles transient API failures from email providers. +- **Worker crash recovery:** BullMQ detects stalled jobs (no heartbeat within `lockDuration`) and re-queues them automatically. On retry, already-processed emails are skipped via `checkDuplicate()`. +- **Partial sync recovery:** Stale session detection handles the case where a worker crashes mid-dispatch, leaving some mailboxes never enqueued. The source is reset to `error` and the next scheduler tick retries the full sync. diff --git a/docs/user-guides/email-providers/eml.md b/docs/user-guides/email-providers/eml.md index 16fb7c3d..f6e1b209 100644 --- a/docs/user-guides/email-providers/eml.md +++ b/docs/user-guides/email-providers/eml.md @@ -31,12 +31,13 @@ archive.zip 3. Select **EML Import** as the provider. 4. Enter a name for the ingestion source. 5. **Choose Import Method:** - * **Upload File:** Click **Choose File** and select the zip archive containing your EML files. (Best for smaller archives) - * **Local Path:** Enter the path to the zip file **inside the container**. (Best for large archives) + - **Upload File:** Click **Choose File** and select the zip archive containing your EML files. (Best for smaller archives) + - **Local Path:** Enter the path to the zip file **inside the container**. (Best for large archives) > **Note on Local Path:** When using Docker, the "Local Path" is relative to the container's filesystem. - > * **Recommended:** Place your zip file in a `temp` folder inside your configured storage directory (`STORAGE_LOCAL_ROOT_PATH`). This path is already mounted. For example, if your storage path is `/data`, put the file in `/data/temp/emails.zip` and enter `/data/temp/emails.zip` as the path. - > * **Alternative:** Mount a separate volume in `docker-compose.yml` (e.g., `- /host/path:/container/path`) and use the container path. + > + > - **Recommended:** Place your zip file in a `temp` folder inside your configured storage directory (`STORAGE_LOCAL_ROOT_PATH`). This path is already mounted. For example, if your storage path is `/data`, put the file in `/data/temp/emails.zip` and enter `/data/temp/emails.zip` as the path. + > - **Alternative:** Mount a separate volume in `docker-compose.yml` (e.g., `- /host/path:/container/path`) and use the container path. 6. Click the **Submit** button. diff --git a/docs/user-guides/email-providers/mbox.md b/docs/user-guides/email-providers/mbox.md index 587d89b2..9fe44951 100644 --- a/docs/user-guides/email-providers/mbox.md +++ b/docs/user-guides/email-providers/mbox.md @@ -18,12 +18,13 @@ Once you have your `.mbox` file, you can upload it to OpenArchiver through the w 2. Click on the **New Ingestion** button. 3. Select **Mbox** as the source type. 4. **Choose Import Method:** - * **Upload File:** Upload your `.mbox` file. - * **Local Path:** Enter the path to the mbox file **inside the container**. + - **Upload File:** Upload your `.mbox` file. + - **Local Path:** Enter the path to the mbox file **inside the container**. > **Note on Local Path:** When using Docker, the "Local Path" is relative to the container's filesystem. - > * **Recommended:** Place your mbox file in a `temp` folder inside your configured storage directory (`STORAGE_LOCAL_ROOT_PATH`). This path is already mounted. For example, if your storage path is `/data`, put the file in `/data/temp/emails.mbox` and enter `/data/temp/emails.mbox` as the path. - > * **Alternative:** Mount a separate volume in `docker-compose.yml` (e.g., `- /host/path:/container/path`) and use the container path. + > + > - **Recommended:** Place your mbox file in a `temp` folder inside your configured storage directory (`STORAGE_LOCAL_ROOT_PATH`). This path is already mounted. For example, if your storage path is `/data`, put the file in `/data/temp/emails.mbox` and enter `/data/temp/emails.mbox` as the path. + > - **Alternative:** Mount a separate volume in `docker-compose.yml` (e.g., `- /host/path:/container/path`) and use the container path. ## 3. Folder Structure diff --git a/docs/user-guides/email-providers/pst.md b/docs/user-guides/email-providers/pst.md index 007e0edb..f1e65c69 100644 --- a/docs/user-guides/email-providers/pst.md +++ b/docs/user-guides/email-providers/pst.md @@ -16,12 +16,13 @@ To ensure a successful import, you should prepare your PST file according to the 3. Select **PST Import** as the provider. 4. Enter a name for the ingestion source. 5. **Choose Import Method:** - * **Upload File:** Click **Choose File** and select the PST file from your computer. (Best for smaller files) - * **Local Path:** Enter the path to the PST file **inside the container**. (Best for large files) + - **Upload File:** Click **Choose File** and select the PST file from your computer. (Best for smaller files) + - **Local Path:** Enter the path to the PST file **inside the container**. (Best for large files) > **Note on Local Path:** When using Docker, the "Local Path" is relative to the container's filesystem. - > * **Recommended:** Place your file in a `temp` folder inside your configured storage directory (`STORAGE_LOCAL_ROOT_PATH`). This path is already mounted. For example, if your storage path is `/data`, put the file in `/data/temp/archive.pst` and enter `/data/temp/archive.pst` as the path. - > * **Alternative:** Mount a separate volume in `docker-compose.yml` (e.g., `- /host/path:/container/path`) and use the container path. + > + > - **Recommended:** Place your file in a `temp` folder inside your configured storage directory (`STORAGE_LOCAL_ROOT_PATH`). This path is already mounted. For example, if your storage path is `/data`, put the file in `/data/temp/archive.pst` and enter `/data/temp/archive.pst` as the path. + > - **Alternative:** Mount a separate volume in `docker-compose.yml` (e.g., `- /host/path:/container/path`) and use the container path. 6. Click the **Submit** button. diff --git a/docs/user-guides/upgrade-and-migration/meilisearch-upgrade.md b/docs/user-guides/upgrade-and-migration/meilisearch-upgrade.md index e0213b39..0324cae0 100644 --- a/docs/user-guides/upgrade-and-migration/meilisearch-upgrade.md +++ b/docs/user-guides/upgrade-and-migration/meilisearch-upgrade.md @@ -24,11 +24,11 @@ Add the `MEILI_EXPERIMENTAL_DUMPLESS_UPGRADE` environment variable to your `dock ```yaml services: - meilisearch: - image: getmeili/meilisearch:v1.x # The new version you want to upgrade to - environment: - - MEILI_MASTER_KEY=${MEILI_MASTER_KEY} - - MEILI_EXPERIMENTAL_DUMPLESS_UPGRADE=true + meilisearch: + image: getmeili/meilisearch:v1.x # The new version you want to upgrade to + environment: + - MEILI_MASTER_KEY=${MEILI_MASTER_KEY} + - MEILI_EXPERIMENTAL_DUMPLESS_UPGRADE=true ``` **Option 2: Using a CLI Option** @@ -37,9 +37,9 @@ Alternatively, you can pass the `--experimental-dumpless-upgrade` flag in the co ```yaml services: - meilisearch: - image: getmeili/meilisearch:v1.x # The new version you want to upgrade to - command: meilisearch --experimental-dumpless-upgrade + meilisearch: + image: getmeili/meilisearch:v1.x # The new version you want to upgrade to + command: meilisearch --experimental-dumpless-upgrade ``` After updating your configuration, restart your container: diff --git a/package.json b/package.json index df69c50a..2ceda0db 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-archiver", - "version": "0.4.2", + "version": "0.5.0", "private": true, "license": "SEE LICENSE IN LICENSE file", "scripts": { @@ -8,8 +8,8 @@ "build:enterprise": "cross-env VITE_ENTERPRISE_MODE=true pnpm build", "start:oss": "dotenv -- concurrently \"node apps/open-archiver/dist/index.js\" \"pnpm --filter @open-archiver/frontend start\"", "start:enterprise": "dotenv -- concurrently \"node apps/open-archiver-enterprise/dist/index.js\" \"pnpm --filter @open-archiver/frontend start\"", - "dev:enterprise": "cross-env VITE_ENTERPRISE_MODE=true dotenv -- pnpm --filter \"@open-archiver/*\" --filter \"open-archiver-enterprise-app\" --parallel dev", - "dev:oss": "dotenv -- pnpm --filter \"./packages/*\" --filter \"!./packages/@open-archiver/enterprise\" --filter \"open-archiver-app\" --parallel dev", + "dev:enterprise": "cross-env VITE_ENTERPRISE_MODE=true dotenv -- pnpm --filter \"@open-archiver/*\" --filter \"open-archiver-enterprise-app\" --parallel dev & pnpm run start:workers:dev", + "dev:oss": "dotenv -- pnpm --filter \"./packages/*\" --filter \"!./packages/@open-archiver/enterprise\" --filter \"open-archiver-app\" --parallel dev & pnpm run start:workers:dev", "build": "pnpm --filter \"./packages/*\" --filter \"./apps/*\" build", "start": "dotenv -- pnpm --filter \"open-archiver-app\" --parallel start", "start:workers": "dotenv -- concurrently \"pnpm --filter @open-archiver/backend start:ingestion-worker\" \"pnpm --filter @open-archiver/backend start:indexing-worker\" \"pnpm --filter @open-archiver/backend start:sync-scheduler\"", @@ -19,8 +19,9 @@ "db:migrate:dev": "dotenv -- pnpm --filter @open-archiver/backend db:migrate:dev", "docker-start:oss": "concurrently \"pnpm start:workers\" \"pnpm start:oss\"", "docker-start:enterprise": "concurrently \"pnpm start:workers\" \"pnpm start:enterprise\"", - "docs:dev": "vitepress dev docs --port 3009", - "docs:build": "vitepress build docs", + "docs:gen-spec": "node packages/backend/scripts/generate-openapi-spec.mjs", + "docs:dev": "pnpm docs:gen-spec && vitepress dev docs --port 3009", + "docs:build": "pnpm docs:gen-spec && vitepress build docs", "docs:preview": "vitepress preview docs", "format": "prettier --write .", "lint": "prettier --check ." @@ -35,7 +36,8 @@ "prettier-plugin-svelte": "^3.4.0", "prettier-plugin-tailwindcss": "^0.6.14", "typescript": "5.8.3", - "vitepress": "^1.6.4" + "vitepress": "^1.6.4", + "vitepress-openapi": "^0.1.18" }, "packageManager": "pnpm@10.13.1", "engines": { diff --git a/packages/backend/package.json b/packages/backend/package.json index aae276bd..13bb5e74 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -5,6 +5,10 @@ "license": "SEE LICENSE IN LICENSE file", "main": "dist/index.js", "types": "dist/index.d.ts", + "exports": { + ".": "./dist/index.js", + "./*": "./dist/*.js" + }, "scripts": { "build": "tsc && pnpm copy-assets", "dev": "tsc --watch", @@ -52,6 +56,7 @@ "mammoth": "^1.9.1", "meilisearch": "^0.51.0", "multer": "^2.0.2", + "nodemailer": "^8.0.2", "pdf2json": "^3.1.6", "pg": "^8.16.3", "pino": "^9.7.0", @@ -73,7 +78,10 @@ "@types/microsoft-graph": "^2.40.1", "@types/multer": "^2.0.0", "@types/node": "^24.0.12", + "@types/nodemailer": "^7.0.11", + "@types/swagger-jsdoc": "^6.0.4", "@types/yauzl": "^2.10.3", + "swagger-jsdoc": "^6.2.8", "ts-node-dev": "^2.0.0", "tsconfig-paths": "^4.2.0", "typescript": "^5.8.3" diff --git a/packages/backend/scripts/generate-openapi-spec.mjs b/packages/backend/scripts/generate-openapi-spec.mjs new file mode 100644 index 00000000..e6613c2e --- /dev/null +++ b/packages/backend/scripts/generate-openapi-spec.mjs @@ -0,0 +1,735 @@ +/** + * Generates the OpenAPI specification from swagger-jsdoc annotations in the route files. + * Outputs the spec to docs/api/openapi.json for use with vitepress-openapi. + * + * Run: node packages/backend/scripts/generate-openapi-spec.mjs + */ +import swaggerJsdoc from 'swagger-jsdoc'; +import { writeFileSync, mkdirSync } from 'fs'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const options = { + definition: { + openapi: '3.1.0', + info: { + title: 'Open Archiver API', + version: '1.0.0', + description: + 'REST API for Open Archiver — an open-source email archiving platform. All authenticated endpoints require a Bearer JWT token obtained from `POST /v1/auth/login`, or an API key passed as a Bearer token.', + license: { + name: 'SEE LICENSE IN LICENSE', + url: 'https://github.com/LogicLabs-OU/OpenArchiver/blob/main/LICENSE', + }, + contact: { + name: 'Open Archiver', + url: 'https://openarchiver.com', + }, + }, + servers: [ + { + url: 'http://localhost:3001', + description: 'Local development', + }, + ], + // Both security schemes apply globally; individual endpoints may override + security: [{ bearerAuth: [] }, { apiKeyAuth: [] }], + components: { + securitySchemes: { + bearerAuth: { + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT', + description: + 'JWT obtained from `POST /v1/auth/login`. Pass as `Authorization: Bearer `.', + }, + apiKeyAuth: { + type: 'apiKey', + in: 'header', + name: 'X-API-KEY', + description: + 'API key generated via `POST /v1/api-keys`. Pass as `X-API-KEY: `.', + }, + }, + responses: { + Unauthorized: { + description: 'Authentication is required or the token is invalid/expired.', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/ErrorMessage' }, + example: { message: 'Unauthorized' }, + }, + }, + }, + Forbidden: { + description: + 'The authenticated user does not have permission to perform this action.', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/ErrorMessage' }, + example: { message: 'Forbidden' }, + }, + }, + }, + NotFound: { + description: 'The requested resource was not found.', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/ErrorMessage' }, + example: { message: 'Not found' }, + }, + }, + }, + InternalServerError: { + description: 'An unexpected error occurred on the server.', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/ErrorMessage' }, + example: { message: 'Internal server error' }, + }, + }, + }, + }, + schemas: { + // --- Shared utility schemas --- + ErrorMessage: { + type: 'object', + properties: { + message: { + type: 'string', + description: 'Human-readable error description.', + example: 'An error occurred.', + }, + }, + required: ['message'], + }, + MessageResponse: { + type: 'object', + properties: { + message: { + type: 'string', + example: 'Operation completed successfully.', + }, + }, + required: ['message'], + }, + ValidationError: { + type: 'object', + properties: { + message: { + type: 'string', + example: 'Request body is invalid.', + }, + errors: { + type: 'string', + description: 'Zod validation error details.', + }, + }, + required: ['message'], + }, + // --- Auth --- + LoginResponse: { + type: 'object', + properties: { + accessToken: { + type: 'string', + description: 'JWT for authenticating subsequent requests.', + example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', + }, + user: { + $ref: '#/components/schemas/User', + }, + }, + required: ['accessToken', 'user'], + }, + // --- Users --- + User: { + type: 'object', + properties: { + id: { type: 'string', example: 'clx1y2z3a0000b4d2' }, + first_name: { type: 'string', nullable: true, example: 'Jane' }, + last_name: { type: 'string', nullable: true, example: 'Doe' }, + email: { + type: 'string', + format: 'email', + example: 'jane.doe@example.com', + }, + role: { + $ref: '#/components/schemas/Role', + nullable: true, + }, + createdAt: { type: 'string', format: 'date-time' }, + }, + required: ['id', 'email', 'createdAt'], + }, + // --- IAM --- + Role: { + type: 'object', + properties: { + id: { type: 'string', example: 'clx1y2z3a0000b4d2' }, + slug: { type: 'string', nullable: true, example: 'predefined_super_admin' }, + name: { type: 'string', example: 'Super Admin' }, + policies: { + type: 'array', + items: { $ref: '#/components/schemas/CaslPolicy' }, + }, + createdAt: { type: 'string', format: 'date-time' }, + updatedAt: { type: 'string', format: 'date-time' }, + }, + required: ['id', 'name', 'policies', 'createdAt', 'updatedAt'], + }, + CaslPolicy: { + type: 'object', + description: + 'An CASL-style permission policy statement. `action` and `subject` can be strings or arrays of strings. `conditions` optionally restricts access to specific resource attributes.', + properties: { + action: { + oneOf: [ + { + type: 'string', + example: 'read', + }, + { + type: 'array', + items: { type: 'string' }, + example: ['read', 'search'], + }, + ], + }, + subject: { + oneOf: [ + { + type: 'string', + example: 'archive', + }, + { + type: 'array', + items: { type: 'string' }, + example: ['archive', 'ingestion'], + }, + ], + }, + conditions: { + type: 'object', + description: + 'Optional attribute-level conditions. Supports `${user.id}` interpolation.', + example: { userId: '${user.id}' }, + }, + }, + required: ['action', 'subject'], + }, + // --- API Keys --- + ApiKey: { + type: 'object', + properties: { + id: { type: 'string', example: 'clx1y2z3a0000b4d2' }, + name: { type: 'string', example: 'CI/CD Pipeline Key' }, + key: { + type: 'string', + description: + 'Partial/masked key — the raw value is only available at creation time.', + example: 'oa_live_abc1...', + }, + expiresAt: { type: 'string', format: 'date-time' }, + createdAt: { type: 'string', format: 'date-time' }, + }, + required: ['id', 'name', 'expiresAt', 'createdAt'], + }, + // --- Ingestion --- + SafeIngestionSource: { + type: 'object', + description: 'An ingestion source with sensitive credential fields removed.', + properties: { + id: { type: 'string', example: 'clx1y2z3a0000b4d2' }, + name: { type: 'string', example: 'Company Google Workspace' }, + provider: { + type: 'string', + enum: [ + 'google_workspace', + 'microsoft_365', + 'generic_imap', + 'pst_import', + 'eml_import', + 'mbox_import', + ], + example: 'google_workspace', + }, + status: { + type: 'string', + enum: [ + 'active', + 'paused', + 'error', + 'pending_auth', + 'syncing', + 'importing', + 'auth_success', + 'imported', + ], + example: 'active', + }, + createdAt: { type: 'string', format: 'date-time' }, + updatedAt: { type: 'string', format: 'date-time' }, + lastSyncStartedAt: { type: 'string', format: 'date-time', nullable: true }, + lastSyncFinishedAt: { type: 'string', format: 'date-time', nullable: true }, + lastSyncStatusMessage: { type: 'string', nullable: true }, + }, + required: ['id', 'name', 'provider', 'status', 'createdAt', 'updatedAt'], + }, + CreateIngestionSourceDto: { + type: 'object', + required: ['name', 'provider', 'providerConfig'], + properties: { + name: { + type: 'string', + example: 'Company Google Workspace', + }, + provider: { + type: 'string', + enum: [ + 'google_workspace', + 'microsoft_365', + 'generic_imap', + 'pst_import', + 'eml_import', + 'mbox_import', + ], + }, + providerConfig: { + type: 'object', + description: + 'Provider-specific configuration. See the ingestion source guides for the required fields per provider.', + example: { + serviceAccountKeyJson: '{"type":"service_account",...}', + impersonatedAdminEmail: 'admin@example.com', + }, + }, + }, + }, + UpdateIngestionSourceDto: { + type: 'object', + properties: { + name: { type: 'string' }, + provider: { + type: 'string', + enum: [ + 'google_workspace', + 'microsoft_365', + 'generic_imap', + 'pst_import', + 'eml_import', + 'mbox_import', + ], + }, + status: { + type: 'string', + enum: [ + 'active', + 'paused', + 'error', + 'pending_auth', + 'syncing', + 'importing', + 'auth_success', + 'imported', + ], + }, + providerConfig: { type: 'object' }, + }, + }, + // --- Archived Emails --- + Recipient: { + type: 'object', + properties: { + name: { type: 'string', nullable: true, example: 'John Doe' }, + email: { + type: 'string', + format: 'email', + example: 'john.doe@example.com', + }, + }, + required: ['email'], + }, + Attachment: { + type: 'object', + properties: { + id: { type: 'string', example: 'clx1y2z3a0000b4d2' }, + filename: { type: 'string', example: 'invoice.pdf' }, + mimeType: { type: 'string', nullable: true, example: 'application/pdf' }, + sizeBytes: { type: 'integer', example: 204800 }, + storagePath: { + type: 'string', + example: 'open-archiver/attachments/abc123.pdf', + }, + }, + required: ['id', 'filename', 'sizeBytes', 'storagePath'], + }, + // Minimal representation of an email within a thread (returned alongside ArchivedEmail) + ThreadEmail: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'ArchivedEmail ID.', + example: 'clx1y2z3a0000b4d2', + }, + subject: { type: 'string', nullable: true, example: 'Re: Q4 Invoice' }, + sentAt: { type: 'string', format: 'date-time' }, + senderEmail: { + type: 'string', + format: 'email', + example: 'finance@vendor.com', + }, + }, + required: ['id', 'sentAt', 'senderEmail'], + }, + ArchivedEmail: { + type: 'object', + properties: { + id: { type: 'string', example: 'clx1y2z3a0000b4d2' }, + ingestionSourceId: { type: 'string', example: 'clx1y2z3a0000b4d2' }, + userEmail: { + type: 'string', + format: 'email', + example: 'user@company.com', + }, + messageIdHeader: { type: 'string', nullable: true }, + sentAt: { type: 'string', format: 'date-time' }, + subject: { type: 'string', nullable: true, example: 'Q4 Invoice' }, + senderName: { type: 'string', nullable: true, example: 'Finance Dept' }, + senderEmail: { + type: 'string', + format: 'email', + example: 'finance@vendor.com', + }, + recipients: { + type: 'array', + items: { $ref: '#/components/schemas/Recipient' }, + }, + storagePath: { type: 'string' }, + storageHashSha256: { + type: 'string', + description: + 'SHA-256 hash of the raw email file, stored at archival time.', + }, + sizeBytes: { type: 'integer' }, + isIndexed: { type: 'boolean' }, + hasAttachments: { type: 'boolean' }, + isOnLegalHold: { type: 'boolean' }, + archivedAt: { type: 'string', format: 'date-time' }, + attachments: { + type: 'array', + items: { $ref: '#/components/schemas/Attachment' }, + }, + thread: { + type: 'array', + description: + 'Other emails in the same thread, ordered by sentAt. Only present on single-email GET responses.', + items: { $ref: '#/components/schemas/ThreadEmail' }, + }, + path: { type: 'string', nullable: true }, + tags: { + type: 'array', + items: { type: 'string' }, + nullable: true, + }, + }, + required: [ + 'id', + 'ingestionSourceId', + 'userEmail', + 'sentAt', + 'senderEmail', + 'recipients', + 'storagePath', + 'storageHashSha256', + 'sizeBytes', + 'isIndexed', + 'hasAttachments', + 'isOnLegalHold', + 'archivedAt', + ], + }, + PaginatedArchivedEmails: { + type: 'object', + properties: { + items: { + type: 'array', + items: { $ref: '#/components/schemas/ArchivedEmail' }, + }, + total: { type: 'integer', example: 1234 }, + page: { type: 'integer', example: 1 }, + limit: { type: 'integer', example: 10 }, + }, + required: ['items', 'total', 'page', 'limit'], + }, + // --- Search --- + SearchResults: { + type: 'object', + properties: { + hits: { + type: 'array', + description: + 'Array of matching archived email objects, potentially with highlighted fields.', + items: { type: 'object' }, + }, + total: { type: 'integer', example: 42 }, + page: { type: 'integer', example: 1 }, + limit: { type: 'integer', example: 10 }, + totalPages: { type: 'integer', example: 5 }, + processingTimeMs: { + type: 'integer', + description: 'Meilisearch query processing time in milliseconds.', + example: 12, + }, + }, + required: ['hits', 'total', 'page', 'limit', 'totalPages', 'processingTimeMs'], + }, + // --- Integrity --- + IntegrityCheckResult: { + type: 'object', + properties: { + type: { + type: 'string', + enum: ['email', 'attachment'], + description: + 'Whether this result is for the email itself or one of its attachments.', + }, + id: { type: 'string', example: 'clx1y2z3a0000b4d2' }, + filename: { + type: 'string', + description: + 'Attachment filename. Only present when `type` is `attachment`.', + example: 'invoice.pdf', + }, + isValid: { + type: 'boolean', + description: 'True if the stored and computed hashes match.', + }, + reason: { + type: 'string', + description: 'Human-readable explanation if `isValid` is false.', + }, + storedHash: { + type: 'string', + description: 'SHA-256 hash stored at archival time.', + example: 'a3f1b2c4...', + }, + computedHash: { + type: 'string', + description: 'SHA-256 hash computed during this verification run.', + example: 'a3f1b2c4...', + }, + }, + required: ['type', 'id', 'isValid', 'storedHash', 'computedHash'], + }, + // --- Jobs --- + QueueCounts: { + type: 'object', + properties: { + active: { type: 'integer', example: 0 }, + completed: { type: 'integer', example: 56 }, + failed: { type: 'integer', example: 4 }, + delayed: { type: 'integer', example: 0 }, + waiting: { type: 'integer', example: 0 }, + paused: { type: 'integer', example: 0 }, + }, + required: ['active', 'completed', 'failed', 'delayed', 'waiting', 'paused'], + }, + QueueOverview: { + type: 'object', + properties: { + name: { type: 'string', example: 'ingestion' }, + counts: { $ref: '#/components/schemas/QueueCounts' }, + }, + required: ['name', 'counts'], + }, + Job: { + type: 'object', + properties: { + id: { type: 'string', nullable: true, example: '1' }, + name: { type: 'string', example: 'initial-import' }, + data: { + type: 'object', + description: 'Job payload data.', + example: { ingestionSourceId: 'clx1y2z3a0000b4d2' }, + }, + state: { + type: 'string', + enum: ['active', 'completed', 'failed', 'delayed', 'waiting', 'paused'], + example: 'failed', + }, + failedReason: { + type: 'string', + nullable: true, + example: 'Error: Connection timed out', + }, + timestamp: { type: 'integer', example: 1678886400000 }, + processedOn: { type: 'integer', nullable: true, example: 1678886401000 }, + finishedOn: { type: 'integer', nullable: true, example: 1678886402000 }, + attemptsMade: { type: 'integer', example: 5 }, + stacktrace: { + type: 'array', + items: { type: 'string' }, + }, + returnValue: { nullable: true }, + ingestionSourceId: { type: 'string', nullable: true }, + error: { + description: 'Shorthand copy of `failedReason` for easier access.', + nullable: true, + }, + }, + required: [ + 'id', + 'name', + 'data', + 'state', + 'timestamp', + 'attemptsMade', + 'stacktrace', + ], + }, + QueueDetails: { + type: 'object', + properties: { + name: { type: 'string', example: 'ingestion' }, + counts: { $ref: '#/components/schemas/QueueCounts' }, + jobs: { + type: 'array', + items: { $ref: '#/components/schemas/Job' }, + }, + pagination: { + type: 'object', + properties: { + currentPage: { type: 'integer', example: 1 }, + totalPages: { type: 'integer', example: 3 }, + totalJobs: { type: 'integer', example: 25 }, + limit: { type: 'integer', example: 10 }, + }, + required: ['currentPage', 'totalPages', 'totalJobs', 'limit'], + }, + }, + required: ['name', 'counts', 'jobs', 'pagination'], + }, + // --- Dashboard --- + DashboardStats: { + type: 'object', + properties: { + totalEmailsArchived: { type: 'integer', example: 125000 }, + totalStorageUsed: { + type: 'integer', + description: 'Total storage used by all archived emails in bytes.', + example: 5368709120, + }, + failedIngestionsLast7Days: { + type: 'integer', + description: + 'Number of ingestion sources in error state updated in the last 7 days.', + example: 2, + }, + }, + }, + IngestionSourceStats: { + type: 'object', + description: 'Summary of an ingestion source including its storage usage.', + properties: { + id: { type: 'string', example: 'clx1y2z3a0000b4d2' }, + name: { type: 'string', example: 'Company Google Workspace' }, + provider: { type: 'string', example: 'google_workspace' }, + status: { type: 'string', example: 'active' }, + storageUsed: { + type: 'integer', + description: + 'Total bytes stored for emails from this ingestion source.', + example: 1073741824, + }, + }, + required: ['id', 'name', 'provider', 'status', 'storageUsed'], + }, + RecentSync: { + type: 'object', + description: 'Summary of a recent sync session.', + properties: { + id: { type: 'string', example: 'clx1y2z3a0000b4d2' }, + sourceName: { type: 'string', example: 'Company Google Workspace' }, + startTime: { type: 'string', format: 'date-time' }, + duration: { + type: 'integer', + description: 'Duration in milliseconds.', + example: 4500, + }, + emailsProcessed: { type: 'integer', example: 120 }, + status: { type: 'string', example: 'completed' }, + }, + required: [ + 'id', + 'sourceName', + 'startTime', + 'duration', + 'emailsProcessed', + 'status', + ], + }, + IndexedInsights: { + type: 'object', + description: 'Insights derived from the search index.', + properties: { + topSenders: { + type: 'array', + items: { + type: 'object', + properties: { + sender: { type: 'string', example: 'finance@vendor.com' }, + count: { type: 'integer', example: 342 }, + }, + required: ['sender', 'count'], + }, + }, + }, + required: ['topSenders'], + }, + // --- Settings --- + SystemSettings: { + type: 'object', + description: 'Non-sensitive system configuration values.', + properties: { + language: { + type: 'string', + enum: ['en', 'es', 'fr', 'de', 'it', 'pt', 'nl', 'ja', 'et', 'el'], + example: 'en', + description: 'Default UI language code.', + }, + theme: { + type: 'string', + enum: ['light', 'dark', 'system'], + example: 'system', + description: 'Default color theme.', + }, + supportEmail: { + type: 'string', + format: 'email', + nullable: true, + example: 'support@example.com', + description: 'Public-facing support email address.', + }, + }, + }, + }, + }, + }, + // Scan all route files for @openapi annotations + apis: [resolve(__dirname, '../src/api/routes/*.ts')], +}; + +const spec = swaggerJsdoc(options); + +// Output to docs/ directory so VitePress can consume it +const outputPath = resolve(__dirname, '../../../docs/api/openapi.json'); +mkdirSync(dirname(outputPath), { recursive: true }); +writeFileSync(outputPath, JSON.stringify(spec, null, 2)); + +console.log(`✅ OpenAPI spec generated: ${outputPath}`); +console.log(` Paths: ${Object.keys(spec.paths ?? {}).length}, Tags: ${(spec.tags ?? []).length}`); diff --git a/packages/backend/src/api/controllers/archived-email.controller.ts b/packages/backend/src/api/controllers/archived-email.controller.ts index 2b3a25eb..c565ddad 100644 --- a/packages/backend/src/api/controllers/archived-email.controller.ts +++ b/packages/backend/src/api/controllers/archived-email.controller.ts @@ -59,17 +59,26 @@ export class ArchivedEmailController { }; public deleteArchivedEmail = async (req: Request, res: Response): Promise => { + // Guard: return 400 if deletion is disabled in system settings before touching anything else try { checkDeletionEnabled(); - const { id } = req.params; - const userId = req.user?.sub; - if (!userId) { - return res.status(401).json({ message: req.t('errors.unauthorized') }); - } - const actor = await this.userService.findById(userId); - if (!actor) { - return res.status(401).json({ message: req.t('errors.unauthorized') }); - } + } catch (error) { + return res.status(400).json({ + message: error instanceof Error ? error.message : req.t('errors.deletionDisabled'), + }); + } + + const { id } = req.params; + const userId = req.user?.sub; + if (!userId) { + return res.status(401).json({ message: req.t('errors.unauthorized') }); + } + const actor = await this.userService.findById(userId); + if (!actor) { + return res.status(401).json({ message: req.t('errors.unauthorized') }); + } + + try { await ArchivedEmailService.deleteArchivedEmail(id, actor, req.ip || 'unknown'); return res.status(204).send(); } catch (error) { @@ -78,6 +87,10 @@ export class ArchivedEmailController { if (error.message === 'Archived email not found') { return res.status(404).json({ message: req.t('archivedEmail.notFound') }); } + // Retention policy / legal hold blocks are user-facing 400 errors + if (error.message.startsWith('Deletion blocked by retention policy')) { + return res.status(400).json({ message: error.message }); + } return res.status(500).json({ message: error.message }); } return res.status(500).json({ message: req.t('errors.internalServerError') }); diff --git a/packages/backend/src/api/controllers/upload.controller.ts b/packages/backend/src/api/controllers/upload.controller.ts index 5ebc5d92..67f8ee68 100644 --- a/packages/backend/src/api/controllers/upload.controller.ts +++ b/packages/backend/src/api/controllers/upload.controller.ts @@ -3,24 +3,96 @@ import { StorageService } from '../../services/StorageService'; import { randomUUID } from 'crypto'; import busboy from 'busboy'; import { config } from '../../config/index'; +import { logger } from '../../config/logger'; +import i18next from 'i18next'; export const uploadFile = async (req: Request, res: Response) => { const storage = new StorageService(); - const bb = busboy({ headers: req.headers }); const uploads: Promise[] = []; let filePath = ''; let originalFilename = ''; + let headersSent = false; + const contentLength = req.headers['content-length']; - bb.on('file', (fieldname, file, filename) => { - originalFilename = filename.filename; + logger.info({ contentLength, contentType: req.headers['content-type'] }, 'File upload started'); + + const sendErrorResponse = (statusCode: number, message: string) => { + if (!headersSent) { + headersSent = true; + res.status(statusCode).json({ + status: 'error', + statusCode, + message, + errors: null, + }); + } + }; + + let bb: busboy.Busboy; + try { + bb = busboy({ headers: req.headers }); + } catch (err) { + const message = err instanceof Error ? err.message : i18next.t('upload.invalid_request'); + logger.error({ error: message }, 'Failed to initialize file upload parser'); + sendErrorResponse(400, i18next.t('upload.invalid_request')); + return; + } + + bb.on('file', (fieldname, file, info) => { + originalFilename = info.filename; const uuid = randomUUID(); filePath = `${config.storage.openArchiverFolderName}/tmp/${uuid}-${originalFilename}`; + + logger.info({ filename: originalFilename, fieldname }, 'Receiving file stream'); + + file.on('error', (err) => { + logger.error( + { error: err.message, filename: originalFilename }, + 'File stream error during upload' + ); + sendErrorResponse(500, i18next.t('upload.stream_error')); + }); + uploads.push(storage.put(filePath, file)); }); + bb.on('error', (err: Error) => { + logger.error({ error: err.message }, 'Upload parsing error'); + sendErrorResponse(500, i18next.t('upload.parse_error')); + }); + bb.on('finish', async () => { - await Promise.all(uploads); - res.json({ filePath }); + try { + await Promise.all(uploads); + if (!headersSent) { + headersSent = true; + logger.info( + { filePath, filename: originalFilename }, + 'File upload completed successfully' + ); + res.json({ filePath }); + } + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown storage error'; + logger.error( + { error: message, filename: originalFilename, filePath }, + 'Failed to write uploaded file to storage' + ); + sendErrorResponse(500, i18next.t('upload.storage_error')); + } + }); + + // Handle client disconnection mid-upload + req.on('error', (err) => { + logger.warn( + { error: err.message, filename: originalFilename }, + 'Client connection error during upload' + ); + sendErrorResponse(499, i18next.t('upload.connection_error')); + }); + + req.on('aborted', () => { + logger.warn({ filename: originalFilename }, 'Client aborted upload'); }); req.pipe(bb); diff --git a/packages/backend/src/api/routes/api-key.routes.ts b/packages/backend/src/api/routes/api-key.routes.ts index 3f6e4def..a1f62500 100644 --- a/packages/backend/src/api/routes/api-key.routes.ts +++ b/packages/backend/src/api/routes/api-key.routes.ts @@ -7,8 +7,127 @@ export const apiKeyRoutes = (authService: AuthService): Router => { const router = Router(); const controller = new ApiKeyController(); + /** + * @openapi + * /v1/api-keys: + * post: + * summary: Generate an API key + * description: > + * Generates a new API key for the authenticated user. The raw key value is only returned once at creation time. + * The key name must be between 1–255 characters. Expiry is required and must be within 730 days (2 years). + * Disabled in demo mode. + * operationId: generateApiKey + * tags: + * - API Keys + * security: + * - bearerAuth: [] + * - apiKeyAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - name + * - expiresInDays + * properties: + * name: + * type: string + * minLength: 1 + * maxLength: 255 + * example: "CI/CD Pipeline Key" + * expiresInDays: + * type: integer + * minimum: 1 + * maximum: 730 + * example: 90 + * responses: + * '201': + * description: API key created. The raw `key` value is only shown once. + * content: + * application/json: + * schema: + * type: object + * properties: + * key: + * type: string + * description: The raw API key. Store this securely — it will not be shown again. + * example: "oa_live_abc123..." + * '400': + * description: Validation error (name too short/long, expiry out of range). + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ValidationError' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * description: Disabled in demo mode. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorMessage' + * '500': + * $ref: '#/components/responses/InternalServerError' + * get: + * summary: List API keys + * description: Returns all API keys belonging to the currently authenticated user. The raw key value is not included. + * operationId: getApiKeys + * tags: + * - API Keys + * security: + * - bearerAuth: [] + * - apiKeyAuth: [] + * responses: + * '200': + * description: List of API keys (without raw key values). + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: '#/components/schemas/ApiKey' + * '401': + * $ref: '#/components/responses/Unauthorized' + */ router.post('/', requireAuth(authService), controller.generateApiKey); router.get('/', requireAuth(authService), controller.getApiKeys); + + /** + * @openapi + * /v1/api-keys/{id}: + * delete: + * summary: Delete an API key + * description: Permanently revokes and deletes an API key by ID. Only the owning user can delete their own keys. Disabled in demo mode. + * operationId: deleteApiKey + * tags: + * - API Keys + * security: + * - bearerAuth: [] + * - apiKeyAuth: [] + * parameters: + * - name: id + * in: path + * required: true + * description: The ID of the API key to delete. + * schema: + * type: string + * example: "clx1y2z3a0000b4d2" + * responses: + * '204': + * description: API key deleted. No content returned. + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * description: Disabled in demo mode. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorMessage' + * '500': + * $ref: '#/components/responses/InternalServerError' + */ router.delete('/:id', requireAuth(authService), controller.deleteApiKey); return router; diff --git a/packages/backend/src/api/routes/archived-email.routes.ts b/packages/backend/src/api/routes/archived-email.routes.ts index 174d1ea0..b4174f88 100644 --- a/packages/backend/src/api/routes/archived-email.routes.ts +++ b/packages/backend/src/api/routes/archived-email.routes.ts @@ -13,12 +13,126 @@ export const createArchivedEmailRouter = ( // Secure all routes in this module router.use(requireAuth(authService)); + /** + * @openapi + * /v1/archived-emails/ingestion-source/{ingestionSourceId}: + * get: + * summary: List archived emails for an ingestion source + * description: Returns a paginated list of archived emails belonging to the specified ingestion source. Requires `read:archive` permission. + * operationId: getArchivedEmails + * tags: + * - Archived Emails + * security: + * - bearerAuth: [] + * - apiKeyAuth: [] + * parameters: + * - name: ingestionSourceId + * in: path + * required: true + * description: The ID of the ingestion source to retrieve emails for. + * schema: + * type: string + * example: "clx1y2z3a0000b4d2" + * - name: page + * in: query + * required: false + * description: Page number for pagination. + * schema: + * type: integer + * default: 1 + * example: 1 + * - name: limit + * in: query + * required: false + * description: Number of items per page. + * schema: + * type: integer + * default: 10 + * example: 10 + * responses: + * '200': + * description: Paginated list of archived emails. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/PaginatedArchivedEmails' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '500': + * $ref: '#/components/responses/InternalServerError' + */ router.get( '/ingestion-source/:ingestionSourceId', requirePermission('read', 'archive'), archivedEmailController.getArchivedEmails ); + /** + * @openapi + * /v1/archived-emails/{id}: + * get: + * summary: Get a single archived email + * description: Retrieves the full details of a single archived email by ID, including attachments and thread. Requires `read:archive` permission. + * operationId: getArchivedEmailById + * tags: + * - Archived Emails + * security: + * - bearerAuth: [] + * - apiKeyAuth: [] + * parameters: + * - name: id + * in: path + * required: true + * description: The ID of the archived email. + * schema: + * type: string + * example: "clx1y2z3a0000b4d2" + * responses: + * '200': + * description: Archived email details. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ArchivedEmail' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '404': + * $ref: '#/components/responses/NotFound' + * '500': + * $ref: '#/components/responses/InternalServerError' + * delete: + * summary: Delete an archived email + * description: Permanently deletes an archived email by ID. Deletion must be enabled in system settings and the email must not be on legal hold. Requires `delete:archive` permission. + * operationId: deleteArchivedEmail + * tags: + * - Archived Emails + * security: + * - bearerAuth: [] + * - apiKeyAuth: [] + * parameters: + * - name: id + * in: path + * required: true + * description: The ID of the archived email to delete. + * schema: + * type: string + * example: "clx1y2z3a0000b4d2" + * responses: + * '204': + * description: Email deleted successfully. No content returned. + * '400': + * description: Deletion is disabled in system settings, or the email is blocked by a retention policy / legal hold. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorMessage' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '404': + * $ref: '#/components/responses/NotFound' + * '500': + * $ref: '#/components/responses/InternalServerError' + */ router.get( '/:id', requirePermission('read', 'archive'), diff --git a/packages/backend/src/api/routes/auth.routes.ts b/packages/backend/src/api/routes/auth.routes.ts index e326165e..71aeab6f 100644 --- a/packages/backend/src/api/routes/auth.routes.ts +++ b/packages/backend/src/api/routes/auth.routes.ts @@ -5,23 +5,141 @@ export const createAuthRouter = (authController: AuthController): Router => { const router = Router(); /** - * @route POST /api/v1/auth/setup - * @description Creates the initial administrator user. - * @access Public + * @openapi + * /v1/auth/setup: + * post: + * summary: Initial setup + * description: Creates the initial administrator user. Can only be called once when no users exist. + * operationId: authSetup + * tags: + * - Auth + * security: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - email + * - password + * - first_name + * - last_name + * properties: + * email: + * type: string + * format: email + * example: admin@example.com + * password: + * type: string + * format: password + * example: "securepassword123" + * first_name: + * type: string + * example: Admin + * last_name: + * type: string + * example: User + * responses: + * '201': + * description: Admin user created and logged in successfully. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/LoginResponse' + * '400': + * description: All fields are required. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorMessage' + * '403': + * description: Setup has already been completed (users already exist). + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorMessage' + * '500': + * $ref: '#/components/responses/InternalServerError' */ router.post('/setup', authController.setup); /** - * @route POST /api/v1/auth/login - * @description Authenticates a user and returns a JWT. - * @access Public + * @openapi + * /v1/auth/login: + * post: + * summary: Login + * description: Authenticates a user with email and password and returns a JWT access token. + * operationId: authLogin + * tags: + * - Auth + * security: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - email + * - password + * properties: + * email: + * type: string + * format: email + * example: user@example.com + * password: + * type: string + * format: password + * example: "securepassword123" + * responses: + * '200': + * description: Authentication successful. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/LoginResponse' + * '400': + * description: Email and password are required. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorMessage' + * '401': + * description: Invalid credentials. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorMessage' + * '500': + * $ref: '#/components/responses/InternalServerError' */ router.post('/login', authController.login); /** - * @route GET /api/v1/auth/status - * @description Checks if the application has been set up. - * @access Public + * @openapi + * /v1/auth/status: + * get: + * summary: Check setup status + * description: Returns whether the application has been set up (i.e., whether an admin user exists). + * operationId: authStatus + * tags: + * - Auth + * security: [] + * responses: + * '200': + * description: Setup status returned. + * content: + * application/json: + * schema: + * type: object + * properties: + * needsSetup: + * type: boolean + * description: True if no admin user exists and setup is required. + * example: false + * '500': + * $ref: '#/components/responses/InternalServerError' */ router.get('/status', authController.status); diff --git a/packages/backend/src/api/routes/dashboard.routes.ts b/packages/backend/src/api/routes/dashboard.routes.ts index 85e45ea4..6bd5f1be 100644 --- a/packages/backend/src/api/routes/dashboard.routes.ts +++ b/packages/backend/src/api/routes/dashboard.routes.ts @@ -9,26 +9,168 @@ export const createDashboardRouter = (authService: AuthService): Router => { router.use(requireAuth(authService)); + /** + * @openapi + * /v1/dashboard/stats: + * get: + * summary: Get dashboard stats + * description: Returns high-level statistics including total archived emails, total storage used, and failed ingestions in the last 7 days. Requires `read:dashboard` permission. + * operationId: getDashboardStats + * tags: + * - Dashboard + * security: + * - bearerAuth: [] + * - apiKeyAuth: [] + * responses: + * '200': + * description: Dashboard statistics. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/DashboardStats' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + */ router.get( '/stats', requirePermission('read', 'dashboard', 'dashboard.permissionRequired'), dashboardController.getStats ); + + /** + * @openapi + * /v1/dashboard/ingestion-history: + * get: + * summary: Get ingestion history + * description: Returns time-series data of email ingestion counts for the last 30 days. Requires `read:dashboard` permission. + * operationId: getIngestionHistory + * tags: + * - Dashboard + * security: + * - bearerAuth: [] + * - apiKeyAuth: [] + * responses: + * '200': + * description: Ingestion history wrapped in a `history` array. + * content: + * application/json: + * schema: + * type: object + * properties: + * history: + * type: array + * items: + * type: object + * properties: + * date: + * type: string + * format: date-time + * description: Truncated to day precision (UTC). + * count: + * type: integer + * required: + * - history + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + */ router.get( '/ingestion-history', requirePermission('read', 'dashboard', 'dashboard.permissionRequired'), dashboardController.getIngestionHistory ); + + /** + * @openapi + * /v1/dashboard/ingestion-sources: + * get: + * summary: Get ingestion source summaries + * description: Returns a summary list of ingestion sources with their storage usage. Requires `read:dashboard` permission. + * operationId: getDashboardIngestionSources + * tags: + * - Dashboard + * security: + * - bearerAuth: [] + * - apiKeyAuth: [] + * responses: + * '200': + * description: List of ingestion source summaries. + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: '#/components/schemas/IngestionSourceStats' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + */ router.get( '/ingestion-sources', requirePermission('read', 'dashboard', 'dashboard.permissionRequired'), dashboardController.getIngestionSources ); + + /** + * @openapi + * /v1/dashboard/recent-syncs: + * get: + * summary: Get recent sync activity + * description: Returns the most recent sync sessions across all ingestion sources. Requires `read:dashboard` permission. + * operationId: getRecentSyncs + * tags: + * - Dashboard + * security: + * - bearerAuth: [] + * - apiKeyAuth: [] + * responses: + * '200': + * description: List of recent sync sessions. + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: '#/components/schemas/RecentSync' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + */ router.get( '/recent-syncs', requirePermission('read', 'dashboard', 'dashboard.permissionRequired'), dashboardController.getRecentSyncs ); + + /** + * @openapi + * /v1/dashboard/indexed-insights: + * get: + * summary: Get indexed email insights + * description: Returns top-sender statistics from the search index. Requires `read:dashboard` permission. + * operationId: getIndexedInsights + * tags: + * - Dashboard + * security: + * - bearerAuth: [] + * - apiKeyAuth: [] + * responses: + * '200': + * description: Indexed email insights. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/IndexedInsights' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + */ router.get( '/indexed-insights', requirePermission('read', 'dashboard', 'dashboard.permissionRequired'), diff --git a/packages/backend/src/api/routes/iam.routes.ts b/packages/backend/src/api/routes/iam.routes.ts index 58416945..bc44501f 100644 --- a/packages/backend/src/api/routes/iam.routes.ts +++ b/packages/backend/src/api/routes/iam.routes.ts @@ -10,16 +10,116 @@ export const createIamRouter = (iamController: IamController, authService: AuthS router.use(requireAuth(authService)); /** - * @route GET /api/v1/iam/roles - * @description Gets all roles. - * @access Private + * @openapi + * /v1/iam/roles: + * get: + * summary: List all roles + * description: Returns all IAM roles. If predefined roles do not yet exist, they are created automatically. Requires `read:roles` permission. + * operationId: getRoles + * tags: + * - IAM + * security: + * - bearerAuth: [] + * - apiKeyAuth: [] + * responses: + * '200': + * description: List of roles. + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: '#/components/schemas/Role' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '500': + * $ref: '#/components/responses/InternalServerError' */ router.get('/roles', requirePermission('read', 'roles'), iamController.getRoles); + /** + * @openapi + * /v1/iam/roles/{id}: + * get: + * summary: Get a role + * description: Returns a single IAM role by ID. Requires `read:roles` permission. + * operationId: getRoleById + * tags: + * - IAM + * security: + * - bearerAuth: [] + * - apiKeyAuth: [] + * parameters: + * - name: id + * in: path + * required: true + * schema: + * type: string + * example: "clx1y2z3a0000b4d2" + * responses: + * '200': + * description: Role details. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Role' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '404': + * $ref: '#/components/responses/NotFound' + * '500': + * $ref: '#/components/responses/InternalServerError' + */ router.get('/roles/:id', requirePermission('read', 'roles'), iamController.getRoleById); /** - * Only super admin has the ability to modify existing roles or create new roles. + * @openapi + * /v1/iam/roles: + * post: + * summary: Create a role + * description: Creates a new IAM role with the given name and CASL policies. Requires `manage:all` (Super Admin) permission. + * operationId: createRole + * tags: + * - IAM + * security: + * - bearerAuth: [] + * - apiKeyAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - name + * - policies + * properties: + * name: + * type: string + * example: "Compliance Officer" + * policies: + * type: array + * items: + * $ref: '#/components/schemas/CaslPolicy' + * responses: + * '201': + * description: Role created. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Role' + * '400': + * description: Missing fields or invalid policy. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorMessage' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + * '500': + * $ref: '#/components/responses/InternalServerError' */ router.post( '/roles', @@ -27,12 +127,94 @@ export const createIamRouter = (iamController: IamController, authService: AuthS iamController.createRole ); + /** + * @openapi + * /v1/iam/roles/{id}: + * delete: + * summary: Delete a role + * description: Permanently deletes an IAM role. Requires `manage:all` (Super Admin) permission. + * operationId: deleteRole + * tags: + * - IAM + * security: + * - bearerAuth: [] + * - apiKeyAuth: [] + * parameters: + * - name: id + * in: path + * required: true + * schema: + * type: string + * example: "clx1y2z3a0000b4d2" + * responses: + * '204': + * description: Role deleted. No content returned. + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + * '500': + * $ref: '#/components/responses/InternalServerError' + */ router.delete( '/roles/:id', requirePermission('manage', 'all', 'iam.requiresSuperAdminRole'), iamController.deleteRole ); + /** + * @openapi + * /v1/iam/roles/{id}: + * put: + * summary: Update a role + * description: Updates the name or policies of an IAM role. Requires `manage:all` (Super Admin) permission. + * operationId: updateRole + * tags: + * - IAM + * security: + * - bearerAuth: [] + * - apiKeyAuth: [] + * parameters: + * - name: id + * in: path + * required: true + * schema: + * type: string + * example: "clx1y2z3a0000b4d2" + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * name: + * type: string + * example: "Senior Compliance Officer" + * policies: + * type: array + * items: + * $ref: '#/components/schemas/CaslPolicy' + * responses: + * '200': + * description: Updated role. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Role' + * '400': + * description: No update fields provided or invalid policy. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorMessage' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + * '500': + * $ref: '#/components/responses/InternalServerError' + */ router.put( '/roles/:id', requirePermission('manage', 'all', 'iam.requiresSuperAdminRole'), diff --git a/packages/backend/src/api/routes/ingestion.routes.ts b/packages/backend/src/api/routes/ingestion.routes.ts index ad4071c8..7d3efe22 100644 --- a/packages/backend/src/api/routes/ingestion.routes.ts +++ b/packages/backend/src/api/routes/ingestion.routes.ts @@ -13,24 +13,278 @@ export const createIngestionRouter = ( // Secure all routes in this module router.use(requireAuth(authService)); + /** + * @openapi + * /v1/ingestion-sources: + * post: + * summary: Create an ingestion source + * description: Creates a new ingestion source and validates the connection. Returns the created source without credentials. Requires `create:ingestion` permission. + * operationId: createIngestionSource + * tags: + * - Ingestion + * security: + * - bearerAuth: [] + * - apiKeyAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/CreateIngestionSourceDto' + * responses: + * '201': + * description: Ingestion source created successfully. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/SafeIngestionSource' + * '400': + * description: Invalid input or connection test failed. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorMessage' + * '401': + * $ref: '#/components/responses/Unauthorized' + * get: + * summary: List ingestion sources + * description: Returns all ingestion sources accessible to the authenticated user. Credentials are excluded from the response. Requires `read:ingestion` permission. + * operationId: listIngestionSources + * tags: + * - Ingestion + * security: + * - bearerAuth: [] + * - apiKeyAuth: [] + * responses: + * '200': + * description: Array of ingestion sources. + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: '#/components/schemas/SafeIngestionSource' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '500': + * $ref: '#/components/responses/InternalServerError' + */ router.post('/', requirePermission('create', 'ingestion'), ingestionController.create); router.get('/', requirePermission('read', 'ingestion'), ingestionController.findAll); + /** + * @openapi + * /v1/ingestion-sources/{id}: + * get: + * summary: Get an ingestion source + * description: Returns a single ingestion source by ID. Credentials are excluded. Requires `read:ingestion` permission. + * operationId: getIngestionSourceById + * tags: + * - Ingestion + * security: + * - bearerAuth: [] + * - apiKeyAuth: [] + * parameters: + * - name: id + * in: path + * required: true + * schema: + * type: string + * example: "clx1y2z3a0000b4d2" + * responses: + * '200': + * description: Ingestion source details. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/SafeIngestionSource' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '404': + * $ref: '#/components/responses/NotFound' + * '500': + * $ref: '#/components/responses/InternalServerError' + * put: + * summary: Update an ingestion source + * description: Updates configuration for an existing ingestion source. Requires `update:ingestion` permission. + * operationId: updateIngestionSource + * tags: + * - Ingestion + * security: + * - bearerAuth: [] + * - apiKeyAuth: [] + * parameters: + * - name: id + * in: path + * required: true + * schema: + * type: string + * example: "clx1y2z3a0000b4d2" + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/UpdateIngestionSourceDto' + * responses: + * '200': + * description: Updated ingestion source. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/SafeIngestionSource' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '404': + * $ref: '#/components/responses/NotFound' + * '500': + * $ref: '#/components/responses/InternalServerError' + * delete: + * summary: Delete an ingestion source + * description: Permanently deletes an ingestion source. Deletion must be enabled in system settings. Requires `delete:ingestion` permission. + * operationId: deleteIngestionSource + * tags: + * - Ingestion + * security: + * - bearerAuth: [] + * - apiKeyAuth: [] + * parameters: + * - name: id + * in: path + * required: true + * schema: + * type: string + * example: "clx1y2z3a0000b4d2" + * responses: + * '204': + * description: Ingestion source deleted. No content returned. + * '400': + * description: Deletion disabled or constraint error. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorMessage' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '404': + * $ref: '#/components/responses/NotFound' + * '500': + * $ref: '#/components/responses/InternalServerError' + */ router.get('/:id', requirePermission('read', 'ingestion'), ingestionController.findById); router.put('/:id', requirePermission('update', 'ingestion'), ingestionController.update); router.delete('/:id', requirePermission('delete', 'ingestion'), ingestionController.delete); + /** + * @openapi + * /v1/ingestion-sources/{id}/import: + * post: + * summary: Trigger initial import + * description: Enqueues an initial import job for the ingestion source. This imports all historical emails. Requires `create:ingestion` permission. + * operationId: triggerInitialImport + * tags: + * - Ingestion + * security: + * - bearerAuth: [] + * - apiKeyAuth: [] + * parameters: + * - name: id + * in: path + * required: true + * schema: + * type: string + * example: "clx1y2z3a0000b4d2" + * responses: + * '202': + * description: Initial import job accepted and queued. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/MessageResponse' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '404': + * $ref: '#/components/responses/NotFound' + * '500': + * $ref: '#/components/responses/InternalServerError' + */ router.post( '/:id/import', requirePermission('create', 'ingestion'), ingestionController.triggerInitialImport ); + /** + * @openapi + * /v1/ingestion-sources/{id}/pause: + * post: + * summary: Pause an ingestion source + * description: Sets the ingestion source status to `paused`, stopping continuous sync. Requires `update:ingestion` permission. + * operationId: pauseIngestionSource + * tags: + * - Ingestion + * security: + * - bearerAuth: [] + * - apiKeyAuth: [] + * parameters: + * - name: id + * in: path + * required: true + * schema: + * type: string + * example: "clx1y2z3a0000b4d2" + * responses: + * '200': + * description: Ingestion source paused. Returns the updated source. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/SafeIngestionSource' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '404': + * $ref: '#/components/responses/NotFound' + * '500': + * $ref: '#/components/responses/InternalServerError' + */ router.post('/:id/pause', requirePermission('update', 'ingestion'), ingestionController.pause); + /** + * @openapi + * /v1/ingestion-sources/{id}/sync: + * post: + * summary: Force sync + * description: Triggers an out-of-schedule continuous sync for the ingestion source. Requires `sync:ingestion` permission. + * operationId: triggerForceSync + * tags: + * - Ingestion + * security: + * - bearerAuth: [] + * - apiKeyAuth: [] + * parameters: + * - name: id + * in: path + * required: true + * schema: + * type: string + * example: "clx1y2z3a0000b4d2" + * responses: + * '202': + * description: Force sync job accepted and queued. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/MessageResponse' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '404': + * $ref: '#/components/responses/NotFound' + * '500': + * $ref: '#/components/responses/InternalServerError' + */ router.post( '/:id/sync', requirePermission('sync', 'ingestion'), diff --git a/packages/backend/src/api/routes/integrity.routes.ts b/packages/backend/src/api/routes/integrity.routes.ts index bcd5d74e..b6806bc9 100644 --- a/packages/backend/src/api/routes/integrity.routes.ts +++ b/packages/backend/src/api/routes/integrity.routes.ts @@ -10,6 +10,49 @@ export const integrityRoutes = (authService: AuthService): Router => { router.use(requireAuth(authService)); + /** + * @openapi + * /v1/integrity/{id}: + * get: + * summary: Check email integrity + * description: Verifies the SHA-256 hash of an archived email and all its attachments against the hashes stored at archival time. Returns per-item integrity results. Requires `read:archive` permission. + * operationId: checkIntegrity + * tags: + * - Integrity + * security: + * - bearerAuth: [] + * - apiKeyAuth: [] + * parameters: + * - name: id + * in: path + * required: true + * description: UUID of the archived email to verify. + * schema: + * type: string + * format: uuid + * example: "550e8400-e29b-41d4-a716-446655440000" + * responses: + * '200': + * description: Integrity check results for the email and its attachments. + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: '#/components/schemas/IntegrityCheckResult' + * '400': + * description: Invalid UUID format. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ValidationError' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '404': + * $ref: '#/components/responses/NotFound' + * '500': + * $ref: '#/components/responses/InternalServerError' + */ router.get('/:id', requirePermission('read', 'archive'), controller.checkIntegrity); return router; diff --git a/packages/backend/src/api/routes/jobs.routes.ts b/packages/backend/src/api/routes/jobs.routes.ts index 9387c14d..c2e60179 100644 --- a/packages/backend/src/api/routes/jobs.routes.ts +++ b/packages/backend/src/api/routes/jobs.routes.ts @@ -10,11 +10,121 @@ export const createJobsRouter = (authService: AuthService): Router => { router.use(requireAuth(authService)); + /** + * @openapi + * /v1/jobs/queues: + * get: + * summary: List all queues + * description: Returns all BullMQ job queues and their current job counts broken down by status. Requires `manage:all` (Super Admin) permission. + * operationId: getQueues + * tags: + * - Jobs + * security: + * - bearerAuth: [] + * - apiKeyAuth: [] + * responses: + * '200': + * description: List of queue overviews. + * content: + * application/json: + * schema: + * type: object + * properties: + * queues: + * type: array + * items: + * $ref: '#/components/schemas/QueueOverview' + * example: + * queues: + * - name: ingestion + * counts: + * active: 0 + * completed: 56 + * failed: 4 + * delayed: 3 + * waiting: 0 + * paused: 0 + * - name: indexing + * counts: + * active: 0 + * completed: 0 + * failed: 0 + * delayed: 0 + * waiting: 0 + * paused: 0 + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + * '500': + * $ref: '#/components/responses/InternalServerError' + */ router.get( '/queues', requirePermission('manage', 'all', 'user.requiresSuperAdminRole'), jobsController.getQueues ); + + /** + * @openapi + * /v1/jobs/queues/{queueName}: + * get: + * summary: Get jobs in a queue + * description: Returns a paginated list of jobs within a specific queue, filtered by status. Requires `manage:all` (Super Admin) permission. + * operationId: getQueueJobs + * tags: + * - Jobs + * security: + * - bearerAuth: [] + * - apiKeyAuth: [] + * parameters: + * - name: queueName + * in: path + * required: true + * description: The name of the queue (e.g. `ingestion` or `indexing`). + * schema: + * type: string + * example: ingestion + * - name: status + * in: query + * required: false + * description: Filter jobs by status. + * schema: + * type: string + * enum: [active, completed, failed, delayed, waiting, paused] + * default: failed + * - name: page + * in: query + * required: false + * schema: + * type: integer + * default: 1 + * - name: limit + * in: query + * required: false + * schema: + * type: integer + * default: 10 + * responses: + * '200': + * description: Detailed view of the queue including paginated jobs. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/QueueDetails' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + * '404': + * description: Queue not found. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorMessage' + * '500': + * $ref: '#/components/responses/InternalServerError' + */ router.get( '/queues/:queueName', requirePermission('manage', 'all', 'user.requiresSuperAdminRole'), diff --git a/packages/backend/src/api/routes/search.routes.ts b/packages/backend/src/api/routes/search.routes.ts index 78ee9a33..be59c38a 100644 --- a/packages/backend/src/api/routes/search.routes.ts +++ b/packages/backend/src/api/routes/search.routes.ts @@ -12,6 +12,68 @@ export const createSearchRouter = ( router.use(requireAuth(authService)); + /** + * @openapi + * /v1/search: + * get: + * summary: Search archived emails + * description: Performs a full-text search across indexed archived emails using Meilisearch. Requires `search:archive` permission. + * operationId: searchEmails + * tags: + * - Search + * security: + * - bearerAuth: [] + * - apiKeyAuth: [] + * parameters: + * - name: keywords + * in: query + * required: true + * description: The search query string. + * schema: + * type: string + * example: "invoice Q4" + * - name: page + * in: query + * required: false + * description: Page number for pagination. + * schema: + * type: integer + * default: 1 + * example: 1 + * - name: limit + * in: query + * required: false + * description: Number of results per page. + * schema: + * type: integer + * default: 10 + * example: 10 + * - name: matchingStrategy + * in: query + * required: false + * description: Meilisearch matching strategy. `last` returns results containing at least one keyword; `all` requires all keywords; `frequency` sorts by keyword frequency. + * schema: + * type: string + * enum: [last, all, frequency] + * default: last + * responses: + * '200': + * description: Search results. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/SearchResults' + * '400': + * description: Keywords parameter is required. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorMessage' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '500': + * $ref: '#/components/responses/InternalServerError' + */ router.get('/', requirePermission('search', 'archive'), searchController.search); return router; diff --git a/packages/backend/src/api/routes/settings.routes.ts b/packages/backend/src/api/routes/settings.routes.ts index 6d2d430f..8b3317e1 100644 --- a/packages/backend/src/api/routes/settings.routes.ts +++ b/packages/backend/src/api/routes/settings.routes.ts @@ -7,10 +7,56 @@ import { AuthService } from '../../services/AuthService'; export const createSettingsRouter = (authService: AuthService): Router => { const router = Router(); - // Public route to get non-sensitive settings. settings read should not be scoped with a permission because all end users need the settings data in the frontend. However, for sensitive settings data, we need to add a new permission subject to limit access. So this route should only expose non-sensitive settings data. /** - * @returns SystemSettings + * @openapi + * /v1/settings/system: + * get: + * summary: Get system settings + * description: > + * Returns non-sensitive system settings such as language, timezone, and feature flags. + * This endpoint is public — no authentication required. Sensitive settings are never exposed. + * operationId: getSystemSettings + * tags: + * - Settings + * responses: + * '200': + * description: Current system settings. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/SystemSettings' + * '500': + * $ref: '#/components/responses/InternalServerError' + * put: + * summary: Update system settings + * description: Updates system settings. Requires `manage:settings` permission. + * operationId: updateSystemSettings + * tags: + * - Settings + * security: + * - bearerAuth: [] + * - apiKeyAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/SystemSettings' + * responses: + * '200': + * description: Updated system settings. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/SystemSettings' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + * '500': + * $ref: '#/components/responses/InternalServerError' */ + // Public route to get non-sensitive settings. All end users need the settings data in the frontend. router.get('/system', settingsController.getSystemSettings); // Protected route to update settings diff --git a/packages/backend/src/api/routes/storage.routes.ts b/packages/backend/src/api/routes/storage.routes.ts index e2b88f68..1e37bcb4 100644 --- a/packages/backend/src/api/routes/storage.routes.ts +++ b/packages/backend/src/api/routes/storage.routes.ts @@ -13,6 +13,60 @@ export const createStorageRouter = ( // Secure all routes in this module router.use(requireAuth(authService)); + /** + * @openapi + * /v1/storage/download: + * get: + * summary: Download a stored file + * description: > + * Downloads a file from the configured storage backend (local filesystem or S3-compatible). + * The path is sanitized to prevent directory traversal attacks. + * Requires `read:archive` permission. + * operationId: downloadFile + * tags: + * - Storage + * security: + * - bearerAuth: [] + * - apiKeyAuth: [] + * parameters: + * - name: path + * in: query + * required: true + * description: The relative storage path of the file to download. + * schema: + * type: string + * example: "open-archiver/emails/abc123.eml" + * responses: + * '200': + * description: The file content as a binary stream. The `Content-Disposition` header is set to trigger a browser download. + * headers: + * Content-Disposition: + * description: Attachment filename. + * schema: + * type: string + * example: 'attachment; filename="abc123.eml"' + * content: + * application/octet-stream: + * schema: + * type: string + * format: binary + * '400': + * description: File path is required or invalid. + * content: + * text/plain: + * schema: + * type: string + * '401': + * $ref: '#/components/responses/Unauthorized' + * '404': + * description: File not found in storage. + * content: + * text/plain: + * schema: + * type: string + * '500': + * $ref: '#/components/responses/InternalServerError' + */ router.get('/download', requirePermission('read', 'archive'), storageController.downloadFile); return router; diff --git a/packages/backend/src/api/routes/upload.routes.ts b/packages/backend/src/api/routes/upload.routes.ts index 10f61e77..5b340889 100644 --- a/packages/backend/src/api/routes/upload.routes.ts +++ b/packages/backend/src/api/routes/upload.routes.ts @@ -9,6 +9,55 @@ export const createUploadRouter = (authService: AuthService): Router => { router.use(requireAuth(authService)); + /** + * @openapi + * /v1/upload: + * post: + * summary: Upload a file + * description: > + * Uploads a file (PST, EML, MBOX, or other) to temporary storage for subsequent use in an ingestion source. + * Returns the storage path, which should be passed as `uploadedFilePath` when creating a file-based ingestion source. + * Requires `create:ingestion` permission. + * operationId: uploadFile + * tags: + * - Upload + * security: + * - bearerAuth: [] + * - apiKeyAuth: [] + * requestBody: + * required: true + * content: + * multipart/form-data: + * schema: + * type: object + * properties: + * file: + * type: string + * format: binary + * description: The file to upload. + * responses: + * '200': + * description: File uploaded successfully. Returns the storage path. + * content: + * application/json: + * schema: + * type: object + * properties: + * filePath: + * type: string + * description: The storage path of the uploaded file. Use this as `uploadedFilePath` when creating a file-based ingestion source. + * example: "open-archiver/tmp/uuid-filename.pst" + * '400': + * description: Invalid multipart request. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorMessage' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '500': + * $ref: '#/components/responses/InternalServerError' + */ router.post('/', requirePermission('create', 'ingestion'), uploadFile); return router; diff --git a/packages/backend/src/api/routes/user.routes.ts b/packages/backend/src/api/routes/user.routes.ts index 1aeac875..8578bc63 100644 --- a/packages/backend/src/api/routes/user.routes.ts +++ b/packages/backend/src/api/routes/user.routes.ts @@ -9,16 +9,235 @@ export const createUserRouter = (authService: AuthService): Router => { router.use(requireAuth(authService)); + /** + * @openapi + * /v1/users: + * get: + * summary: List all users + * description: Returns all user accounts in the system. Requires `read:users` permission. + * operationId: getUsers + * tags: + * - Users + * security: + * - bearerAuth: [] + * - apiKeyAuth: [] + * responses: + * '200': + * description: List of users. + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: '#/components/schemas/User' + * '401': + * $ref: '#/components/responses/Unauthorized' + */ router.get('/', requirePermission('read', 'users'), userController.getUsers); + /** + * @openapi + * /v1/users/profile: + * get: + * summary: Get current user profile + * description: Returns the profile of the currently authenticated user. + * operationId: getProfile + * tags: + * - Users + * security: + * - bearerAuth: [] + * - apiKeyAuth: [] + * responses: + * '200': + * description: Current user's profile. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/User' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '404': + * $ref: '#/components/responses/NotFound' + * patch: + * summary: Update current user profile + * description: Updates the email, first name, or last name of the currently authenticated user. Disabled in demo mode. + * operationId: updateProfile + * tags: + * - Users + * security: + * - bearerAuth: [] + * - apiKeyAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * email: + * type: string + * format: email + * first_name: + * type: string + * last_name: + * type: string + * responses: + * '200': + * description: Updated user profile. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/User' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * description: Disabled in demo mode. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorMessage' + */ router.get('/profile', userController.getProfile); router.patch('/profile', userController.updateProfile); + + /** + * @openapi + * /v1/users/profile/password: + * post: + * summary: Update password + * description: Updates the password of the currently authenticated user. The current password must be provided for verification. Disabled in demo mode. + * operationId: updatePassword + * tags: + * - Users + * security: + * - bearerAuth: [] + * - apiKeyAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - currentPassword + * - newPassword + * properties: + * currentPassword: + * type: string + * format: password + * newPassword: + * type: string + * format: password + * responses: + * '200': + * description: Password updated successfully. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/MessageResponse' + * '400': + * description: Current password is incorrect. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorMessage' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * description: Disabled in demo mode. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorMessage' + */ router.post('/profile/password', userController.updatePassword); + /** + * @openapi + * /v1/users/{id}: + * get: + * summary: Get a user + * description: Returns a single user by ID. Requires `read:users` permission. + * operationId: getUser + * tags: + * - Users + * security: + * - bearerAuth: [] + * - apiKeyAuth: [] + * parameters: + * - name: id + * in: path + * required: true + * schema: + * type: string + * example: "clx1y2z3a0000b4d2" + * responses: + * '200': + * description: User details. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/User' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '404': + * $ref: '#/components/responses/NotFound' + */ router.get('/:id', requirePermission('read', 'users'), userController.getUser); /** - * Only super admin has the ability to modify existing users or create new users. + * @openapi + * /v1/users: + * post: + * summary: Create a user + * description: Creates a new user account and optionally assigns a role. Requires `manage:all` (Super Admin) permission. + * operationId: createUser + * tags: + * - Users + * security: + * - bearerAuth: [] + * - apiKeyAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - email + * - first_name + * - last_name + * - password + * properties: + * email: + * type: string + * format: email + * example: jane.doe@example.com + * first_name: + * type: string + * example: Jane + * last_name: + * type: string + * example: Doe + * password: + * type: string + * format: password + * example: "securepassword123" + * roleId: + * type: string + * description: Optional role ID to assign to the user. + * example: "clx1y2z3a0000b4d2" + * responses: + * '201': + * description: User created. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/User' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' */ router.post( '/', @@ -26,12 +245,94 @@ export const createUserRouter = (authService: AuthService): Router => { userController.createUser ); + /** + * @openapi + * /v1/users/{id}: + * put: + * summary: Update a user + * description: Updates a user's email, name, or role assignment. Requires `manage:all` (Super Admin) permission. + * operationId: updateUser + * tags: + * - Users + * security: + * - bearerAuth: [] + * - apiKeyAuth: [] + * parameters: + * - name: id + * in: path + * required: true + * schema: + * type: string + * example: "clx1y2z3a0000b4d2" + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * email: + * type: string + * format: email + * first_name: + * type: string + * last_name: + * type: string + * roleId: + * type: string + * responses: + * '200': + * description: Updated user. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/User' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + * '404': + * $ref: '#/components/responses/NotFound' + */ router.put( '/:id', requirePermission('manage', 'all', 'user.requiresSuperAdminRole'), userController.updateUser ); + /** + * @openapi + * /v1/users/{id}: + * delete: + * summary: Delete a user + * description: Permanently deletes a user. Cannot delete the last remaining user. Requires `manage:all` (Super Admin) permission. + * operationId: deleteUser + * tags: + * - Users + * security: + * - bearerAuth: [] + * - apiKeyAuth: [] + * parameters: + * - name: id + * in: path + * required: true + * schema: + * type: string + * example: "clx1y2z3a0000b4d2" + * responses: + * '204': + * description: User deleted. No content returned. + * '400': + * description: Cannot delete the only remaining user. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorMessage' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + */ router.delete( '/:id', requirePermission('manage', 'all', 'user.requiresSuperAdminRole'), diff --git a/packages/backend/src/api/server.ts b/packages/backend/src/api/server.ts index f3f74a5d..892ba813 100644 --- a/packages/backend/src/api/server.ts +++ b/packages/backend/src/api/server.ts @@ -158,13 +158,12 @@ export async function createServer(modules: ArchiverModule[] = []): Promise { res.send('Backend is running!!'); }); - - console.log('✅ Core OSS modules loaded.'); + logger.info('✅ Core OSS modules loaded.'); return app; } diff --git a/packages/backend/src/database/migrations/0027_black_morph.sql b/packages/backend/src/database/migrations/0027_black_morph.sql new file mode 100644 index 00000000..5a58bfda --- /dev/null +++ b/packages/backend/src/database/migrations/0027_black_morph.sql @@ -0,0 +1,12 @@ +CREATE TABLE "sync_sessions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "ingestion_source_id" uuid NOT NULL, + "is_initial_import" boolean DEFAULT false NOT NULL, + "total_mailboxes" integer DEFAULT 0 NOT NULL, + "completed_mailboxes" integer DEFAULT 0 NOT NULL, + "failed_mailboxes" integer DEFAULT 0 NOT NULL, + "error_messages" text[] DEFAULT '{}' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "sync_sessions" ADD CONSTRAINT "sync_sessions_ingestion_source_id_ingestion_sources_id_fk" FOREIGN KEY ("ingestion_source_id") REFERENCES "public"."ingestion_sources"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/packages/backend/src/database/migrations/0028_youthful_kitty_pryde.sql b/packages/backend/src/database/migrations/0028_youthful_kitty_pryde.sql new file mode 100644 index 00000000..86ca3b9b --- /dev/null +++ b/packages/backend/src/database/migrations/0028_youthful_kitty_pryde.sql @@ -0,0 +1 @@ +ALTER TABLE "sync_sessions" ADD COLUMN "last_activity_at" timestamp with time zone DEFAULT now() NOT NULL; \ No newline at end of file diff --git a/packages/backend/src/database/migrations/0029_lethal_brood.sql b/packages/backend/src/database/migrations/0029_lethal_brood.sql new file mode 100644 index 00000000..6b9e6b89 --- /dev/null +++ b/packages/backend/src/database/migrations/0029_lethal_brood.sql @@ -0,0 +1,2 @@ +ALTER TABLE "archived_emails" ADD COLUMN "provider_message_id" text;--> statement-breakpoint +CREATE INDEX "provider_msg_source_idx" ON "archived_emails" USING btree ("provider_message_id","ingestion_source_id"); \ No newline at end of file diff --git a/packages/backend/src/database/migrations/meta/0024_snapshot.json b/packages/backend/src/database/migrations/meta/0024_snapshot.json index f752132c..10da02a3 100644 --- a/packages/backend/src/database/migrations/meta/0024_snapshot.json +++ b/packages/backend/src/database/migrations/meta/0024_snapshot.json @@ -1,1551 +1,1460 @@ { - "id": "d713dd13-babf-4b31-9c4c-76a3a5fd731a", - "prevId": "2747b009-4502-4e19-a725-1c5e9807c52b", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.archived_emails": { - "name": "archived_emails", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "thread_id": { - "name": "thread_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "ingestion_source_id": { - "name": "ingestion_source_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "user_email": { - "name": "user_email", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "message_id_header": { - "name": "message_id_header", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "sent_at": { - "name": "sent_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "subject": { - "name": "subject", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "sender_name": { - "name": "sender_name", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "sender_email": { - "name": "sender_email", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "recipients": { - "name": "recipients", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "storage_path": { - "name": "storage_path", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "storage_hash_sha256": { - "name": "storage_hash_sha256", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "size_bytes": { - "name": "size_bytes", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "is_indexed": { - "name": "is_indexed", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "has_attachments": { - "name": "has_attachments", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "is_on_legal_hold": { - "name": "is_on_legal_hold", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "archived_at": { - "name": "archived_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "path": { - "name": "path", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "tags": { - "name": "tags", - "type": "jsonb", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "thread_id_idx": { - "name": "thread_id_idx", - "columns": [ - { - "expression": "thread_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "archived_emails_ingestion_source_id_ingestion_sources_id_fk": { - "name": "archived_emails_ingestion_source_id_ingestion_sources_id_fk", - "tableFrom": "archived_emails", - "tableTo": "ingestion_sources", - "columnsFrom": [ - "ingestion_source_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.attachments": { - "name": "attachments", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "filename": { - "name": "filename", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "mime_type": { - "name": "mime_type", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "size_bytes": { - "name": "size_bytes", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "content_hash_sha256": { - "name": "content_hash_sha256", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "storage_path": { - "name": "storage_path", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "ingestion_source_id": { - "name": "ingestion_source_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "source_hash_idx": { - "name": "source_hash_idx", - "columns": [ - { - "expression": "ingestion_source_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "content_hash_sha256", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "attachments_ingestion_source_id_ingestion_sources_id_fk": { - "name": "attachments_ingestion_source_id_ingestion_sources_id_fk", - "tableFrom": "attachments", - "tableTo": "ingestion_sources", - "columnsFrom": [ - "ingestion_source_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.email_attachments": { - "name": "email_attachments", - "schema": "", - "columns": { - "email_id": { - "name": "email_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "attachment_id": { - "name": "attachment_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": { - "email_attachments_email_id_archived_emails_id_fk": { - "name": "email_attachments_email_id_archived_emails_id_fk", - "tableFrom": "email_attachments", - "tableTo": "archived_emails", - "columnsFrom": [ - "email_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "email_attachments_attachment_id_attachments_id_fk": { - "name": "email_attachments_attachment_id_attachments_id_fk", - "tableFrom": "email_attachments", - "tableTo": "attachments", - "columnsFrom": [ - "attachment_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "restrict", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "email_attachments_email_id_attachment_id_pk": { - "name": "email_attachments_email_id_attachment_id_pk", - "columns": [ - "email_id", - "attachment_id" - ] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.audit_logs": { - "name": "audit_logs", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "bigserial", - "primaryKey": true, - "notNull": true - }, - "previous_hash": { - "name": "previous_hash", - "type": "varchar(64)", - "primaryKey": false, - "notNull": false - }, - "timestamp": { - "name": "timestamp", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "actor_identifier": { - "name": "actor_identifier", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "actor_ip": { - "name": "actor_ip", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "action_type": { - "name": "action_type", - "type": "audit_log_action", - "typeSchema": "public", - "primaryKey": false, - "notNull": true - }, - "target_type": { - "name": "target_type", - "type": "audit_log_target_type", - "typeSchema": "public", - "primaryKey": false, - "notNull": false - }, - "target_id": { - "name": "target_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "details": { - "name": "details", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "current_hash": { - "name": "current_hash", - "type": "varchar(64)", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.ediscovery_cases": { - "name": "ediscovery_cases", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'open'" - }, - "created_by_identifier": { - "name": "created_by_identifier", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "ediscovery_cases_name_unique": { - "name": "ediscovery_cases_name_unique", - "nullsNotDistinct": false, - "columns": [ - "name" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.email_legal_holds": { - "name": "email_legal_holds", - "schema": "", - "columns": { - "email_id": { - "name": "email_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "legal_hold_id": { - "name": "legal_hold_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": { - "email_legal_holds_email_id_archived_emails_id_fk": { - "name": "email_legal_holds_email_id_archived_emails_id_fk", - "tableFrom": "email_legal_holds", - "tableTo": "archived_emails", - "columnsFrom": [ - "email_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "email_legal_holds_legal_hold_id_legal_holds_id_fk": { - "name": "email_legal_holds_legal_hold_id_legal_holds_id_fk", - "tableFrom": "email_legal_holds", - "tableTo": "legal_holds", - "columnsFrom": [ - "legal_hold_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "email_legal_holds_email_id_legal_hold_id_pk": { - "name": "email_legal_holds_email_id_legal_hold_id_pk", - "columns": [ - "email_id", - "legal_hold_id" - ] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.email_retention_labels": { - "name": "email_retention_labels", - "schema": "", - "columns": { - "email_id": { - "name": "email_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "label_id": { - "name": "label_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "applied_at": { - "name": "applied_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "applied_by_user_id": { - "name": "applied_by_user_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": { - "email_retention_labels_email_id_archived_emails_id_fk": { - "name": "email_retention_labels_email_id_archived_emails_id_fk", - "tableFrom": "email_retention_labels", - "tableTo": "archived_emails", - "columnsFrom": [ - "email_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "email_retention_labels_label_id_retention_labels_id_fk": { - "name": "email_retention_labels_label_id_retention_labels_id_fk", - "tableFrom": "email_retention_labels", - "tableTo": "retention_labels", - "columnsFrom": [ - "label_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "email_retention_labels_applied_by_user_id_users_id_fk": { - "name": "email_retention_labels_applied_by_user_id_users_id_fk", - "tableFrom": "email_retention_labels", - "tableTo": "users", - "columnsFrom": [ - "applied_by_user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "email_retention_labels_email_id_label_id_pk": { - "name": "email_retention_labels_email_id_label_id_pk", - "columns": [ - "email_id", - "label_id" - ] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.export_jobs": { - "name": "export_jobs", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "case_id": { - "name": "case_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "format": { - "name": "format", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'pending'" - }, - "query": { - "name": "query", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "file_path": { - "name": "file_path", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_by_identifier": { - "name": "created_by_identifier", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "completed_at": { - "name": "completed_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": { - "export_jobs_case_id_ediscovery_cases_id_fk": { - "name": "export_jobs_case_id_ediscovery_cases_id_fk", - "tableFrom": "export_jobs", - "tableTo": "ediscovery_cases", - "columnsFrom": [ - "case_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.legal_holds": { - "name": "legal_holds", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "name": { - "name": "name", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "reason": { - "name": "reason", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "is_active": { - "name": "is_active", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": true - }, - "case_id": { - "name": "case_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "legal_holds_case_id_ediscovery_cases_id_fk": { - "name": "legal_holds_case_id_ediscovery_cases_id_fk", - "tableFrom": "legal_holds", - "tableTo": "ediscovery_cases", - "columnsFrom": [ - "case_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.retention_events": { - "name": "retention_events", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "event_name": { - "name": "event_name", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "event_type": { - "name": "event_type", - "type": "varchar(100)", - "primaryKey": false, - "notNull": true - }, - "event_timestamp": { - "name": "event_timestamp", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "target_criteria": { - "name": "target_criteria", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.retention_labels": { - "name": "retention_labels", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "name": { - "name": "name", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "retention_period_days": { - "name": "retention_period_days", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.retention_policies": { - "name": "retention_policies", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "priority": { - "name": "priority", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "retention_period_days": { - "name": "retention_period_days", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "action_on_expiry": { - "name": "action_on_expiry", - "type": "retention_action", - "typeSchema": "public", - "primaryKey": false, - "notNull": true - }, - "is_enabled": { - "name": "is_enabled", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": true - }, - "conditions": { - "name": "conditions", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "retention_policies_name_unique": { - "name": "retention_policies_name_unique", - "nullsNotDistinct": false, - "columns": [ - "name" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.custodians": { - "name": "custodians", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "email": { - "name": "email", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "display_name": { - "name": "display_name", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "source_type": { - "name": "source_type", - "type": "ingestion_provider", - "typeSchema": "public", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "custodians_email_unique": { - "name": "custodians_email_unique", - "nullsNotDistinct": false, - "columns": [ - "email" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.ingestion_sources": { - "name": "ingestion_sources", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "user_id": { - "name": "user_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "provider": { - "name": "provider", - "type": "ingestion_provider", - "typeSchema": "public", - "primaryKey": false, - "notNull": true - }, - "credentials": { - "name": "credentials", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "status": { - "name": "status", - "type": "ingestion_status", - "typeSchema": "public", - "primaryKey": false, - "notNull": true, - "default": "'pending_auth'" - }, - "last_sync_started_at": { - "name": "last_sync_started_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "last_sync_finished_at": { - "name": "last_sync_finished_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "last_sync_status_message": { - "name": "last_sync_status_message", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "sync_state": { - "name": "sync_state", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "ingestion_sources_user_id_users_id_fk": { - "name": "ingestion_sources_user_id_users_id_fk", - "tableFrom": "ingestion_sources", - "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.roles": { - "name": "roles", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "policies": { - "name": "policies", - "type": "jsonb", - "primaryKey": false, - "notNull": true, - "default": "'[]'::jsonb" - }, - "slug": { - "name": "slug", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "roles_name_unique": { - "name": "roles_name_unique", - "nullsNotDistinct": false, - "columns": [ - "name" - ] - }, - "roles_slug_unique": { - "name": "roles_slug_unique", - "nullsNotDistinct": false, - "columns": [ - "slug" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.sessions": { - "name": "sessions", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": { - "sessions_user_id_users_id_fk": { - "name": "sessions_user_id_users_id_fk", - "tableFrom": "sessions", - "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.user_roles": { - "name": "user_roles", - "schema": "", - "columns": { - "user_id": { - "name": "user_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "role_id": { - "name": "role_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": { - "user_roles_user_id_users_id_fk": { - "name": "user_roles_user_id_users_id_fk", - "tableFrom": "user_roles", - "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "user_roles_role_id_roles_id_fk": { - "name": "user_roles_role_id_roles_id_fk", - "tableFrom": "user_roles", - "tableTo": "roles", - "columnsFrom": [ - "role_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "user_roles_user_id_role_id_pk": { - "name": "user_roles_user_id_role_id_pk", - "columns": [ - "user_id", - "role_id" - ] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.users": { - "name": "users", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "email": { - "name": "email", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "first_name": { - "name": "first_name", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "last_name": { - "name": "last_name", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "password": { - "name": "password", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "provider": { - "name": "provider", - "type": "text", - "primaryKey": false, - "notNull": false, - "default": "'local'" - }, - "provider_id": { - "name": "provider_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "users_email_unique": { - "name": "users_email_unique", - "nullsNotDistinct": false, - "columns": [ - "email" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.system_settings": { - "name": "system_settings", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "serial", - "primaryKey": true, - "notNull": true - }, - "config": { - "name": "config", - "type": "jsonb", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.api_keys": { - "name": "api_keys", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "key": { - "name": "key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "key_hash": { - "name": "key_hash", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "api_keys_user_id_users_id_fk": { - "name": "api_keys_user_id_users_id_fk", - "tableFrom": "api_keys", - "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": { - "public.retention_action": { - "name": "retention_action", - "schema": "public", - "values": [ - "delete_permanently", - "notify_admin" - ] - }, - "public.ingestion_provider": { - "name": "ingestion_provider", - "schema": "public", - "values": [ - "google_workspace", - "microsoft_365", - "generic_imap", - "pst_import", - "eml_import", - "mbox_import" - ] - }, - "public.ingestion_status": { - "name": "ingestion_status", - "schema": "public", - "values": [ - "active", - "paused", - "error", - "pending_auth", - "syncing", - "importing", - "auth_success", - "imported" - ] - }, - "public.audit_log_action": { - "name": "audit_log_action", - "schema": "public", - "values": [ - "CREATE", - "READ", - "UPDATE", - "DELETE", - "LOGIN", - "LOGOUT", - "SETUP", - "IMPORT", - "PAUSE", - "SYNC", - "UPLOAD", - "SEARCH", - "DOWNLOAD", - "GENERATE" - ] - }, - "public.audit_log_target_type": { - "name": "audit_log_target_type", - "schema": "public", - "values": [ - "ApiKey", - "ArchivedEmail", - "Dashboard", - "IngestionSource", - "Role", - "SystemSettings", - "User", - "File" - ] - } - }, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} \ No newline at end of file + "id": "d713dd13-babf-4b31-9c4c-76a3a5fd731a", + "prevId": "2747b009-4502-4e19-a725-1c5e9807c52b", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.archived_emails": { + "name": "archived_emails", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ingestion_source_id": { + "name": "ingestion_source_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_email": { + "name": "user_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_id_header": { + "name": "message_id_header", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sender_name": { + "name": "sender_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sender_email": { + "name": "sender_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipients": { + "name": "recipients", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "storage_path": { + "name": "storage_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_hash_sha256": { + "name": "storage_hash_sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "is_indexed": { + "name": "is_indexed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "has_attachments": { + "name": "has_attachments", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_on_legal_hold": { + "name": "is_on_legal_hold", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "thread_id_idx": { + "name": "thread_id_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "archived_emails_ingestion_source_id_ingestion_sources_id_fk": { + "name": "archived_emails_ingestion_source_id_ingestion_sources_id_fk", + "tableFrom": "archived_emails", + "tableTo": "ingestion_sources", + "columnsFrom": ["ingestion_source_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.attachments": { + "name": "attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "content_hash_sha256": { + "name": "content_hash_sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_path": { + "name": "storage_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ingestion_source_id": { + "name": "ingestion_source_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "source_hash_idx": { + "name": "source_hash_idx", + "columns": [ + { + "expression": "ingestion_source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "content_hash_sha256", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "attachments_ingestion_source_id_ingestion_sources_id_fk": { + "name": "attachments_ingestion_source_id_ingestion_sources_id_fk", + "tableFrom": "attachments", + "tableTo": "ingestion_sources", + "columnsFrom": ["ingestion_source_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_attachments": { + "name": "email_attachments", + "schema": "", + "columns": { + "email_id": { + "name": "email_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "attachment_id": { + "name": "attachment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "email_attachments_email_id_archived_emails_id_fk": { + "name": "email_attachments_email_id_archived_emails_id_fk", + "tableFrom": "email_attachments", + "tableTo": "archived_emails", + "columnsFrom": ["email_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_attachments_attachment_id_attachments_id_fk": { + "name": "email_attachments_attachment_id_attachments_id_fk", + "tableFrom": "email_attachments", + "tableTo": "attachments", + "columnsFrom": ["attachment_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "email_attachments_email_id_attachment_id_pk": { + "name": "email_attachments_email_id_attachment_id_pk", + "columns": ["email_id", "attachment_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "previous_hash": { + "name": "previous_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "actor_identifier": { + "name": "actor_identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_ip": { + "name": "actor_ip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action_type": { + "name": "action_type", + "type": "audit_log_action", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "audit_log_target_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "current_hash": { + "name": "current_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ediscovery_cases": { + "name": "ediscovery_cases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "created_by_identifier": { + "name": "created_by_identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ediscovery_cases_name_unique": { + "name": "ediscovery_cases_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_legal_holds": { + "name": "email_legal_holds", + "schema": "", + "columns": { + "email_id": { + "name": "email_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "legal_hold_id": { + "name": "legal_hold_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "email_legal_holds_email_id_archived_emails_id_fk": { + "name": "email_legal_holds_email_id_archived_emails_id_fk", + "tableFrom": "email_legal_holds", + "tableTo": "archived_emails", + "columnsFrom": ["email_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_legal_holds_legal_hold_id_legal_holds_id_fk": { + "name": "email_legal_holds_legal_hold_id_legal_holds_id_fk", + "tableFrom": "email_legal_holds", + "tableTo": "legal_holds", + "columnsFrom": ["legal_hold_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "email_legal_holds_email_id_legal_hold_id_pk": { + "name": "email_legal_holds_email_id_legal_hold_id_pk", + "columns": ["email_id", "legal_hold_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_retention_labels": { + "name": "email_retention_labels", + "schema": "", + "columns": { + "email_id": { + "name": "email_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "label_id": { + "name": "label_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "applied_at": { + "name": "applied_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "applied_by_user_id": { + "name": "applied_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "email_retention_labels_email_id_archived_emails_id_fk": { + "name": "email_retention_labels_email_id_archived_emails_id_fk", + "tableFrom": "email_retention_labels", + "tableTo": "archived_emails", + "columnsFrom": ["email_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_retention_labels_label_id_retention_labels_id_fk": { + "name": "email_retention_labels_label_id_retention_labels_id_fk", + "tableFrom": "email_retention_labels", + "tableTo": "retention_labels", + "columnsFrom": ["label_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_retention_labels_applied_by_user_id_users_id_fk": { + "name": "email_retention_labels_applied_by_user_id_users_id_fk", + "tableFrom": "email_retention_labels", + "tableTo": "users", + "columnsFrom": ["applied_by_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "email_retention_labels_email_id_label_id_pk": { + "name": "email_retention_labels_email_id_label_id_pk", + "columns": ["email_id", "label_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.export_jobs": { + "name": "export_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "case_id": { + "name": "case_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "query": { + "name": "query", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_identifier": { + "name": "created_by_identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "export_jobs_case_id_ediscovery_cases_id_fk": { + "name": "export_jobs_case_id_ediscovery_cases_id_fk", + "tableFrom": "export_jobs", + "tableTo": "ediscovery_cases", + "columnsFrom": ["case_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.legal_holds": { + "name": "legal_holds", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "case_id": { + "name": "case_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "legal_holds_case_id_ediscovery_cases_id_fk": { + "name": "legal_holds_case_id_ediscovery_cases_id_fk", + "tableFrom": "legal_holds", + "tableTo": "ediscovery_cases", + "columnsFrom": ["case_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.retention_events": { + "name": "retention_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "event_name": { + "name": "event_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "event_timestamp": { + "name": "event_timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "target_criteria": { + "name": "target_criteria", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.retention_labels": { + "name": "retention_labels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "retention_period_days": { + "name": "retention_period_days", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.retention_policies": { + "name": "retention_policies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "retention_period_days": { + "name": "retention_period_days", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "action_on_expiry": { + "name": "action_on_expiry", + "type": "retention_action", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "conditions": { + "name": "conditions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "retention_policies_name_unique": { + "name": "retention_policies_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custodians": { + "name": "custodians", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "ingestion_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "custodians_email_unique": { + "name": "custodians_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ingestion_sources": { + "name": "ingestion_sources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "ingestion_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "credentials": { + "name": "credentials", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "ingestion_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending_auth'" + }, + "last_sync_started_at": { + "name": "last_sync_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_sync_finished_at": { + "name": "last_sync_finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_sync_status_message": { + "name": "last_sync_status_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_state": { + "name": "sync_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "ingestion_sources_user_id_users_id_fk": { + "name": "ingestion_sources_user_id_users_id_fk", + "tableFrom": "ingestion_sources", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.roles": { + "name": "roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "policies": { + "name": "policies", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "roles_name_unique": { + "name": "roles_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + }, + "roles_slug_unique": { + "name": "roles_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_roles": { + "name": "user_roles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_roles_role_id_roles_id_fk": { + "name": "user_roles_role_id_roles_id_fk", + "tableFrom": "user_roles", + "tableTo": "roles", + "columnsFrom": ["role_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_roles_user_id_role_id_pk": { + "name": "user_roles_user_id_role_id_pk", + "columns": ["user_id", "role_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'local'" + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_settings": { + "name": "system_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "api_keys_user_id_users_id_fk": { + "name": "api_keys_user_id_users_id_fk", + "tableFrom": "api_keys", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.retention_action": { + "name": "retention_action", + "schema": "public", + "values": ["delete_permanently", "notify_admin"] + }, + "public.ingestion_provider": { + "name": "ingestion_provider", + "schema": "public", + "values": [ + "google_workspace", + "microsoft_365", + "generic_imap", + "pst_import", + "eml_import", + "mbox_import" + ] + }, + "public.ingestion_status": { + "name": "ingestion_status", + "schema": "public", + "values": [ + "active", + "paused", + "error", + "pending_auth", + "syncing", + "importing", + "auth_success", + "imported" + ] + }, + "public.audit_log_action": { + "name": "audit_log_action", + "schema": "public", + "values": [ + "CREATE", + "READ", + "UPDATE", + "DELETE", + "LOGIN", + "LOGOUT", + "SETUP", + "IMPORT", + "PAUSE", + "SYNC", + "UPLOAD", + "SEARCH", + "DOWNLOAD", + "GENERATE" + ] + }, + "public.audit_log_target_type": { + "name": "audit_log_target_type", + "schema": "public", + "values": [ + "ApiKey", + "ArchivedEmail", + "Dashboard", + "IngestionSource", + "Role", + "SystemSettings", + "User", + "File" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/backend/src/database/migrations/meta/0025_snapshot.json b/packages/backend/src/database/migrations/meta/0025_snapshot.json index 50e3a8fd..37c6cf39 100644 --- a/packages/backend/src/database/migrations/meta/0025_snapshot.json +++ b/packages/backend/src/database/migrations/meta/0025_snapshot.json @@ -1,1560 +1,1469 @@ { - "id": "fe6390c6-4fcf-4182-a7fb-fe929003aec0", - "prevId": "d713dd13-babf-4b31-9c4c-76a3a5fd731a", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.archived_emails": { - "name": "archived_emails", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "thread_id": { - "name": "thread_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "ingestion_source_id": { - "name": "ingestion_source_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "user_email": { - "name": "user_email", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "message_id_header": { - "name": "message_id_header", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "sent_at": { - "name": "sent_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "subject": { - "name": "subject", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "sender_name": { - "name": "sender_name", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "sender_email": { - "name": "sender_email", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "recipients": { - "name": "recipients", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "storage_path": { - "name": "storage_path", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "storage_hash_sha256": { - "name": "storage_hash_sha256", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "size_bytes": { - "name": "size_bytes", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "is_indexed": { - "name": "is_indexed", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "has_attachments": { - "name": "has_attachments", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "is_on_legal_hold": { - "name": "is_on_legal_hold", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "archived_at": { - "name": "archived_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "path": { - "name": "path", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "tags": { - "name": "tags", - "type": "jsonb", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "thread_id_idx": { - "name": "thread_id_idx", - "columns": [ - { - "expression": "thread_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "archived_emails_ingestion_source_id_ingestion_sources_id_fk": { - "name": "archived_emails_ingestion_source_id_ingestion_sources_id_fk", - "tableFrom": "archived_emails", - "tableTo": "ingestion_sources", - "columnsFrom": [ - "ingestion_source_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.attachments": { - "name": "attachments", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "filename": { - "name": "filename", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "mime_type": { - "name": "mime_type", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "size_bytes": { - "name": "size_bytes", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "content_hash_sha256": { - "name": "content_hash_sha256", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "storage_path": { - "name": "storage_path", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "ingestion_source_id": { - "name": "ingestion_source_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "source_hash_idx": { - "name": "source_hash_idx", - "columns": [ - { - "expression": "ingestion_source_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "content_hash_sha256", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "attachments_ingestion_source_id_ingestion_sources_id_fk": { - "name": "attachments_ingestion_source_id_ingestion_sources_id_fk", - "tableFrom": "attachments", - "tableTo": "ingestion_sources", - "columnsFrom": [ - "ingestion_source_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.email_attachments": { - "name": "email_attachments", - "schema": "", - "columns": { - "email_id": { - "name": "email_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "attachment_id": { - "name": "attachment_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": { - "email_attachments_email_id_archived_emails_id_fk": { - "name": "email_attachments_email_id_archived_emails_id_fk", - "tableFrom": "email_attachments", - "tableTo": "archived_emails", - "columnsFrom": [ - "email_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "email_attachments_attachment_id_attachments_id_fk": { - "name": "email_attachments_attachment_id_attachments_id_fk", - "tableFrom": "email_attachments", - "tableTo": "attachments", - "columnsFrom": [ - "attachment_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "restrict", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "email_attachments_email_id_attachment_id_pk": { - "name": "email_attachments_email_id_attachment_id_pk", - "columns": [ - "email_id", - "attachment_id" - ] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.audit_logs": { - "name": "audit_logs", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "bigserial", - "primaryKey": true, - "notNull": true - }, - "previous_hash": { - "name": "previous_hash", - "type": "varchar(64)", - "primaryKey": false, - "notNull": false - }, - "timestamp": { - "name": "timestamp", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "actor_identifier": { - "name": "actor_identifier", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "actor_ip": { - "name": "actor_ip", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "action_type": { - "name": "action_type", - "type": "audit_log_action", - "typeSchema": "public", - "primaryKey": false, - "notNull": true - }, - "target_type": { - "name": "target_type", - "type": "audit_log_target_type", - "typeSchema": "public", - "primaryKey": false, - "notNull": false - }, - "target_id": { - "name": "target_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "details": { - "name": "details", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "current_hash": { - "name": "current_hash", - "type": "varchar(64)", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.ediscovery_cases": { - "name": "ediscovery_cases", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'open'" - }, - "created_by_identifier": { - "name": "created_by_identifier", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "ediscovery_cases_name_unique": { - "name": "ediscovery_cases_name_unique", - "nullsNotDistinct": false, - "columns": [ - "name" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.email_legal_holds": { - "name": "email_legal_holds", - "schema": "", - "columns": { - "email_id": { - "name": "email_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "legal_hold_id": { - "name": "legal_hold_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": { - "email_legal_holds_email_id_archived_emails_id_fk": { - "name": "email_legal_holds_email_id_archived_emails_id_fk", - "tableFrom": "email_legal_holds", - "tableTo": "archived_emails", - "columnsFrom": [ - "email_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "email_legal_holds_legal_hold_id_legal_holds_id_fk": { - "name": "email_legal_holds_legal_hold_id_legal_holds_id_fk", - "tableFrom": "email_legal_holds", - "tableTo": "legal_holds", - "columnsFrom": [ - "legal_hold_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "email_legal_holds_email_id_legal_hold_id_pk": { - "name": "email_legal_holds_email_id_legal_hold_id_pk", - "columns": [ - "email_id", - "legal_hold_id" - ] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.email_retention_labels": { - "name": "email_retention_labels", - "schema": "", - "columns": { - "email_id": { - "name": "email_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "label_id": { - "name": "label_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "applied_at": { - "name": "applied_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "applied_by_user_id": { - "name": "applied_by_user_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": { - "email_retention_labels_email_id_archived_emails_id_fk": { - "name": "email_retention_labels_email_id_archived_emails_id_fk", - "tableFrom": "email_retention_labels", - "tableTo": "archived_emails", - "columnsFrom": [ - "email_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "email_retention_labels_label_id_retention_labels_id_fk": { - "name": "email_retention_labels_label_id_retention_labels_id_fk", - "tableFrom": "email_retention_labels", - "tableTo": "retention_labels", - "columnsFrom": [ - "label_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "email_retention_labels_applied_by_user_id_users_id_fk": { - "name": "email_retention_labels_applied_by_user_id_users_id_fk", - "tableFrom": "email_retention_labels", - "tableTo": "users", - "columnsFrom": [ - "applied_by_user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "email_retention_labels_email_id_label_id_pk": { - "name": "email_retention_labels_email_id_label_id_pk", - "columns": [ - "email_id", - "label_id" - ] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.export_jobs": { - "name": "export_jobs", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "case_id": { - "name": "case_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "format": { - "name": "format", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'pending'" - }, - "query": { - "name": "query", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "file_path": { - "name": "file_path", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_by_identifier": { - "name": "created_by_identifier", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "completed_at": { - "name": "completed_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": { - "export_jobs_case_id_ediscovery_cases_id_fk": { - "name": "export_jobs_case_id_ediscovery_cases_id_fk", - "tableFrom": "export_jobs", - "tableTo": "ediscovery_cases", - "columnsFrom": [ - "case_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.legal_holds": { - "name": "legal_holds", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "name": { - "name": "name", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "reason": { - "name": "reason", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "is_active": { - "name": "is_active", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": true - }, - "case_id": { - "name": "case_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "legal_holds_case_id_ediscovery_cases_id_fk": { - "name": "legal_holds_case_id_ediscovery_cases_id_fk", - "tableFrom": "legal_holds", - "tableTo": "ediscovery_cases", - "columnsFrom": [ - "case_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.retention_events": { - "name": "retention_events", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "event_name": { - "name": "event_name", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "event_type": { - "name": "event_type", - "type": "varchar(100)", - "primaryKey": false, - "notNull": true - }, - "event_timestamp": { - "name": "event_timestamp", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "target_criteria": { - "name": "target_criteria", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.retention_labels": { - "name": "retention_labels", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "name": { - "name": "name", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "retention_period_days": { - "name": "retention_period_days", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.retention_policies": { - "name": "retention_policies", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "priority": { - "name": "priority", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "retention_period_days": { - "name": "retention_period_days", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "action_on_expiry": { - "name": "action_on_expiry", - "type": "retention_action", - "typeSchema": "public", - "primaryKey": false, - "notNull": true - }, - "is_enabled": { - "name": "is_enabled", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": true - }, - "conditions": { - "name": "conditions", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "ingestion_scope": { - "name": "ingestion_scope", - "type": "jsonb", - "primaryKey": false, - "notNull": false, - "default": "'null'::jsonb" - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "retention_policies_name_unique": { - "name": "retention_policies_name_unique", - "nullsNotDistinct": false, - "columns": [ - "name" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.custodians": { - "name": "custodians", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "email": { - "name": "email", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "display_name": { - "name": "display_name", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "source_type": { - "name": "source_type", - "type": "ingestion_provider", - "typeSchema": "public", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "custodians_email_unique": { - "name": "custodians_email_unique", - "nullsNotDistinct": false, - "columns": [ - "email" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.ingestion_sources": { - "name": "ingestion_sources", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "user_id": { - "name": "user_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "provider": { - "name": "provider", - "type": "ingestion_provider", - "typeSchema": "public", - "primaryKey": false, - "notNull": true - }, - "credentials": { - "name": "credentials", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "status": { - "name": "status", - "type": "ingestion_status", - "typeSchema": "public", - "primaryKey": false, - "notNull": true, - "default": "'pending_auth'" - }, - "last_sync_started_at": { - "name": "last_sync_started_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "last_sync_finished_at": { - "name": "last_sync_finished_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "last_sync_status_message": { - "name": "last_sync_status_message", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "sync_state": { - "name": "sync_state", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "ingestion_sources_user_id_users_id_fk": { - "name": "ingestion_sources_user_id_users_id_fk", - "tableFrom": "ingestion_sources", - "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.roles": { - "name": "roles", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "policies": { - "name": "policies", - "type": "jsonb", - "primaryKey": false, - "notNull": true, - "default": "'[]'::jsonb" - }, - "slug": { - "name": "slug", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "roles_name_unique": { - "name": "roles_name_unique", - "nullsNotDistinct": false, - "columns": [ - "name" - ] - }, - "roles_slug_unique": { - "name": "roles_slug_unique", - "nullsNotDistinct": false, - "columns": [ - "slug" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.sessions": { - "name": "sessions", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": { - "sessions_user_id_users_id_fk": { - "name": "sessions_user_id_users_id_fk", - "tableFrom": "sessions", - "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.user_roles": { - "name": "user_roles", - "schema": "", - "columns": { - "user_id": { - "name": "user_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "role_id": { - "name": "role_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": { - "user_roles_user_id_users_id_fk": { - "name": "user_roles_user_id_users_id_fk", - "tableFrom": "user_roles", - "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "user_roles_role_id_roles_id_fk": { - "name": "user_roles_role_id_roles_id_fk", - "tableFrom": "user_roles", - "tableTo": "roles", - "columnsFrom": [ - "role_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "user_roles_user_id_role_id_pk": { - "name": "user_roles_user_id_role_id_pk", - "columns": [ - "user_id", - "role_id" - ] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.users": { - "name": "users", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "email": { - "name": "email", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "first_name": { - "name": "first_name", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "last_name": { - "name": "last_name", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "password": { - "name": "password", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "provider": { - "name": "provider", - "type": "text", - "primaryKey": false, - "notNull": false, - "default": "'local'" - }, - "provider_id": { - "name": "provider_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "users_email_unique": { - "name": "users_email_unique", - "nullsNotDistinct": false, - "columns": [ - "email" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.system_settings": { - "name": "system_settings", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "serial", - "primaryKey": true, - "notNull": true - }, - "config": { - "name": "config", - "type": "jsonb", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.api_keys": { - "name": "api_keys", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "key": { - "name": "key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "key_hash": { - "name": "key_hash", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "api_keys_user_id_users_id_fk": { - "name": "api_keys_user_id_users_id_fk", - "tableFrom": "api_keys", - "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": { - "public.retention_action": { - "name": "retention_action", - "schema": "public", - "values": [ - "delete_permanently", - "notify_admin" - ] - }, - "public.ingestion_provider": { - "name": "ingestion_provider", - "schema": "public", - "values": [ - "google_workspace", - "microsoft_365", - "generic_imap", - "pst_import", - "eml_import", - "mbox_import" - ] - }, - "public.ingestion_status": { - "name": "ingestion_status", - "schema": "public", - "values": [ - "active", - "paused", - "error", - "pending_auth", - "syncing", - "importing", - "auth_success", - "imported" - ] - }, - "public.audit_log_action": { - "name": "audit_log_action", - "schema": "public", - "values": [ - "CREATE", - "READ", - "UPDATE", - "DELETE", - "LOGIN", - "LOGOUT", - "SETUP", - "IMPORT", - "PAUSE", - "SYNC", - "UPLOAD", - "SEARCH", - "DOWNLOAD", - "GENERATE" - ] - }, - "public.audit_log_target_type": { - "name": "audit_log_target_type", - "schema": "public", - "values": [ - "ApiKey", - "ArchivedEmail", - "Dashboard", - "IngestionSource", - "RetentionPolicy", - "Role", - "SystemEvent", - "SystemSettings", - "User", - "File" - ] - } - }, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} \ No newline at end of file + "id": "fe6390c6-4fcf-4182-a7fb-fe929003aec0", + "prevId": "d713dd13-babf-4b31-9c4c-76a3a5fd731a", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.archived_emails": { + "name": "archived_emails", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ingestion_source_id": { + "name": "ingestion_source_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_email": { + "name": "user_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_id_header": { + "name": "message_id_header", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sender_name": { + "name": "sender_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sender_email": { + "name": "sender_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipients": { + "name": "recipients", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "storage_path": { + "name": "storage_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_hash_sha256": { + "name": "storage_hash_sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "is_indexed": { + "name": "is_indexed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "has_attachments": { + "name": "has_attachments", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_on_legal_hold": { + "name": "is_on_legal_hold", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "thread_id_idx": { + "name": "thread_id_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "archived_emails_ingestion_source_id_ingestion_sources_id_fk": { + "name": "archived_emails_ingestion_source_id_ingestion_sources_id_fk", + "tableFrom": "archived_emails", + "tableTo": "ingestion_sources", + "columnsFrom": ["ingestion_source_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.attachments": { + "name": "attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "content_hash_sha256": { + "name": "content_hash_sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_path": { + "name": "storage_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ingestion_source_id": { + "name": "ingestion_source_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "source_hash_idx": { + "name": "source_hash_idx", + "columns": [ + { + "expression": "ingestion_source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "content_hash_sha256", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "attachments_ingestion_source_id_ingestion_sources_id_fk": { + "name": "attachments_ingestion_source_id_ingestion_sources_id_fk", + "tableFrom": "attachments", + "tableTo": "ingestion_sources", + "columnsFrom": ["ingestion_source_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_attachments": { + "name": "email_attachments", + "schema": "", + "columns": { + "email_id": { + "name": "email_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "attachment_id": { + "name": "attachment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "email_attachments_email_id_archived_emails_id_fk": { + "name": "email_attachments_email_id_archived_emails_id_fk", + "tableFrom": "email_attachments", + "tableTo": "archived_emails", + "columnsFrom": ["email_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_attachments_attachment_id_attachments_id_fk": { + "name": "email_attachments_attachment_id_attachments_id_fk", + "tableFrom": "email_attachments", + "tableTo": "attachments", + "columnsFrom": ["attachment_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "email_attachments_email_id_attachment_id_pk": { + "name": "email_attachments_email_id_attachment_id_pk", + "columns": ["email_id", "attachment_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "previous_hash": { + "name": "previous_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "actor_identifier": { + "name": "actor_identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_ip": { + "name": "actor_ip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action_type": { + "name": "action_type", + "type": "audit_log_action", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "audit_log_target_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "current_hash": { + "name": "current_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ediscovery_cases": { + "name": "ediscovery_cases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "created_by_identifier": { + "name": "created_by_identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ediscovery_cases_name_unique": { + "name": "ediscovery_cases_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_legal_holds": { + "name": "email_legal_holds", + "schema": "", + "columns": { + "email_id": { + "name": "email_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "legal_hold_id": { + "name": "legal_hold_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "email_legal_holds_email_id_archived_emails_id_fk": { + "name": "email_legal_holds_email_id_archived_emails_id_fk", + "tableFrom": "email_legal_holds", + "tableTo": "archived_emails", + "columnsFrom": ["email_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_legal_holds_legal_hold_id_legal_holds_id_fk": { + "name": "email_legal_holds_legal_hold_id_legal_holds_id_fk", + "tableFrom": "email_legal_holds", + "tableTo": "legal_holds", + "columnsFrom": ["legal_hold_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "email_legal_holds_email_id_legal_hold_id_pk": { + "name": "email_legal_holds_email_id_legal_hold_id_pk", + "columns": ["email_id", "legal_hold_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_retention_labels": { + "name": "email_retention_labels", + "schema": "", + "columns": { + "email_id": { + "name": "email_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "label_id": { + "name": "label_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "applied_at": { + "name": "applied_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "applied_by_user_id": { + "name": "applied_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "email_retention_labels_email_id_archived_emails_id_fk": { + "name": "email_retention_labels_email_id_archived_emails_id_fk", + "tableFrom": "email_retention_labels", + "tableTo": "archived_emails", + "columnsFrom": ["email_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_retention_labels_label_id_retention_labels_id_fk": { + "name": "email_retention_labels_label_id_retention_labels_id_fk", + "tableFrom": "email_retention_labels", + "tableTo": "retention_labels", + "columnsFrom": ["label_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_retention_labels_applied_by_user_id_users_id_fk": { + "name": "email_retention_labels_applied_by_user_id_users_id_fk", + "tableFrom": "email_retention_labels", + "tableTo": "users", + "columnsFrom": ["applied_by_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "email_retention_labels_email_id_label_id_pk": { + "name": "email_retention_labels_email_id_label_id_pk", + "columns": ["email_id", "label_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.export_jobs": { + "name": "export_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "case_id": { + "name": "case_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "query": { + "name": "query", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_identifier": { + "name": "created_by_identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "export_jobs_case_id_ediscovery_cases_id_fk": { + "name": "export_jobs_case_id_ediscovery_cases_id_fk", + "tableFrom": "export_jobs", + "tableTo": "ediscovery_cases", + "columnsFrom": ["case_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.legal_holds": { + "name": "legal_holds", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "case_id": { + "name": "case_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "legal_holds_case_id_ediscovery_cases_id_fk": { + "name": "legal_holds_case_id_ediscovery_cases_id_fk", + "tableFrom": "legal_holds", + "tableTo": "ediscovery_cases", + "columnsFrom": ["case_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.retention_events": { + "name": "retention_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "event_name": { + "name": "event_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "event_timestamp": { + "name": "event_timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "target_criteria": { + "name": "target_criteria", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.retention_labels": { + "name": "retention_labels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "retention_period_days": { + "name": "retention_period_days", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.retention_policies": { + "name": "retention_policies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "retention_period_days": { + "name": "retention_period_days", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "action_on_expiry": { + "name": "action_on_expiry", + "type": "retention_action", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "conditions": { + "name": "conditions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ingestion_scope": { + "name": "ingestion_scope", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "retention_policies_name_unique": { + "name": "retention_policies_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custodians": { + "name": "custodians", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "ingestion_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "custodians_email_unique": { + "name": "custodians_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ingestion_sources": { + "name": "ingestion_sources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "ingestion_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "credentials": { + "name": "credentials", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "ingestion_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending_auth'" + }, + "last_sync_started_at": { + "name": "last_sync_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_sync_finished_at": { + "name": "last_sync_finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_sync_status_message": { + "name": "last_sync_status_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_state": { + "name": "sync_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "ingestion_sources_user_id_users_id_fk": { + "name": "ingestion_sources_user_id_users_id_fk", + "tableFrom": "ingestion_sources", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.roles": { + "name": "roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "policies": { + "name": "policies", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "roles_name_unique": { + "name": "roles_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + }, + "roles_slug_unique": { + "name": "roles_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_roles": { + "name": "user_roles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_roles_role_id_roles_id_fk": { + "name": "user_roles_role_id_roles_id_fk", + "tableFrom": "user_roles", + "tableTo": "roles", + "columnsFrom": ["role_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_roles_user_id_role_id_pk": { + "name": "user_roles_user_id_role_id_pk", + "columns": ["user_id", "role_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'local'" + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_settings": { + "name": "system_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "api_keys_user_id_users_id_fk": { + "name": "api_keys_user_id_users_id_fk", + "tableFrom": "api_keys", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.retention_action": { + "name": "retention_action", + "schema": "public", + "values": ["delete_permanently", "notify_admin"] + }, + "public.ingestion_provider": { + "name": "ingestion_provider", + "schema": "public", + "values": [ + "google_workspace", + "microsoft_365", + "generic_imap", + "pst_import", + "eml_import", + "mbox_import" + ] + }, + "public.ingestion_status": { + "name": "ingestion_status", + "schema": "public", + "values": [ + "active", + "paused", + "error", + "pending_auth", + "syncing", + "importing", + "auth_success", + "imported" + ] + }, + "public.audit_log_action": { + "name": "audit_log_action", + "schema": "public", + "values": [ + "CREATE", + "READ", + "UPDATE", + "DELETE", + "LOGIN", + "LOGOUT", + "SETUP", + "IMPORT", + "PAUSE", + "SYNC", + "UPLOAD", + "SEARCH", + "DOWNLOAD", + "GENERATE" + ] + }, + "public.audit_log_target_type": { + "name": "audit_log_target_type", + "schema": "public", + "values": [ + "ApiKey", + "ArchivedEmail", + "Dashboard", + "IngestionSource", + "RetentionPolicy", + "Role", + "SystemEvent", + "SystemSettings", + "User", + "File" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/backend/src/database/migrations/meta/0026_snapshot.json b/packages/backend/src/database/migrations/meta/0026_snapshot.json index 68ee7916..e22b579f 100644 --- a/packages/backend/src/database/migrations/meta/0026_snapshot.json +++ b/packages/backend/src/database/migrations/meta/0026_snapshot.json @@ -1,1595 +1,1500 @@ { - "id": "e36b5387-e9b0-4b3a-8253-cfaf19c65952", - "prevId": "fe6390c6-4fcf-4182-a7fb-fe929003aec0", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.archived_emails": { - "name": "archived_emails", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "thread_id": { - "name": "thread_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "ingestion_source_id": { - "name": "ingestion_source_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "user_email": { - "name": "user_email", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "message_id_header": { - "name": "message_id_header", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "sent_at": { - "name": "sent_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "subject": { - "name": "subject", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "sender_name": { - "name": "sender_name", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "sender_email": { - "name": "sender_email", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "recipients": { - "name": "recipients", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "storage_path": { - "name": "storage_path", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "storage_hash_sha256": { - "name": "storage_hash_sha256", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "size_bytes": { - "name": "size_bytes", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "is_indexed": { - "name": "is_indexed", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "has_attachments": { - "name": "has_attachments", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "is_on_legal_hold": { - "name": "is_on_legal_hold", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "archived_at": { - "name": "archived_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "path": { - "name": "path", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "tags": { - "name": "tags", - "type": "jsonb", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "thread_id_idx": { - "name": "thread_id_idx", - "columns": [ - { - "expression": "thread_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "archived_emails_ingestion_source_id_ingestion_sources_id_fk": { - "name": "archived_emails_ingestion_source_id_ingestion_sources_id_fk", - "tableFrom": "archived_emails", - "tableTo": "ingestion_sources", - "columnsFrom": [ - "ingestion_source_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.attachments": { - "name": "attachments", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "filename": { - "name": "filename", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "mime_type": { - "name": "mime_type", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "size_bytes": { - "name": "size_bytes", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "content_hash_sha256": { - "name": "content_hash_sha256", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "storage_path": { - "name": "storage_path", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "ingestion_source_id": { - "name": "ingestion_source_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "source_hash_idx": { - "name": "source_hash_idx", - "columns": [ - { - "expression": "ingestion_source_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "content_hash_sha256", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "attachments_ingestion_source_id_ingestion_sources_id_fk": { - "name": "attachments_ingestion_source_id_ingestion_sources_id_fk", - "tableFrom": "attachments", - "tableTo": "ingestion_sources", - "columnsFrom": [ - "ingestion_source_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.email_attachments": { - "name": "email_attachments", - "schema": "", - "columns": { - "email_id": { - "name": "email_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "attachment_id": { - "name": "attachment_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": { - "email_attachments_email_id_archived_emails_id_fk": { - "name": "email_attachments_email_id_archived_emails_id_fk", - "tableFrom": "email_attachments", - "tableTo": "archived_emails", - "columnsFrom": [ - "email_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "email_attachments_attachment_id_attachments_id_fk": { - "name": "email_attachments_attachment_id_attachments_id_fk", - "tableFrom": "email_attachments", - "tableTo": "attachments", - "columnsFrom": [ - "attachment_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "restrict", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "email_attachments_email_id_attachment_id_pk": { - "name": "email_attachments_email_id_attachment_id_pk", - "columns": [ - "email_id", - "attachment_id" - ] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.audit_logs": { - "name": "audit_logs", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "bigserial", - "primaryKey": true, - "notNull": true - }, - "previous_hash": { - "name": "previous_hash", - "type": "varchar(64)", - "primaryKey": false, - "notNull": false - }, - "timestamp": { - "name": "timestamp", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "actor_identifier": { - "name": "actor_identifier", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "actor_ip": { - "name": "actor_ip", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "action_type": { - "name": "action_type", - "type": "audit_log_action", - "typeSchema": "public", - "primaryKey": false, - "notNull": true - }, - "target_type": { - "name": "target_type", - "type": "audit_log_target_type", - "typeSchema": "public", - "primaryKey": false, - "notNull": false - }, - "target_id": { - "name": "target_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "details": { - "name": "details", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "current_hash": { - "name": "current_hash", - "type": "varchar(64)", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.ediscovery_cases": { - "name": "ediscovery_cases", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'open'" - }, - "created_by_identifier": { - "name": "created_by_identifier", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "ediscovery_cases_name_unique": { - "name": "ediscovery_cases_name_unique", - "nullsNotDistinct": false, - "columns": [ - "name" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.email_legal_holds": { - "name": "email_legal_holds", - "schema": "", - "columns": { - "email_id": { - "name": "email_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "legal_hold_id": { - "name": "legal_hold_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "applied_at": { - "name": "applied_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "applied_by_user_id": { - "name": "applied_by_user_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": { - "email_legal_holds_email_id_archived_emails_id_fk": { - "name": "email_legal_holds_email_id_archived_emails_id_fk", - "tableFrom": "email_legal_holds", - "tableTo": "archived_emails", - "columnsFrom": [ - "email_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "email_legal_holds_legal_hold_id_legal_holds_id_fk": { - "name": "email_legal_holds_legal_hold_id_legal_holds_id_fk", - "tableFrom": "email_legal_holds", - "tableTo": "legal_holds", - "columnsFrom": [ - "legal_hold_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "email_legal_holds_applied_by_user_id_users_id_fk": { - "name": "email_legal_holds_applied_by_user_id_users_id_fk", - "tableFrom": "email_legal_holds", - "tableTo": "users", - "columnsFrom": [ - "applied_by_user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "email_legal_holds_email_id_legal_hold_id_pk": { - "name": "email_legal_holds_email_id_legal_hold_id_pk", - "columns": [ - "email_id", - "legal_hold_id" - ] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.email_retention_labels": { - "name": "email_retention_labels", - "schema": "", - "columns": { - "email_id": { - "name": "email_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "label_id": { - "name": "label_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "applied_at": { - "name": "applied_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "applied_by_user_id": { - "name": "applied_by_user_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": { - "email_retention_labels_email_id_archived_emails_id_fk": { - "name": "email_retention_labels_email_id_archived_emails_id_fk", - "tableFrom": "email_retention_labels", - "tableTo": "archived_emails", - "columnsFrom": [ - "email_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "email_retention_labels_label_id_retention_labels_id_fk": { - "name": "email_retention_labels_label_id_retention_labels_id_fk", - "tableFrom": "email_retention_labels", - "tableTo": "retention_labels", - "columnsFrom": [ - "label_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "email_retention_labels_applied_by_user_id_users_id_fk": { - "name": "email_retention_labels_applied_by_user_id_users_id_fk", - "tableFrom": "email_retention_labels", - "tableTo": "users", - "columnsFrom": [ - "applied_by_user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "email_retention_labels_email_id_label_id_pk": { - "name": "email_retention_labels_email_id_label_id_pk", - "columns": [ - "email_id", - "label_id" - ] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.export_jobs": { - "name": "export_jobs", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "case_id": { - "name": "case_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "format": { - "name": "format", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'pending'" - }, - "query": { - "name": "query", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "file_path": { - "name": "file_path", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_by_identifier": { - "name": "created_by_identifier", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "completed_at": { - "name": "completed_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": { - "export_jobs_case_id_ediscovery_cases_id_fk": { - "name": "export_jobs_case_id_ediscovery_cases_id_fk", - "tableFrom": "export_jobs", - "tableTo": "ediscovery_cases", - "columnsFrom": [ - "case_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.legal_holds": { - "name": "legal_holds", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "name": { - "name": "name", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "reason": { - "name": "reason", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "is_active": { - "name": "is_active", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": true - }, - "case_id": { - "name": "case_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "legal_holds_case_id_ediscovery_cases_id_fk": { - "name": "legal_holds_case_id_ediscovery_cases_id_fk", - "tableFrom": "legal_holds", - "tableTo": "ediscovery_cases", - "columnsFrom": [ - "case_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.retention_events": { - "name": "retention_events", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "event_name": { - "name": "event_name", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "event_type": { - "name": "event_type", - "type": "varchar(100)", - "primaryKey": false, - "notNull": true - }, - "event_timestamp": { - "name": "event_timestamp", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "target_criteria": { - "name": "target_criteria", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.retention_labels": { - "name": "retention_labels", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "name": { - "name": "name", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "retention_period_days": { - "name": "retention_period_days", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "is_disabled": { - "name": "is_disabled", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.retention_policies": { - "name": "retention_policies", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "priority": { - "name": "priority", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "retention_period_days": { - "name": "retention_period_days", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "action_on_expiry": { - "name": "action_on_expiry", - "type": "retention_action", - "typeSchema": "public", - "primaryKey": false, - "notNull": true - }, - "is_enabled": { - "name": "is_enabled", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": true - }, - "conditions": { - "name": "conditions", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "ingestion_scope": { - "name": "ingestion_scope", - "type": "jsonb", - "primaryKey": false, - "notNull": false, - "default": "'null'::jsonb" - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "retention_policies_name_unique": { - "name": "retention_policies_name_unique", - "nullsNotDistinct": false, - "columns": [ - "name" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.custodians": { - "name": "custodians", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "email": { - "name": "email", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "display_name": { - "name": "display_name", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "source_type": { - "name": "source_type", - "type": "ingestion_provider", - "typeSchema": "public", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "custodians_email_unique": { - "name": "custodians_email_unique", - "nullsNotDistinct": false, - "columns": [ - "email" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.ingestion_sources": { - "name": "ingestion_sources", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "user_id": { - "name": "user_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "provider": { - "name": "provider", - "type": "ingestion_provider", - "typeSchema": "public", - "primaryKey": false, - "notNull": true - }, - "credentials": { - "name": "credentials", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "status": { - "name": "status", - "type": "ingestion_status", - "typeSchema": "public", - "primaryKey": false, - "notNull": true, - "default": "'pending_auth'" - }, - "last_sync_started_at": { - "name": "last_sync_started_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "last_sync_finished_at": { - "name": "last_sync_finished_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "last_sync_status_message": { - "name": "last_sync_status_message", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "sync_state": { - "name": "sync_state", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "ingestion_sources_user_id_users_id_fk": { - "name": "ingestion_sources_user_id_users_id_fk", - "tableFrom": "ingestion_sources", - "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.roles": { - "name": "roles", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "policies": { - "name": "policies", - "type": "jsonb", - "primaryKey": false, - "notNull": true, - "default": "'[]'::jsonb" - }, - "slug": { - "name": "slug", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "roles_name_unique": { - "name": "roles_name_unique", - "nullsNotDistinct": false, - "columns": [ - "name" - ] - }, - "roles_slug_unique": { - "name": "roles_slug_unique", - "nullsNotDistinct": false, - "columns": [ - "slug" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.sessions": { - "name": "sessions", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": { - "sessions_user_id_users_id_fk": { - "name": "sessions_user_id_users_id_fk", - "tableFrom": "sessions", - "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.user_roles": { - "name": "user_roles", - "schema": "", - "columns": { - "user_id": { - "name": "user_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "role_id": { - "name": "role_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": { - "user_roles_user_id_users_id_fk": { - "name": "user_roles_user_id_users_id_fk", - "tableFrom": "user_roles", - "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "user_roles_role_id_roles_id_fk": { - "name": "user_roles_role_id_roles_id_fk", - "tableFrom": "user_roles", - "tableTo": "roles", - "columnsFrom": [ - "role_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "user_roles_user_id_role_id_pk": { - "name": "user_roles_user_id_role_id_pk", - "columns": [ - "user_id", - "role_id" - ] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.users": { - "name": "users", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "email": { - "name": "email", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "first_name": { - "name": "first_name", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "last_name": { - "name": "last_name", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "password": { - "name": "password", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "provider": { - "name": "provider", - "type": "text", - "primaryKey": false, - "notNull": false, - "default": "'local'" - }, - "provider_id": { - "name": "provider_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "users_email_unique": { - "name": "users_email_unique", - "nullsNotDistinct": false, - "columns": [ - "email" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.system_settings": { - "name": "system_settings", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "serial", - "primaryKey": true, - "notNull": true - }, - "config": { - "name": "config", - "type": "jsonb", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.api_keys": { - "name": "api_keys", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "key": { - "name": "key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "key_hash": { - "name": "key_hash", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "api_keys_user_id_users_id_fk": { - "name": "api_keys_user_id_users_id_fk", - "tableFrom": "api_keys", - "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": { - "public.retention_action": { - "name": "retention_action", - "schema": "public", - "values": [ - "delete_permanently", - "notify_admin" - ] - }, - "public.ingestion_provider": { - "name": "ingestion_provider", - "schema": "public", - "values": [ - "google_workspace", - "microsoft_365", - "generic_imap", - "pst_import", - "eml_import", - "mbox_import" - ] - }, - "public.ingestion_status": { - "name": "ingestion_status", - "schema": "public", - "values": [ - "active", - "paused", - "error", - "pending_auth", - "syncing", - "importing", - "auth_success", - "imported" - ] - }, - "public.audit_log_action": { - "name": "audit_log_action", - "schema": "public", - "values": [ - "CREATE", - "READ", - "UPDATE", - "DELETE", - "LOGIN", - "LOGOUT", - "SETUP", - "IMPORT", - "PAUSE", - "SYNC", - "UPLOAD", - "SEARCH", - "DOWNLOAD", - "GENERATE" - ] - }, - "public.audit_log_target_type": { - "name": "audit_log_target_type", - "schema": "public", - "values": [ - "ApiKey", - "ArchivedEmail", - "Dashboard", - "IngestionSource", - "RetentionPolicy", - "RetentionLabel", - "LegalHold", - "Role", - "SystemEvent", - "SystemSettings", - "User", - "File" - ] - } - }, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} \ No newline at end of file + "id": "e36b5387-e9b0-4b3a-8253-cfaf19c65952", + "prevId": "fe6390c6-4fcf-4182-a7fb-fe929003aec0", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.archived_emails": { + "name": "archived_emails", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ingestion_source_id": { + "name": "ingestion_source_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_email": { + "name": "user_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_id_header": { + "name": "message_id_header", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sender_name": { + "name": "sender_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sender_email": { + "name": "sender_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipients": { + "name": "recipients", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "storage_path": { + "name": "storage_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_hash_sha256": { + "name": "storage_hash_sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "is_indexed": { + "name": "is_indexed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "has_attachments": { + "name": "has_attachments", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_on_legal_hold": { + "name": "is_on_legal_hold", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "thread_id_idx": { + "name": "thread_id_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "archived_emails_ingestion_source_id_ingestion_sources_id_fk": { + "name": "archived_emails_ingestion_source_id_ingestion_sources_id_fk", + "tableFrom": "archived_emails", + "tableTo": "ingestion_sources", + "columnsFrom": ["ingestion_source_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.attachments": { + "name": "attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "content_hash_sha256": { + "name": "content_hash_sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_path": { + "name": "storage_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ingestion_source_id": { + "name": "ingestion_source_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "source_hash_idx": { + "name": "source_hash_idx", + "columns": [ + { + "expression": "ingestion_source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "content_hash_sha256", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "attachments_ingestion_source_id_ingestion_sources_id_fk": { + "name": "attachments_ingestion_source_id_ingestion_sources_id_fk", + "tableFrom": "attachments", + "tableTo": "ingestion_sources", + "columnsFrom": ["ingestion_source_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_attachments": { + "name": "email_attachments", + "schema": "", + "columns": { + "email_id": { + "name": "email_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "attachment_id": { + "name": "attachment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "email_attachments_email_id_archived_emails_id_fk": { + "name": "email_attachments_email_id_archived_emails_id_fk", + "tableFrom": "email_attachments", + "tableTo": "archived_emails", + "columnsFrom": ["email_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_attachments_attachment_id_attachments_id_fk": { + "name": "email_attachments_attachment_id_attachments_id_fk", + "tableFrom": "email_attachments", + "tableTo": "attachments", + "columnsFrom": ["attachment_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "email_attachments_email_id_attachment_id_pk": { + "name": "email_attachments_email_id_attachment_id_pk", + "columns": ["email_id", "attachment_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "previous_hash": { + "name": "previous_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "actor_identifier": { + "name": "actor_identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_ip": { + "name": "actor_ip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action_type": { + "name": "action_type", + "type": "audit_log_action", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "audit_log_target_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "current_hash": { + "name": "current_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ediscovery_cases": { + "name": "ediscovery_cases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "created_by_identifier": { + "name": "created_by_identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ediscovery_cases_name_unique": { + "name": "ediscovery_cases_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_legal_holds": { + "name": "email_legal_holds", + "schema": "", + "columns": { + "email_id": { + "name": "email_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "legal_hold_id": { + "name": "legal_hold_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "applied_at": { + "name": "applied_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "applied_by_user_id": { + "name": "applied_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "email_legal_holds_email_id_archived_emails_id_fk": { + "name": "email_legal_holds_email_id_archived_emails_id_fk", + "tableFrom": "email_legal_holds", + "tableTo": "archived_emails", + "columnsFrom": ["email_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_legal_holds_legal_hold_id_legal_holds_id_fk": { + "name": "email_legal_holds_legal_hold_id_legal_holds_id_fk", + "tableFrom": "email_legal_holds", + "tableTo": "legal_holds", + "columnsFrom": ["legal_hold_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_legal_holds_applied_by_user_id_users_id_fk": { + "name": "email_legal_holds_applied_by_user_id_users_id_fk", + "tableFrom": "email_legal_holds", + "tableTo": "users", + "columnsFrom": ["applied_by_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "email_legal_holds_email_id_legal_hold_id_pk": { + "name": "email_legal_holds_email_id_legal_hold_id_pk", + "columns": ["email_id", "legal_hold_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_retention_labels": { + "name": "email_retention_labels", + "schema": "", + "columns": { + "email_id": { + "name": "email_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "label_id": { + "name": "label_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "applied_at": { + "name": "applied_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "applied_by_user_id": { + "name": "applied_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "email_retention_labels_email_id_archived_emails_id_fk": { + "name": "email_retention_labels_email_id_archived_emails_id_fk", + "tableFrom": "email_retention_labels", + "tableTo": "archived_emails", + "columnsFrom": ["email_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_retention_labels_label_id_retention_labels_id_fk": { + "name": "email_retention_labels_label_id_retention_labels_id_fk", + "tableFrom": "email_retention_labels", + "tableTo": "retention_labels", + "columnsFrom": ["label_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_retention_labels_applied_by_user_id_users_id_fk": { + "name": "email_retention_labels_applied_by_user_id_users_id_fk", + "tableFrom": "email_retention_labels", + "tableTo": "users", + "columnsFrom": ["applied_by_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "email_retention_labels_email_id_label_id_pk": { + "name": "email_retention_labels_email_id_label_id_pk", + "columns": ["email_id", "label_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.export_jobs": { + "name": "export_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "case_id": { + "name": "case_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "query": { + "name": "query", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_identifier": { + "name": "created_by_identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "export_jobs_case_id_ediscovery_cases_id_fk": { + "name": "export_jobs_case_id_ediscovery_cases_id_fk", + "tableFrom": "export_jobs", + "tableTo": "ediscovery_cases", + "columnsFrom": ["case_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.legal_holds": { + "name": "legal_holds", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "case_id": { + "name": "case_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "legal_holds_case_id_ediscovery_cases_id_fk": { + "name": "legal_holds_case_id_ediscovery_cases_id_fk", + "tableFrom": "legal_holds", + "tableTo": "ediscovery_cases", + "columnsFrom": ["case_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.retention_events": { + "name": "retention_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "event_name": { + "name": "event_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "event_timestamp": { + "name": "event_timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "target_criteria": { + "name": "target_criteria", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.retention_labels": { + "name": "retention_labels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "retention_period_days": { + "name": "retention_period_days", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_disabled": { + "name": "is_disabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.retention_policies": { + "name": "retention_policies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "retention_period_days": { + "name": "retention_period_days", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "action_on_expiry": { + "name": "action_on_expiry", + "type": "retention_action", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "conditions": { + "name": "conditions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ingestion_scope": { + "name": "ingestion_scope", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "retention_policies_name_unique": { + "name": "retention_policies_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custodians": { + "name": "custodians", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "ingestion_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "custodians_email_unique": { + "name": "custodians_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ingestion_sources": { + "name": "ingestion_sources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "ingestion_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "credentials": { + "name": "credentials", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "ingestion_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending_auth'" + }, + "last_sync_started_at": { + "name": "last_sync_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_sync_finished_at": { + "name": "last_sync_finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_sync_status_message": { + "name": "last_sync_status_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_state": { + "name": "sync_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "ingestion_sources_user_id_users_id_fk": { + "name": "ingestion_sources_user_id_users_id_fk", + "tableFrom": "ingestion_sources", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.roles": { + "name": "roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "policies": { + "name": "policies", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "roles_name_unique": { + "name": "roles_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + }, + "roles_slug_unique": { + "name": "roles_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_roles": { + "name": "user_roles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_roles_role_id_roles_id_fk": { + "name": "user_roles_role_id_roles_id_fk", + "tableFrom": "user_roles", + "tableTo": "roles", + "columnsFrom": ["role_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_roles_user_id_role_id_pk": { + "name": "user_roles_user_id_role_id_pk", + "columns": ["user_id", "role_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'local'" + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_settings": { + "name": "system_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "api_keys_user_id_users_id_fk": { + "name": "api_keys_user_id_users_id_fk", + "tableFrom": "api_keys", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.retention_action": { + "name": "retention_action", + "schema": "public", + "values": ["delete_permanently", "notify_admin"] + }, + "public.ingestion_provider": { + "name": "ingestion_provider", + "schema": "public", + "values": [ + "google_workspace", + "microsoft_365", + "generic_imap", + "pst_import", + "eml_import", + "mbox_import" + ] + }, + "public.ingestion_status": { + "name": "ingestion_status", + "schema": "public", + "values": [ + "active", + "paused", + "error", + "pending_auth", + "syncing", + "importing", + "auth_success", + "imported" + ] + }, + "public.audit_log_action": { + "name": "audit_log_action", + "schema": "public", + "values": [ + "CREATE", + "READ", + "UPDATE", + "DELETE", + "LOGIN", + "LOGOUT", + "SETUP", + "IMPORT", + "PAUSE", + "SYNC", + "UPLOAD", + "SEARCH", + "DOWNLOAD", + "GENERATE" + ] + }, + "public.audit_log_target_type": { + "name": "audit_log_target_type", + "schema": "public", + "values": [ + "ApiKey", + "ArchivedEmail", + "Dashboard", + "IngestionSource", + "RetentionPolicy", + "RetentionLabel", + "LegalHold", + "Role", + "SystemEvent", + "SystemSettings", + "User", + "File" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/backend/src/database/migrations/meta/0027_snapshot.json b/packages/backend/src/database/migrations/meta/0027_snapshot.json new file mode 100644 index 00000000..6f924d5f --- /dev/null +++ b/packages/backend/src/database/migrations/meta/0027_snapshot.json @@ -0,0 +1,1578 @@ +{ + "id": "028cd409-7341-499d-a220-409b0d8e604d", + "prevId": "e36b5387-e9b0-4b3a-8253-cfaf19c65952", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.archived_emails": { + "name": "archived_emails", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ingestion_source_id": { + "name": "ingestion_source_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_email": { + "name": "user_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_id_header": { + "name": "message_id_header", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sender_name": { + "name": "sender_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sender_email": { + "name": "sender_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipients": { + "name": "recipients", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "storage_path": { + "name": "storage_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_hash_sha256": { + "name": "storage_hash_sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "is_indexed": { + "name": "is_indexed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "has_attachments": { + "name": "has_attachments", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_on_legal_hold": { + "name": "is_on_legal_hold", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "thread_id_idx": { + "name": "thread_id_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "archived_emails_ingestion_source_id_ingestion_sources_id_fk": { + "name": "archived_emails_ingestion_source_id_ingestion_sources_id_fk", + "tableFrom": "archived_emails", + "tableTo": "ingestion_sources", + "columnsFrom": ["ingestion_source_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.attachments": { + "name": "attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "content_hash_sha256": { + "name": "content_hash_sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_path": { + "name": "storage_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ingestion_source_id": { + "name": "ingestion_source_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "source_hash_idx": { + "name": "source_hash_idx", + "columns": [ + { + "expression": "ingestion_source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "content_hash_sha256", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "attachments_ingestion_source_id_ingestion_sources_id_fk": { + "name": "attachments_ingestion_source_id_ingestion_sources_id_fk", + "tableFrom": "attachments", + "tableTo": "ingestion_sources", + "columnsFrom": ["ingestion_source_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_attachments": { + "name": "email_attachments", + "schema": "", + "columns": { + "email_id": { + "name": "email_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "attachment_id": { + "name": "attachment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "email_attachments_email_id_archived_emails_id_fk": { + "name": "email_attachments_email_id_archived_emails_id_fk", + "tableFrom": "email_attachments", + "tableTo": "archived_emails", + "columnsFrom": ["email_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_attachments_attachment_id_attachments_id_fk": { + "name": "email_attachments_attachment_id_attachments_id_fk", + "tableFrom": "email_attachments", + "tableTo": "attachments", + "columnsFrom": ["attachment_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "email_attachments_email_id_attachment_id_pk": { + "name": "email_attachments_email_id_attachment_id_pk", + "columns": ["email_id", "attachment_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "previous_hash": { + "name": "previous_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "actor_identifier": { + "name": "actor_identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_ip": { + "name": "actor_ip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action_type": { + "name": "action_type", + "type": "audit_log_action", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "audit_log_target_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "current_hash": { + "name": "current_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ediscovery_cases": { + "name": "ediscovery_cases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "created_by_identifier": { + "name": "created_by_identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ediscovery_cases_name_unique": { + "name": "ediscovery_cases_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_legal_holds": { + "name": "email_legal_holds", + "schema": "", + "columns": { + "email_id": { + "name": "email_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "legal_hold_id": { + "name": "legal_hold_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "applied_at": { + "name": "applied_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "applied_by_user_id": { + "name": "applied_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "email_legal_holds_email_id_archived_emails_id_fk": { + "name": "email_legal_holds_email_id_archived_emails_id_fk", + "tableFrom": "email_legal_holds", + "tableTo": "archived_emails", + "columnsFrom": ["email_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_legal_holds_legal_hold_id_legal_holds_id_fk": { + "name": "email_legal_holds_legal_hold_id_legal_holds_id_fk", + "tableFrom": "email_legal_holds", + "tableTo": "legal_holds", + "columnsFrom": ["legal_hold_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_legal_holds_applied_by_user_id_users_id_fk": { + "name": "email_legal_holds_applied_by_user_id_users_id_fk", + "tableFrom": "email_legal_holds", + "tableTo": "users", + "columnsFrom": ["applied_by_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "email_legal_holds_email_id_legal_hold_id_pk": { + "name": "email_legal_holds_email_id_legal_hold_id_pk", + "columns": ["email_id", "legal_hold_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_retention_labels": { + "name": "email_retention_labels", + "schema": "", + "columns": { + "email_id": { + "name": "email_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "label_id": { + "name": "label_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "applied_at": { + "name": "applied_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "applied_by_user_id": { + "name": "applied_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "email_retention_labels_email_id_archived_emails_id_fk": { + "name": "email_retention_labels_email_id_archived_emails_id_fk", + "tableFrom": "email_retention_labels", + "tableTo": "archived_emails", + "columnsFrom": ["email_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_retention_labels_label_id_retention_labels_id_fk": { + "name": "email_retention_labels_label_id_retention_labels_id_fk", + "tableFrom": "email_retention_labels", + "tableTo": "retention_labels", + "columnsFrom": ["label_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_retention_labels_applied_by_user_id_users_id_fk": { + "name": "email_retention_labels_applied_by_user_id_users_id_fk", + "tableFrom": "email_retention_labels", + "tableTo": "users", + "columnsFrom": ["applied_by_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "email_retention_labels_email_id_label_id_pk": { + "name": "email_retention_labels_email_id_label_id_pk", + "columns": ["email_id", "label_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.export_jobs": { + "name": "export_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "case_id": { + "name": "case_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "query": { + "name": "query", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_identifier": { + "name": "created_by_identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "export_jobs_case_id_ediscovery_cases_id_fk": { + "name": "export_jobs_case_id_ediscovery_cases_id_fk", + "tableFrom": "export_jobs", + "tableTo": "ediscovery_cases", + "columnsFrom": ["case_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.legal_holds": { + "name": "legal_holds", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "case_id": { + "name": "case_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "legal_holds_case_id_ediscovery_cases_id_fk": { + "name": "legal_holds_case_id_ediscovery_cases_id_fk", + "tableFrom": "legal_holds", + "tableTo": "ediscovery_cases", + "columnsFrom": ["case_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.retention_events": { + "name": "retention_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "event_name": { + "name": "event_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "event_timestamp": { + "name": "event_timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "target_criteria": { + "name": "target_criteria", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.retention_labels": { + "name": "retention_labels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "retention_period_days": { + "name": "retention_period_days", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_disabled": { + "name": "is_disabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.retention_policies": { + "name": "retention_policies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "retention_period_days": { + "name": "retention_period_days", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "action_on_expiry": { + "name": "action_on_expiry", + "type": "retention_action", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "conditions": { + "name": "conditions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ingestion_scope": { + "name": "ingestion_scope", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "retention_policies_name_unique": { + "name": "retention_policies_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custodians": { + "name": "custodians", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "ingestion_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "custodians_email_unique": { + "name": "custodians_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ingestion_sources": { + "name": "ingestion_sources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "ingestion_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "credentials": { + "name": "credentials", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "ingestion_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending_auth'" + }, + "last_sync_started_at": { + "name": "last_sync_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_sync_finished_at": { + "name": "last_sync_finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_sync_status_message": { + "name": "last_sync_status_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_state": { + "name": "sync_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "ingestion_sources_user_id_users_id_fk": { + "name": "ingestion_sources_user_id_users_id_fk", + "tableFrom": "ingestion_sources", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.roles": { + "name": "roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "policies": { + "name": "policies", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "roles_name_unique": { + "name": "roles_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + }, + "roles_slug_unique": { + "name": "roles_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_roles": { + "name": "user_roles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_roles_role_id_roles_id_fk": { + "name": "user_roles_role_id_roles_id_fk", + "tableFrom": "user_roles", + "tableTo": "roles", + "columnsFrom": ["role_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_roles_user_id_role_id_pk": { + "name": "user_roles_user_id_role_id_pk", + "columns": ["user_id", "role_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'local'" + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_settings": { + "name": "system_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "api_keys_user_id_users_id_fk": { + "name": "api_keys_user_id_users_id_fk", + "tableFrom": "api_keys", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_sessions": { + "name": "sync_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "ingestion_source_id": { + "name": "ingestion_source_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "is_initial_import": { + "name": "is_initial_import", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "total_mailboxes": { + "name": "total_mailboxes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "completed_mailboxes": { + "name": "completed_mailboxes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "failed_mailboxes": { + "name": "failed_mailboxes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_messages": { + "name": "error_messages", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sync_sessions_ingestion_source_id_ingestion_sources_id_fk": { + "name": "sync_sessions_ingestion_source_id_ingestion_sources_id_fk", + "tableFrom": "sync_sessions", + "tableTo": "ingestion_sources", + "columnsFrom": ["ingestion_source_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.retention_action": { + "name": "retention_action", + "schema": "public", + "values": ["delete_permanently", "notify_admin"] + }, + "public.ingestion_provider": { + "name": "ingestion_provider", + "schema": "public", + "values": [ + "google_workspace", + "microsoft_365", + "generic_imap", + "pst_import", + "eml_import", + "mbox_import" + ] + }, + "public.ingestion_status": { + "name": "ingestion_status", + "schema": "public", + "values": [ + "active", + "paused", + "error", + "pending_auth", + "syncing", + "importing", + "auth_success", + "imported" + ] + }, + "public.audit_log_action": { + "name": "audit_log_action", + "schema": "public", + "values": [ + "CREATE", + "READ", + "UPDATE", + "DELETE", + "LOGIN", + "LOGOUT", + "SETUP", + "IMPORT", + "PAUSE", + "SYNC", + "UPLOAD", + "SEARCH", + "DOWNLOAD", + "GENERATE" + ] + }, + "public.audit_log_target_type": { + "name": "audit_log_target_type", + "schema": "public", + "values": [ + "ApiKey", + "ArchivedEmail", + "Dashboard", + "IngestionSource", + "RetentionPolicy", + "RetentionLabel", + "LegalHold", + "Role", + "SystemEvent", + "SystemSettings", + "User", + "File" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/backend/src/database/migrations/meta/0028_snapshot.json b/packages/backend/src/database/migrations/meta/0028_snapshot.json new file mode 100644 index 00000000..060ba69a --- /dev/null +++ b/packages/backend/src/database/migrations/meta/0028_snapshot.json @@ -0,0 +1,1585 @@ +{ + "id": "3a1b6200-f229-4fd9-af5a-3b9ceddc0bd8", + "prevId": "028cd409-7341-499d-a220-409b0d8e604d", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.archived_emails": { + "name": "archived_emails", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ingestion_source_id": { + "name": "ingestion_source_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_email": { + "name": "user_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_id_header": { + "name": "message_id_header", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sender_name": { + "name": "sender_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sender_email": { + "name": "sender_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipients": { + "name": "recipients", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "storage_path": { + "name": "storage_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_hash_sha256": { + "name": "storage_hash_sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "is_indexed": { + "name": "is_indexed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "has_attachments": { + "name": "has_attachments", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_on_legal_hold": { + "name": "is_on_legal_hold", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "thread_id_idx": { + "name": "thread_id_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "archived_emails_ingestion_source_id_ingestion_sources_id_fk": { + "name": "archived_emails_ingestion_source_id_ingestion_sources_id_fk", + "tableFrom": "archived_emails", + "tableTo": "ingestion_sources", + "columnsFrom": ["ingestion_source_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.attachments": { + "name": "attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "content_hash_sha256": { + "name": "content_hash_sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_path": { + "name": "storage_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ingestion_source_id": { + "name": "ingestion_source_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "source_hash_idx": { + "name": "source_hash_idx", + "columns": [ + { + "expression": "ingestion_source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "content_hash_sha256", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "attachments_ingestion_source_id_ingestion_sources_id_fk": { + "name": "attachments_ingestion_source_id_ingestion_sources_id_fk", + "tableFrom": "attachments", + "tableTo": "ingestion_sources", + "columnsFrom": ["ingestion_source_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_attachments": { + "name": "email_attachments", + "schema": "", + "columns": { + "email_id": { + "name": "email_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "attachment_id": { + "name": "attachment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "email_attachments_email_id_archived_emails_id_fk": { + "name": "email_attachments_email_id_archived_emails_id_fk", + "tableFrom": "email_attachments", + "tableTo": "archived_emails", + "columnsFrom": ["email_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_attachments_attachment_id_attachments_id_fk": { + "name": "email_attachments_attachment_id_attachments_id_fk", + "tableFrom": "email_attachments", + "tableTo": "attachments", + "columnsFrom": ["attachment_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "email_attachments_email_id_attachment_id_pk": { + "name": "email_attachments_email_id_attachment_id_pk", + "columns": ["email_id", "attachment_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "previous_hash": { + "name": "previous_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "actor_identifier": { + "name": "actor_identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_ip": { + "name": "actor_ip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action_type": { + "name": "action_type", + "type": "audit_log_action", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "audit_log_target_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "current_hash": { + "name": "current_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ediscovery_cases": { + "name": "ediscovery_cases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "created_by_identifier": { + "name": "created_by_identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ediscovery_cases_name_unique": { + "name": "ediscovery_cases_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_legal_holds": { + "name": "email_legal_holds", + "schema": "", + "columns": { + "email_id": { + "name": "email_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "legal_hold_id": { + "name": "legal_hold_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "applied_at": { + "name": "applied_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "applied_by_user_id": { + "name": "applied_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "email_legal_holds_email_id_archived_emails_id_fk": { + "name": "email_legal_holds_email_id_archived_emails_id_fk", + "tableFrom": "email_legal_holds", + "tableTo": "archived_emails", + "columnsFrom": ["email_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_legal_holds_legal_hold_id_legal_holds_id_fk": { + "name": "email_legal_holds_legal_hold_id_legal_holds_id_fk", + "tableFrom": "email_legal_holds", + "tableTo": "legal_holds", + "columnsFrom": ["legal_hold_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_legal_holds_applied_by_user_id_users_id_fk": { + "name": "email_legal_holds_applied_by_user_id_users_id_fk", + "tableFrom": "email_legal_holds", + "tableTo": "users", + "columnsFrom": ["applied_by_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "email_legal_holds_email_id_legal_hold_id_pk": { + "name": "email_legal_holds_email_id_legal_hold_id_pk", + "columns": ["email_id", "legal_hold_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_retention_labels": { + "name": "email_retention_labels", + "schema": "", + "columns": { + "email_id": { + "name": "email_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "label_id": { + "name": "label_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "applied_at": { + "name": "applied_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "applied_by_user_id": { + "name": "applied_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "email_retention_labels_email_id_archived_emails_id_fk": { + "name": "email_retention_labels_email_id_archived_emails_id_fk", + "tableFrom": "email_retention_labels", + "tableTo": "archived_emails", + "columnsFrom": ["email_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_retention_labels_label_id_retention_labels_id_fk": { + "name": "email_retention_labels_label_id_retention_labels_id_fk", + "tableFrom": "email_retention_labels", + "tableTo": "retention_labels", + "columnsFrom": ["label_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_retention_labels_applied_by_user_id_users_id_fk": { + "name": "email_retention_labels_applied_by_user_id_users_id_fk", + "tableFrom": "email_retention_labels", + "tableTo": "users", + "columnsFrom": ["applied_by_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "email_retention_labels_email_id_label_id_pk": { + "name": "email_retention_labels_email_id_label_id_pk", + "columns": ["email_id", "label_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.export_jobs": { + "name": "export_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "case_id": { + "name": "case_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "query": { + "name": "query", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_identifier": { + "name": "created_by_identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "export_jobs_case_id_ediscovery_cases_id_fk": { + "name": "export_jobs_case_id_ediscovery_cases_id_fk", + "tableFrom": "export_jobs", + "tableTo": "ediscovery_cases", + "columnsFrom": ["case_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.legal_holds": { + "name": "legal_holds", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "case_id": { + "name": "case_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "legal_holds_case_id_ediscovery_cases_id_fk": { + "name": "legal_holds_case_id_ediscovery_cases_id_fk", + "tableFrom": "legal_holds", + "tableTo": "ediscovery_cases", + "columnsFrom": ["case_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.retention_events": { + "name": "retention_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "event_name": { + "name": "event_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "event_timestamp": { + "name": "event_timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "target_criteria": { + "name": "target_criteria", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.retention_labels": { + "name": "retention_labels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "retention_period_days": { + "name": "retention_period_days", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_disabled": { + "name": "is_disabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.retention_policies": { + "name": "retention_policies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "retention_period_days": { + "name": "retention_period_days", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "action_on_expiry": { + "name": "action_on_expiry", + "type": "retention_action", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "conditions": { + "name": "conditions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ingestion_scope": { + "name": "ingestion_scope", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "retention_policies_name_unique": { + "name": "retention_policies_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custodians": { + "name": "custodians", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "ingestion_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "custodians_email_unique": { + "name": "custodians_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ingestion_sources": { + "name": "ingestion_sources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "ingestion_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "credentials": { + "name": "credentials", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "ingestion_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending_auth'" + }, + "last_sync_started_at": { + "name": "last_sync_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_sync_finished_at": { + "name": "last_sync_finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_sync_status_message": { + "name": "last_sync_status_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_state": { + "name": "sync_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "ingestion_sources_user_id_users_id_fk": { + "name": "ingestion_sources_user_id_users_id_fk", + "tableFrom": "ingestion_sources", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.roles": { + "name": "roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "policies": { + "name": "policies", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "roles_name_unique": { + "name": "roles_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + }, + "roles_slug_unique": { + "name": "roles_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_roles": { + "name": "user_roles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_roles_role_id_roles_id_fk": { + "name": "user_roles_role_id_roles_id_fk", + "tableFrom": "user_roles", + "tableTo": "roles", + "columnsFrom": ["role_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_roles_user_id_role_id_pk": { + "name": "user_roles_user_id_role_id_pk", + "columns": ["user_id", "role_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'local'" + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_settings": { + "name": "system_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "api_keys_user_id_users_id_fk": { + "name": "api_keys_user_id_users_id_fk", + "tableFrom": "api_keys", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_sessions": { + "name": "sync_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "ingestion_source_id": { + "name": "ingestion_source_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "is_initial_import": { + "name": "is_initial_import", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "total_mailboxes": { + "name": "total_mailboxes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "completed_mailboxes": { + "name": "completed_mailboxes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "failed_mailboxes": { + "name": "failed_mailboxes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_messages": { + "name": "error_messages", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sync_sessions_ingestion_source_id_ingestion_sources_id_fk": { + "name": "sync_sessions_ingestion_source_id_ingestion_sources_id_fk", + "tableFrom": "sync_sessions", + "tableTo": "ingestion_sources", + "columnsFrom": ["ingestion_source_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.retention_action": { + "name": "retention_action", + "schema": "public", + "values": ["delete_permanently", "notify_admin"] + }, + "public.ingestion_provider": { + "name": "ingestion_provider", + "schema": "public", + "values": [ + "google_workspace", + "microsoft_365", + "generic_imap", + "pst_import", + "eml_import", + "mbox_import" + ] + }, + "public.ingestion_status": { + "name": "ingestion_status", + "schema": "public", + "values": [ + "active", + "paused", + "error", + "pending_auth", + "syncing", + "importing", + "auth_success", + "imported" + ] + }, + "public.audit_log_action": { + "name": "audit_log_action", + "schema": "public", + "values": [ + "CREATE", + "READ", + "UPDATE", + "DELETE", + "LOGIN", + "LOGOUT", + "SETUP", + "IMPORT", + "PAUSE", + "SYNC", + "UPLOAD", + "SEARCH", + "DOWNLOAD", + "GENERATE" + ] + }, + "public.audit_log_target_type": { + "name": "audit_log_target_type", + "schema": "public", + "values": [ + "ApiKey", + "ArchivedEmail", + "Dashboard", + "IngestionSource", + "RetentionPolicy", + "RetentionLabel", + "LegalHold", + "Role", + "SystemEvent", + "SystemSettings", + "User", + "File" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/backend/src/database/migrations/meta/0029_snapshot.json b/packages/backend/src/database/migrations/meta/0029_snapshot.json new file mode 100644 index 00000000..6e3a320e --- /dev/null +++ b/packages/backend/src/database/migrations/meta/0029_snapshot.json @@ -0,0 +1,1612 @@ +{ + "id": "5b69110d-3df3-41e0-982c-57413d5956f5", + "prevId": "3a1b6200-f229-4fd9-af5a-3b9ceddc0bd8", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.archived_emails": { + "name": "archived_emails", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ingestion_source_id": { + "name": "ingestion_source_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_email": { + "name": "user_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_id_header": { + "name": "message_id_header", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_message_id": { + "name": "provider_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sender_name": { + "name": "sender_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sender_email": { + "name": "sender_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipients": { + "name": "recipients", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "storage_path": { + "name": "storage_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_hash_sha256": { + "name": "storage_hash_sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "is_indexed": { + "name": "is_indexed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "has_attachments": { + "name": "has_attachments", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_on_legal_hold": { + "name": "is_on_legal_hold", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "thread_id_idx": { + "name": "thread_id_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "provider_msg_source_idx": { + "name": "provider_msg_source_idx", + "columns": [ + { + "expression": "provider_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ingestion_source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "archived_emails_ingestion_source_id_ingestion_sources_id_fk": { + "name": "archived_emails_ingestion_source_id_ingestion_sources_id_fk", + "tableFrom": "archived_emails", + "tableTo": "ingestion_sources", + "columnsFrom": ["ingestion_source_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.attachments": { + "name": "attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "content_hash_sha256": { + "name": "content_hash_sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_path": { + "name": "storage_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ingestion_source_id": { + "name": "ingestion_source_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "source_hash_idx": { + "name": "source_hash_idx", + "columns": [ + { + "expression": "ingestion_source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "content_hash_sha256", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "attachments_ingestion_source_id_ingestion_sources_id_fk": { + "name": "attachments_ingestion_source_id_ingestion_sources_id_fk", + "tableFrom": "attachments", + "tableTo": "ingestion_sources", + "columnsFrom": ["ingestion_source_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_attachments": { + "name": "email_attachments", + "schema": "", + "columns": { + "email_id": { + "name": "email_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "attachment_id": { + "name": "attachment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "email_attachments_email_id_archived_emails_id_fk": { + "name": "email_attachments_email_id_archived_emails_id_fk", + "tableFrom": "email_attachments", + "tableTo": "archived_emails", + "columnsFrom": ["email_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_attachments_attachment_id_attachments_id_fk": { + "name": "email_attachments_attachment_id_attachments_id_fk", + "tableFrom": "email_attachments", + "tableTo": "attachments", + "columnsFrom": ["attachment_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "email_attachments_email_id_attachment_id_pk": { + "name": "email_attachments_email_id_attachment_id_pk", + "columns": ["email_id", "attachment_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "previous_hash": { + "name": "previous_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "actor_identifier": { + "name": "actor_identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_ip": { + "name": "actor_ip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action_type": { + "name": "action_type", + "type": "audit_log_action", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "audit_log_target_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "current_hash": { + "name": "current_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ediscovery_cases": { + "name": "ediscovery_cases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "created_by_identifier": { + "name": "created_by_identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ediscovery_cases_name_unique": { + "name": "ediscovery_cases_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_legal_holds": { + "name": "email_legal_holds", + "schema": "", + "columns": { + "email_id": { + "name": "email_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "legal_hold_id": { + "name": "legal_hold_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "applied_at": { + "name": "applied_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "applied_by_user_id": { + "name": "applied_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "email_legal_holds_email_id_archived_emails_id_fk": { + "name": "email_legal_holds_email_id_archived_emails_id_fk", + "tableFrom": "email_legal_holds", + "tableTo": "archived_emails", + "columnsFrom": ["email_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_legal_holds_legal_hold_id_legal_holds_id_fk": { + "name": "email_legal_holds_legal_hold_id_legal_holds_id_fk", + "tableFrom": "email_legal_holds", + "tableTo": "legal_holds", + "columnsFrom": ["legal_hold_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_legal_holds_applied_by_user_id_users_id_fk": { + "name": "email_legal_holds_applied_by_user_id_users_id_fk", + "tableFrom": "email_legal_holds", + "tableTo": "users", + "columnsFrom": ["applied_by_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "email_legal_holds_email_id_legal_hold_id_pk": { + "name": "email_legal_holds_email_id_legal_hold_id_pk", + "columns": ["email_id", "legal_hold_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_retention_labels": { + "name": "email_retention_labels", + "schema": "", + "columns": { + "email_id": { + "name": "email_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "label_id": { + "name": "label_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "applied_at": { + "name": "applied_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "applied_by_user_id": { + "name": "applied_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "email_retention_labels_email_id_archived_emails_id_fk": { + "name": "email_retention_labels_email_id_archived_emails_id_fk", + "tableFrom": "email_retention_labels", + "tableTo": "archived_emails", + "columnsFrom": ["email_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_retention_labels_label_id_retention_labels_id_fk": { + "name": "email_retention_labels_label_id_retention_labels_id_fk", + "tableFrom": "email_retention_labels", + "tableTo": "retention_labels", + "columnsFrom": ["label_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_retention_labels_applied_by_user_id_users_id_fk": { + "name": "email_retention_labels_applied_by_user_id_users_id_fk", + "tableFrom": "email_retention_labels", + "tableTo": "users", + "columnsFrom": ["applied_by_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "email_retention_labels_email_id_label_id_pk": { + "name": "email_retention_labels_email_id_label_id_pk", + "columns": ["email_id", "label_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.export_jobs": { + "name": "export_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "case_id": { + "name": "case_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "query": { + "name": "query", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_identifier": { + "name": "created_by_identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "export_jobs_case_id_ediscovery_cases_id_fk": { + "name": "export_jobs_case_id_ediscovery_cases_id_fk", + "tableFrom": "export_jobs", + "tableTo": "ediscovery_cases", + "columnsFrom": ["case_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.legal_holds": { + "name": "legal_holds", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "case_id": { + "name": "case_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "legal_holds_case_id_ediscovery_cases_id_fk": { + "name": "legal_holds_case_id_ediscovery_cases_id_fk", + "tableFrom": "legal_holds", + "tableTo": "ediscovery_cases", + "columnsFrom": ["case_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.retention_events": { + "name": "retention_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "event_name": { + "name": "event_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "event_timestamp": { + "name": "event_timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "target_criteria": { + "name": "target_criteria", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.retention_labels": { + "name": "retention_labels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "retention_period_days": { + "name": "retention_period_days", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_disabled": { + "name": "is_disabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.retention_policies": { + "name": "retention_policies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "retention_period_days": { + "name": "retention_period_days", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "action_on_expiry": { + "name": "action_on_expiry", + "type": "retention_action", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "conditions": { + "name": "conditions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ingestion_scope": { + "name": "ingestion_scope", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "retention_policies_name_unique": { + "name": "retention_policies_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custodians": { + "name": "custodians", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "ingestion_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "custodians_email_unique": { + "name": "custodians_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ingestion_sources": { + "name": "ingestion_sources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "ingestion_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "credentials": { + "name": "credentials", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "ingestion_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending_auth'" + }, + "last_sync_started_at": { + "name": "last_sync_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_sync_finished_at": { + "name": "last_sync_finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_sync_status_message": { + "name": "last_sync_status_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_state": { + "name": "sync_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "ingestion_sources_user_id_users_id_fk": { + "name": "ingestion_sources_user_id_users_id_fk", + "tableFrom": "ingestion_sources", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.roles": { + "name": "roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "policies": { + "name": "policies", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "roles_name_unique": { + "name": "roles_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + }, + "roles_slug_unique": { + "name": "roles_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_roles": { + "name": "user_roles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_roles_role_id_roles_id_fk": { + "name": "user_roles_role_id_roles_id_fk", + "tableFrom": "user_roles", + "tableTo": "roles", + "columnsFrom": ["role_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_roles_user_id_role_id_pk": { + "name": "user_roles_user_id_role_id_pk", + "columns": ["user_id", "role_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'local'" + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_settings": { + "name": "system_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "api_keys_user_id_users_id_fk": { + "name": "api_keys_user_id_users_id_fk", + "tableFrom": "api_keys", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_sessions": { + "name": "sync_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "ingestion_source_id": { + "name": "ingestion_source_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "is_initial_import": { + "name": "is_initial_import", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "total_mailboxes": { + "name": "total_mailboxes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "completed_mailboxes": { + "name": "completed_mailboxes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "failed_mailboxes": { + "name": "failed_mailboxes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_messages": { + "name": "error_messages", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sync_sessions_ingestion_source_id_ingestion_sources_id_fk": { + "name": "sync_sessions_ingestion_source_id_ingestion_sources_id_fk", + "tableFrom": "sync_sessions", + "tableTo": "ingestion_sources", + "columnsFrom": ["ingestion_source_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.retention_action": { + "name": "retention_action", + "schema": "public", + "values": ["delete_permanently", "notify_admin"] + }, + "public.ingestion_provider": { + "name": "ingestion_provider", + "schema": "public", + "values": [ + "google_workspace", + "microsoft_365", + "generic_imap", + "pst_import", + "eml_import", + "mbox_import" + ] + }, + "public.ingestion_status": { + "name": "ingestion_status", + "schema": "public", + "values": [ + "active", + "paused", + "error", + "pending_auth", + "syncing", + "importing", + "auth_success", + "imported" + ] + }, + "public.audit_log_action": { + "name": "audit_log_action", + "schema": "public", + "values": [ + "CREATE", + "READ", + "UPDATE", + "DELETE", + "LOGIN", + "LOGOUT", + "SETUP", + "IMPORT", + "PAUSE", + "SYNC", + "UPLOAD", + "SEARCH", + "DOWNLOAD", + "GENERATE" + ] + }, + "public.audit_log_target_type": { + "name": "audit_log_target_type", + "schema": "public", + "values": [ + "ApiKey", + "ArchivedEmail", + "Dashboard", + "IngestionSource", + "RetentionPolicy", + "RetentionLabel", + "LegalHold", + "Role", + "SystemEvent", + "SystemSettings", + "User", + "File" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/backend/src/database/migrations/meta/_journal.json b/packages/backend/src/database/migrations/meta/_journal.json index 769becef..561be855 100644 --- a/packages/backend/src/database/migrations/meta/_journal.json +++ b/packages/backend/src/database/migrations/meta/_journal.json @@ -1,195 +1,216 @@ { - "version": "7", - "dialect": "postgresql", - "entries": [ - { - "idx": 0, - "version": "7", - "when": 1752225352591, - "tag": "0000_amusing_namora", - "breakpoints": true - }, - { - "idx": 1, - "version": "7", - "when": 1752326803882, - "tag": "0001_odd_night_thrasher", - "breakpoints": true - }, - { - "idx": 2, - "version": "7", - "when": 1752332648392, - "tag": "0002_lethal_quentin_quire", - "breakpoints": true - }, - { - "idx": 3, - "version": "7", - "when": 1752332967084, - "tag": "0003_petite_wrecker", - "breakpoints": true - }, - { - "idx": 4, - "version": "7", - "when": 1752606108876, - "tag": "0004_sleepy_paper_doll", - "breakpoints": true - }, - { - "idx": 5, - "version": "7", - "when": 1752606327253, - "tag": "0005_chunky_sue_storm", - "breakpoints": true - }, - { - "idx": 6, - "version": "7", - "when": 1753112018514, - "tag": "0006_majestic_caretaker", - "breakpoints": true - }, - { - "idx": 7, - "version": "7", - "when": 1753190159356, - "tag": "0007_handy_archangel", - "breakpoints": true - }, - { - "idx": 8, - "version": "7", - "when": 1753370737317, - "tag": "0008_eminent_the_spike", - "breakpoints": true - }, - { - "idx": 9, - "version": "7", - "when": 1754337938241, - "tag": "0009_late_lenny_balinger", - "breakpoints": true - }, - { - "idx": 10, - "version": "7", - "when": 1754420780849, - "tag": "0010_perpetual_lightspeed", - "breakpoints": true - }, - { - "idx": 11, - "version": "7", - "when": 1754422064158, - "tag": "0011_tan_blackheart", - "breakpoints": true - }, - { - "idx": 12, - "version": "7", - "when": 1754476962901, - "tag": "0012_warm_the_stranger", - "breakpoints": true - }, - { - "idx": 13, - "version": "7", - "when": 1754659373517, - "tag": "0013_classy_talkback", - "breakpoints": true - }, - { - "idx": 14, - "version": "7", - "when": 1754831765718, - "tag": "0014_foamy_vapor", - "breakpoints": true - }, - { - "idx": 15, - "version": "7", - "when": 1755443936046, - "tag": "0015_wakeful_norman_osborn", - "breakpoints": true - }, - { - "idx": 16, - "version": "7", - "when": 1755780572342, - "tag": "0016_lonely_mariko_yashida", - "breakpoints": true - }, - { - "idx": 17, - "version": "7", - "when": 1755961566627, - "tag": "0017_tranquil_shooting_star", - "breakpoints": true - }, - { - "idx": 18, - "version": "7", - "when": 1756911118035, - "tag": "0018_flawless_owl", - "breakpoints": true - }, - { - "idx": 19, - "version": "7", - "when": 1756937533843, - "tag": "0019_confused_scream", - "breakpoints": true - }, - { - "idx": 20, - "version": "7", - "when": 1757860242528, - "tag": "0020_panoramic_wolverine", - "breakpoints": true - }, - { - "idx": 21, - "version": "7", - "when": 1759412986134, - "tag": "0021_nosy_veda", - "breakpoints": true - }, - { - "idx": 22, - "version": "7", - "when": 1759701622932, - "tag": "0022_complete_triton", - "breakpoints": true - }, - { - "idx": 23, - "version": "7", - "when": 1760354094610, - "tag": "0023_swift_swordsman", - "breakpoints": true - }, - { - "idx": 24, - "version": "7", - "when": 1772842674479, - "tag": "0024_careful_black_panther", - "breakpoints": true - }, - { - "idx": 25, - "version": "7", - "when": 1773013461190, - "tag": "0025_peaceful_grim_reaper", - "breakpoints": true - }, - { - "idx": 26, - "version": "7", - "when": 1773326266420, - "tag": "0026_pink_fantastic_four", - "breakpoints": true - } - ] -} \ No newline at end of file + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1752225352591, + "tag": "0000_amusing_namora", + "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1752326803882, + "tag": "0001_odd_night_thrasher", + "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1752332648392, + "tag": "0002_lethal_quentin_quire", + "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1752332967084, + "tag": "0003_petite_wrecker", + "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1752606108876, + "tag": "0004_sleepy_paper_doll", + "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1752606327253, + "tag": "0005_chunky_sue_storm", + "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1753112018514, + "tag": "0006_majestic_caretaker", + "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1753190159356, + "tag": "0007_handy_archangel", + "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1753370737317, + "tag": "0008_eminent_the_spike", + "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1754337938241, + "tag": "0009_late_lenny_balinger", + "breakpoints": true + }, + { + "idx": 10, + "version": "7", + "when": 1754420780849, + "tag": "0010_perpetual_lightspeed", + "breakpoints": true + }, + { + "idx": 11, + "version": "7", + "when": 1754422064158, + "tag": "0011_tan_blackheart", + "breakpoints": true + }, + { + "idx": 12, + "version": "7", + "when": 1754476962901, + "tag": "0012_warm_the_stranger", + "breakpoints": true + }, + { + "idx": 13, + "version": "7", + "when": 1754659373517, + "tag": "0013_classy_talkback", + "breakpoints": true + }, + { + "idx": 14, + "version": "7", + "when": 1754831765718, + "tag": "0014_foamy_vapor", + "breakpoints": true + }, + { + "idx": 15, + "version": "7", + "when": 1755443936046, + "tag": "0015_wakeful_norman_osborn", + "breakpoints": true + }, + { + "idx": 16, + "version": "7", + "when": 1755780572342, + "tag": "0016_lonely_mariko_yashida", + "breakpoints": true + }, + { + "idx": 17, + "version": "7", + "when": 1755961566627, + "tag": "0017_tranquil_shooting_star", + "breakpoints": true + }, + { + "idx": 18, + "version": "7", + "when": 1756911118035, + "tag": "0018_flawless_owl", + "breakpoints": true + }, + { + "idx": 19, + "version": "7", + "when": 1756937533843, + "tag": "0019_confused_scream", + "breakpoints": true + }, + { + "idx": 20, + "version": "7", + "when": 1757860242528, + "tag": "0020_panoramic_wolverine", + "breakpoints": true + }, + { + "idx": 21, + "version": "7", + "when": 1759412986134, + "tag": "0021_nosy_veda", + "breakpoints": true + }, + { + "idx": 22, + "version": "7", + "when": 1759701622932, + "tag": "0022_complete_triton", + "breakpoints": true + }, + { + "idx": 23, + "version": "7", + "when": 1760354094610, + "tag": "0023_swift_swordsman", + "breakpoints": true + }, + { + "idx": 24, + "version": "7", + "when": 1772842674479, + "tag": "0024_careful_black_panther", + "breakpoints": true + }, + { + "idx": 25, + "version": "7", + "when": 1773013461190, + "tag": "0025_peaceful_grim_reaper", + "breakpoints": true + }, + { + "idx": 26, + "version": "7", + "when": 1773326266420, + "tag": "0026_pink_fantastic_four", + "breakpoints": true + }, + { + "idx": 27, + "version": "7", + "when": 1773768709477, + "tag": "0027_black_morph", + "breakpoints": true + }, + { + "idx": 28, + "version": "7", + "when": 1773770326402, + "tag": "0028_youthful_kitty_pryde", + "breakpoints": true + }, + { + "idx": 29, + "version": "7", + "when": 1773927678269, + "tag": "0029_lethal_brood", + "breakpoints": true + } + ] +} diff --git a/packages/backend/src/database/schema.ts b/packages/backend/src/database/schema.ts index 19f6e17d..444423bb 100644 --- a/packages/backend/src/database/schema.ts +++ b/packages/backend/src/database/schema.ts @@ -9,3 +9,4 @@ export * from './schema/system-settings'; export * from './schema/api-keys'; export * from './schema/audit-logs'; export * from './schema/enums'; +export * from './schema/sync-sessions'; diff --git a/packages/backend/src/database/schema/archived-emails.ts b/packages/backend/src/database/schema/archived-emails.ts index ef90f46a..42aa2bb6 100644 --- a/packages/backend/src/database/schema/archived-emails.ts +++ b/packages/backend/src/database/schema/archived-emails.ts @@ -12,6 +12,9 @@ export const archivedEmails = pgTable( .references(() => ingestionSources.id, { onDelete: 'cascade' }), userEmail: text('user_email').notNull(), messageIdHeader: text('message_id_header'), + /** The provider-specific message ID (e.g., Gmail API ID, Graph API ID). + * Used by the pre-fetch duplicate check to avoid unnecessary API calls during retries. */ + providerMessageId: text('provider_message_id'), sentAt: timestamp('sent_at', { withTimezone: true }).notNull(), subject: text('subject'), senderName: text('sender_name'), @@ -27,7 +30,10 @@ export const archivedEmails = pgTable( path: text('path'), tags: jsonb('tags'), }, - (table) => [index('thread_id_idx').on(table.threadId)] + (table) => [ + index('thread_id_idx').on(table.threadId), + index('provider_msg_source_idx').on(table.providerMessageId, table.ingestionSourceId), + ] ); export const archivedEmailsRelations = relations(archivedEmails, ({ one }) => ({ diff --git a/packages/backend/src/database/schema/compliance.ts b/packages/backend/src/database/schema/compliance.ts index cb3539b6..95301d7f 100644 --- a/packages/backend/src/database/schema/compliance.ts +++ b/packages/backend/src/database/schema/compliance.ts @@ -50,18 +50,20 @@ export const retentionLabels = pgTable('retention_labels', { createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), }); -export const emailRetentionLabels = pgTable('email_retention_labels', { - emailId: uuid('email_id') - .references(() => archivedEmails.id, { onDelete: 'cascade' }) - .notNull(), - labelId: uuid('label_id') - .references(() => retentionLabels.id, { onDelete: 'cascade' }) - .notNull(), - appliedAt: timestamp('applied_at', { withTimezone: true }).notNull().defaultNow(), - appliedByUserId: uuid('applied_by_user_id').references(() => users.id), -}, (t) => [ - primaryKey({ columns: [t.emailId, t.labelId] }), -]); +export const emailRetentionLabels = pgTable( + 'email_retention_labels', + { + emailId: uuid('email_id') + .references(() => archivedEmails.id, { onDelete: 'cascade' }) + .notNull(), + labelId: uuid('label_id') + .references(() => retentionLabels.id, { onDelete: 'cascade' }) + .notNull(), + appliedAt: timestamp('applied_at', { withTimezone: true }).notNull().defaultNow(), + appliedByUserId: uuid('applied_by_user_id').references(() => users.id), + }, + (t) => [primaryKey({ columns: [t.emailId, t.labelId] })] +); export const retentionEvents = pgTable('retention_events', { id: uuid('id').defaultRandom().primaryKey(), @@ -105,9 +107,7 @@ export const emailLegalHolds = pgTable( appliedAt: timestamp('applied_at', { withTimezone: true }).notNull().defaultNow(), appliedByUserId: uuid('applied_by_user_id').references(() => users.id), }, - (t) => [ - primaryKey({ columns: [t.emailId, t.legalHoldId] }), - ], + (t) => [primaryKey({ columns: [t.emailId, t.legalHoldId] })] ); export const exportJobs = pgTable('export_jobs', { diff --git a/packages/backend/src/database/schema/sync-sessions.ts b/packages/backend/src/database/schema/sync-sessions.ts new file mode 100644 index 00000000..ebc6d816 --- /dev/null +++ b/packages/backend/src/database/schema/sync-sessions.ts @@ -0,0 +1,36 @@ +import { boolean, integer, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core'; +import { ingestionSources } from './ingestion-sources'; +import { relations } from 'drizzle-orm'; + +/** + * Tracks the progress of a single sync cycle (initial import or continuous sync). + * Used as the coordination layer to replace BullMQ FlowProducer parent/child tracking. + * Each process-mailbox job atomically increments completed/failed counters here, + * and the last job to finish dispatches the sync-cycle-finished job. + */ +export const syncSessions = pgTable('sync_sessions', { + id: uuid('id').primaryKey().defaultRandom(), + ingestionSourceId: uuid('ingestion_source_id') + .notNull() + .references(() => ingestionSources.id, { onDelete: 'cascade' }), + isInitialImport: boolean('is_initial_import').notNull().default(false), + totalMailboxes: integer('total_mailboxes').notNull().default(0), + completedMailboxes: integer('completed_mailboxes').notNull().default(0), + failedMailboxes: integer('failed_mailboxes').notNull().default(0), + /** Aggregated error messages from all failed process-mailbox jobs */ + errorMessages: text('error_messages').array().notNull().default([]), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + /** + * Updated each time a process-mailbox job reports its result. + * Used to detect genuinely stuck sessions (no activity for N minutes) vs. + * large imports that are still actively running. + */ + lastActivityAt: timestamp('last_activity_at', { withTimezone: true }).notNull().defaultNow(), +}); + +export const syncSessionsRelations = relations(syncSessions, ({ one }) => ({ + ingestionSource: one(ingestionSources, { + fields: [syncSessions.ingestionSourceId], + references: [ingestionSources.id], + }), +})); diff --git a/packages/backend/src/helpers/emlUtils.ts b/packages/backend/src/helpers/emlUtils.ts new file mode 100644 index 00000000..19597c33 --- /dev/null +++ b/packages/backend/src/helpers/emlUtils.ts @@ -0,0 +1,218 @@ +import { simpleParser, type Attachment } from 'mailparser'; +import MailComposer from 'nodemailer/lib/mail-composer'; +import type Mail from 'nodemailer/lib/mailer'; +import { logger } from '../config/logger'; + +/** + * Set of headers that are either handled natively by nodemailer's MailComposer + * via dedicated options, or are structural MIME headers that will be regenerated + * when the MIME tree is rebuilt. + */ +const HEADERS_HANDLED_BY_COMPOSER = new Set([ + 'content-type', + 'content-transfer-encoding', + 'mime-version', + 'from', + 'to', + 'cc', + 'bcc', + 'subject', + 'message-id', + 'date', + 'in-reply-to', + 'references', + 'reply-to', + 'sender', +]); + +/** + * Determines whether a parsed attachment should be preserved in the stored .eml. + * + * An attachment is considered inline if: + * 1. mailparser explicitly marked it as related (embedded in multipart/related) + * 2. It has Content-Disposition: inline AND a Content-ID + * 3. Its Content-ID is referenced as a cid: URL in the HTML body + * + * All three checks are evaluated with OR logic (conservative: keep if any match). + */ +function isInlineAttachment(attachment: Attachment, referencedCids: Set): boolean { + // Signal 1: mailparser marks embedded multipart/related resources + if (attachment.related === true) { + return true; + } + + if (attachment.cid) { + const normalizedCid = attachment.cid.toLowerCase(); + + // Signal 2: explicitly marked inline with a CID + if (attachment.contentDisposition === 'inline') { + return true; + } + + // Signal 3: CID is actively referenced in the HTML body + if (referencedCids.has(normalizedCid)) { + return true; + } + } + + return false; +} + +/** + * Extracts cid: references from an HTML string. + * Matches patterns like src="cid:abc123" in img tags or CSS backgrounds. + * + * @returns A Set of normalized (lowercased) CID values without the "cid:" prefix. + */ +function extractCidReferences(html: string): Set { + const cidPattern = /\bcid:([^\s"'>]+)/gi; + const cids = new Set(); + let match: RegExpExecArray | null; + while ((match = cidPattern.exec(html)) !== null) { + cids.add(match[1].toLowerCase()); + } + return cids; +} + +/** + * Extracts additional headers from the parsed email's header map that are NOT + * handled natively by nodemailer's MailComposer dedicated options. + * These are passed through as custom headers to preserve the original email metadata. + */ +function extractAdditionalHeaders( + headers: Map +): Array<{ key: string; value: string }> { + const result: Array<{ key: string; value: string }> = []; + + for (const [key, value] of headers) { + if (HEADERS_HANDLED_BY_COMPOSER.has(key.toLowerCase())) { + continue; + } + + if (typeof value === 'string') { + result.push({ key, value }); + } else if (Array.isArray(value)) { + // Headers like 'received' can appear multiple times + for (const item of value) { + if (typeof item === 'string') { + result.push({ key, value: item }); + } else if (item && typeof item === 'object' && 'value' in item) { + result.push({ key, value: String(item.value) }); + } + } + } else if (value && typeof value === 'object' && 'value' in value) { + // Structured headers like { value: '...', params: {...} } + result.push({ key, value: String((value as { value: string }).value) }); + } + } + + return result; +} + +/** + * Converts a mailparser AddressObject or AddressObject[] to a comma-separated string + * suitable for nodemailer's MailComposer options. + */ +function addressToString( + addresses: import('mailparser').AddressObject | import('mailparser').AddressObject[] | undefined +): string | undefined { + if (!addresses) return undefined; + const arr = Array.isArray(addresses) ? addresses : [addresses]; + return arr.map((a) => a.text).join(', ') || undefined; +} + +/** + * Strips non-inline attachments from a raw .eml buffer to avoid double-storing + * attachment data (since attachments are already stored separately). + * + * Inline images referenced via cid: in the HTML body are preserved so that + * the email renders correctly when viewed. + * + * If the email has no strippable attachments, the original buffer is returned + * unchanged (zero overhead). + * + * If re-serialization fails for any reason, the original buffer is returned + * and a warning is logged — email ingestion is never blocked by this function. + * + * @param emlBuffer The raw .eml file as a Buffer. + * @returns A new Buffer with non-inline attachments removed, or the original if nothing was stripped. + */ +export async function stripAttachmentsFromEml(emlBuffer: Buffer): Promise { + try { + const parsed = await simpleParser(emlBuffer); + + // If there are no attachments at all, return early + if (!parsed.attachments || parsed.attachments.length === 0) { + return emlBuffer; + } + + // Build the set of cid values referenced in the HTML body + const htmlBody = parsed.html || ''; + const referencedCids = extractCidReferences(htmlBody); + + // Check if there's anything to strip + const hasStrippableAttachments = parsed.attachments.some( + (a) => !isInlineAttachment(a, referencedCids) + ); + + if (!hasStrippableAttachments) { + return emlBuffer; + } + + // Build the list of inline attachments to preserve in the .eml + const inlineAttachments: Mail.Attachment[] = []; + for (const attachment of parsed.attachments) { + if (isInlineAttachment(attachment, referencedCids)) { + inlineAttachments.push({ + content: attachment.content, + contentType: attachment.contentType, + contentDisposition: 'inline' as const, + filename: attachment.filename || undefined, + cid: attachment.cid || undefined, + }); + } + } + + // Collect additional headers not handled by MailComposer's dedicated fields + const additionalHeaders = extractAdditionalHeaders(parsed.headers); + + // Build the mail options for MailComposer + const mailOptions: Mail.Options = { + from: addressToString(parsed.from), + to: addressToString(parsed.to), + cc: addressToString(parsed.cc), + bcc: addressToString(parsed.bcc), + replyTo: addressToString(parsed.replyTo), + subject: parsed.subject, + messageId: parsed.messageId, + date: parsed.date, + inReplyTo: parsed.inReplyTo, + references: Array.isArray(parsed.references) + ? parsed.references.join(' ') + : parsed.references, + text: parsed.text || undefined, + html: parsed.html || undefined, + attachments: inlineAttachments, + headers: additionalHeaders, + }; + + const composer = new MailComposer(mailOptions); + const builtMessage = composer.compile(); + const stream = builtMessage.createReadStream(); + + return await new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + stream.on('data', (chunk: Buffer) => chunks.push(chunk)); + stream.on('end', () => resolve(Buffer.concat(chunks))); + stream.on('error', reject); + }); + } catch (error) { + // If stripping fails, return the original buffer unchanged. + // Email ingestion should never be blocked by an attachment-stripping failure. + logger.warn( + { error }, + 'Failed to strip non-inline attachments from .eml — storing original.' + ); + return emlBuffer; + } +} diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 0b947063..ee0f3413 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -8,6 +8,7 @@ export * from './api/middleware/requirePermission'; export { db } from './database'; export * from './database/schema'; export { AuditService } from './services/AuditService'; -export * from './config' -export * from './jobs/queues' +export * from './config'; +export * from './jobs/queues'; export { RetentionHook } from './hooks/RetentionHook'; +export { IntegrityService } from './services/IntegrityService'; diff --git a/packages/backend/src/jobs/processors/continuous-sync.processor.ts b/packages/backend/src/jobs/processors/continuous-sync.processor.ts index 19d83e0a..f9353a50 100644 --- a/packages/backend/src/jobs/processors/continuous-sync.processor.ts +++ b/packages/backend/src/jobs/processors/continuous-sync.processor.ts @@ -2,7 +2,8 @@ import { Job } from 'bullmq'; import { IngestionService } from '../../services/IngestionService'; import { IContinuousSyncJob } from '@open-archiver/types'; import { EmailProviderFactory } from '../../services/EmailProviderFactory'; -import { flowProducer } from '../queues'; +import { ingestionQueue } from '../queues'; +import { SyncSessionService } from '../../services/SyncSessionService'; import { logger } from '../../config/logger'; export default async (job: Job) => { @@ -26,50 +27,54 @@ export default async (job: Job) => { const connector = EmailProviderFactory.createConnector(source); try { - const jobs = []; + // Phase 1: Collect user emails (async generator — no full buffering of job descriptors). + // We need the total count before creating the session so the counter is correct. + const userEmails: string[] = []; for await (const user of connector.listAllUsers()) { if (user.primaryEmail) { - jobs.push({ - name: 'process-mailbox', - queueName: 'ingestion', - data: { - ingestionSourceId: source.id, - userEmail: user.primaryEmail, - }, - opts: { - removeOnComplete: { - age: 60 * 10, // 10 minutes - }, - removeOnFail: { - age: 60 * 30, // 30 minutes - }, - timeout: 1000 * 60 * 30, // 30 minutes - }, - }); + userEmails.push(user.primaryEmail); } } - // } - if (jobs.length > 0) { - await flowProducer.add({ - name: 'sync-cycle-finished', - queueName: 'ingestion', - data: { - ingestionSourceId, - isInitialImport: false, - }, - children: jobs, - opts: { - removeOnComplete: true, - removeOnFail: true, - }, + if (userEmails.length === 0) { + logger.info( + { ingestionSourceId }, + 'No users found during continuous sync, marking active.' + ); + await IngestionService.update(ingestionSourceId, { + status: 'active', + lastSyncFinishedAt: new Date(), + lastSyncStatusMessage: 'Continuous sync complete. No users found.', + }); + return; + } + + // Phase 2: Create a session BEFORE dispatching any jobs. + const sessionId = await SyncSessionService.create( + ingestionSourceId, + userEmails.length, + false + ); + + logger.info( + { ingestionSourceId, userCount: userEmails.length, sessionId }, + 'Dispatching process-mailbox jobs for continuous sync' + ); + + // Phase 3: Enqueue individual process-mailbox jobs one at a time. + // No FlowProducer — each job carries the sessionId for DB-based coordination. + for (const userEmail of userEmails) { + await ingestionQueue.add('process-mailbox', { + ingestionSourceId: source.id, + userEmail, + sessionId, }); } // The status will be set back to 'active' by the 'sync-cycle-finished' job // once all the mailboxes have been processed. logger.info( - { ingestionSourceId }, + { ingestionSourceId, sessionId }, 'Continuous sync job finished dispatching mailbox jobs.' ); } catch (error) { diff --git a/packages/backend/src/jobs/processors/index-email-batch.processor.ts b/packages/backend/src/jobs/processors/index-email-batch.processor.ts index 1b4f1735..393b2bbe 100644 --- a/packages/backend/src/jobs/processors/index-email-batch.processor.ts +++ b/packages/backend/src/jobs/processors/index-email-batch.processor.ts @@ -4,6 +4,7 @@ import { SearchService } from '../../services/SearchService'; import { StorageService } from '../../services/StorageService'; import { DatabaseService } from '../../services/DatabaseService'; import { PendingEmail } from '@open-archiver/types'; +import { logger } from '@open-archiver/backend/config/logger'; const searchService = new SearchService(); const storageService = new StorageService(); @@ -12,6 +13,6 @@ const indexingService = new IndexingService(databaseService, searchService, stor export default async function (job: Job<{ emails: PendingEmail[] }>) { const { emails } = job.data; - console.log(`Indexing email batch with ${emails.length} emails`); + logger.info(`Indexing email batch with ${emails.length} emails`); await indexingService.indexEmailBatch(emails); } diff --git a/packages/backend/src/jobs/processors/initial-import.processor.ts b/packages/backend/src/jobs/processors/initial-import.processor.ts index e3971db2..2040faaa 100644 --- a/packages/backend/src/jobs/processors/initial-import.processor.ts +++ b/packages/backend/src/jobs/processors/initial-import.processor.ts @@ -1,8 +1,9 @@ -import { Job, FlowChildJob } from 'bullmq'; +import { Job } from 'bullmq'; import { IngestionService } from '../../services/IngestionService'; -import { IInitialImportJob, IngestionProvider } from '@open-archiver/types'; +import { IInitialImportJob, IngestionStatus } from '@open-archiver/types'; import { EmailProviderFactory } from '../../services/EmailProviderFactory'; -import { flowProducer } from '../queues'; +import { ingestionQueue } from '../queues'; +import { SyncSessionService } from '../../services/SyncSessionService'; import { logger } from '../../config/logger'; export default async (job: Job) => { @@ -22,66 +23,55 @@ export default async (job: Job) => { const connector = EmailProviderFactory.createConnector(source); - // if (connector instanceof GoogleWorkspaceConnector || connector instanceof MicrosoftConnector) { - const jobs: FlowChildJob[] = []; - let userCount = 0; + // Phase 1: Collect user emails from the provider (async generator — no full buffering + // of FlowChildJob objects). Email strings are tiny (~30 bytes each) compared to the + // old FlowChildJob descriptors (~500 bytes each), and we need the count before we can + // create the session. + const userEmails: string[] = []; for await (const user of connector.listAllUsers()) { if (user.primaryEmail) { - jobs.push({ - name: 'process-mailbox', - queueName: 'ingestion', - data: { - ingestionSourceId, - userEmail: user.primaryEmail, - }, - opts: { - removeOnComplete: { - age: 60 * 10, // 10 minutes - }, - removeOnFail: { - age: 60 * 30, // 30 minutes - }, - attempts: 1, - // failParentOnFailure: true - }, - }); - userCount++; + userEmails.push(user.primaryEmail); } } - if (jobs.length > 0) { - logger.info( - { ingestionSourceId, userCount }, - 'Adding sync-cycle-finished job to the queue' - ); - await flowProducer.add({ - name: 'sync-cycle-finished', - queueName: 'ingestion', - data: { - ingestionSourceId, - userCount, - isInitialImport: true, - }, - children: jobs, - opts: { - removeOnComplete: true, - removeOnFail: true, - }, - }); - } else { + if (userEmails.length === 0) { const fileBasedIngestions = IngestionService.returnFileBasedIngestions(); - const finalStatus = fileBasedIngestions.includes(source.provider) + const finalStatus: IngestionStatus = fileBasedIngestions.includes(source.provider) ? 'imported' : 'active'; - // If there are no users, we can consider the import finished and set to active await IngestionService.update(ingestionSourceId, { status: finalStatus, lastSyncFinishedAt: new Date(), lastSyncStatusMessage: 'Initial import complete. No users found.', }); + logger.info({ ingestionSourceId }, 'No users found, initial import complete'); + return; + } + + // Phase 2: Create a session BEFORE dispatching any jobs to avoid a race condition + // where a process-mailbox job finishes before the session's totalMailboxes is set. + const sessionId = await SyncSessionService.create( + ingestionSourceId, + userEmails.length, + true + ); + + logger.info( + { ingestionSourceId, userCount: userEmails.length, sessionId }, + 'Dispatching process-mailbox jobs for initial import' + ); + + // Phase 3: Enqueue individual process-mailbox jobs one at a time. + // No FlowProducer, no large atomic Redis write — jobs are enqueued in a loop. + for (const userEmail of userEmails) { + await ingestionQueue.add('process-mailbox', { + ingestionSourceId, + userEmail, + sessionId, + }); } - logger.info({ ingestionSourceId }, 'Finished initial import master job'); + logger.info({ ingestionSourceId, sessionId }, 'Finished dispatching initial import jobs'); } catch (error) { logger.error({ err: error, ingestionSourceId }, 'Error in initial import master job'); await IngestionService.update(ingestionSourceId, { diff --git a/packages/backend/src/jobs/processors/process-mailbox.processor.ts b/packages/backend/src/jobs/processors/process-mailbox.processor.ts index ee73049d..66b211dd 100644 --- a/packages/backend/src/jobs/processors/process-mailbox.processor.ts +++ b/packages/backend/src/jobs/processors/process-mailbox.processor.ts @@ -1,38 +1,29 @@ import { Job } from 'bullmq'; -import { - IProcessMailboxJob, - SyncState, - ProcessMailboxError, - PendingEmail, -} from '@open-archiver/types'; +import { IProcessMailboxJob, ProcessMailboxError, PendingEmail } from '@open-archiver/types'; import { IngestionService } from '../../services/IngestionService'; import { logger } from '../../config/logger'; import { EmailProviderFactory } from '../../services/EmailProviderFactory'; import { StorageService } from '../../services/StorageService'; -import { IndexingService } from '../../services/IndexingService'; -import { SearchService } from '../../services/SearchService'; -import { DatabaseService } from '../../services/DatabaseService'; import { config } from '../../config'; -import { indexingQueue } from '../queues'; +import { indexingQueue, ingestionQueue } from '../queues'; +import { SyncSessionService } from '../../services/SyncSessionService'; /** - * This processor handles the ingestion of emails for a single user's mailbox. - * If an error occurs during processing (e.g., an API failure), - * it catches the exception and returns a structured error object instead of throwing. - * This prevents a single failed mailbox from halting the entire sync cycle for all users. - * The parent 'sync-cycle-finished' job is responsible for inspecting the results of all - * 'process-mailbox' jobs, aggregating successes, and reporting detailed failures. + * Handles ingestion of emails for a single user's mailbox. + * + * On completion, it reports its result to SyncSessionService using an atomic DB counter. + * If this is the last mailbox job in the session, it dispatches the 'sync-cycle-finished' job. + * This replaces the BullMQ FlowProducer parent/child pattern, avoiding the memory and Redis + * overhead of loading all children's return values at once. */ -export const processMailboxProcessor = async (job: Job) => { - const { ingestionSourceId, userEmail } = job.data; +export const processMailboxProcessor = async (job: Job) => { + const { ingestionSourceId, userEmail, sessionId } = job.data; const BATCH_SIZE: number = config.meili.indexingBatchSize; let emailBatch: PendingEmail[] = []; - logger.info({ ingestionSourceId, userEmail }, `Processing mailbox for user`); + logger.info({ ingestionSourceId, userEmail, sessionId }, `Processing mailbox for user`); - const searchService = new SearchService(); const storageService = new StorageService(); - const databaseService = new DatabaseService(); try { const source = await IngestionService.findById(ingestionSourceId); @@ -43,7 +34,7 @@ export const processMailboxProcessor = async (job: Job { return await IngestionService.doesEmailExist(messageId, ingestionSourceId); }; @@ -65,6 +56,12 @@ export const processMailboxProcessor = async (job: Job= BATCH_SIZE) { await indexingQueue.add('index-email-batch', { emails: emailBatch }); emailBatch = []; + // Heartbeat: a single large mailbox can take hours to process. + // Without this, cleanStaleSessions() would see no activity on the + // session and incorrectly mark it as stale after 30 minutes. + // We piggyback on the existing batch flush cadence — no extra DB + // writes beyond what we'd do anyway. + await SyncSessionService.heartbeat(sessionId); } } } @@ -77,8 +74,26 @@ export const processMailboxProcessor = async (job: Job 0) { await indexingQueue.add('index-email-batch', { emails: emailBatch }); emailBatch = []; @@ -90,6 +105,33 @@ export const processMailboxProcessor = async (job: Job { - console.log('Scheduler running: Looking for active or error ingestion sources to sync.'); - // find all sources that have the status of active or error for continuous syncing. + logger.info({}, 'Scheduler running: checking for stale sessions and active sources to sync.'); + + // Step 1: Clean up any stale sync sessions from previous crashed runs. + // A session is stale when lastActivityAt hasn't been updated in 30 minutes — + // meaning no process-mailbox job has reported back, indicating the worker crashed + // after creating the session but before all jobs were enqueued. + // This sets the associated ingestion source to 'error' so Step 2 picks it up. + try { + await SyncSessionService.cleanStaleSessions(); + } catch (error) { + // Log but don't abort — stale session cleanup is best-effort + logger.error({ err: error }, 'Error during stale session cleanup in scheduler'); + } + + // Step 2: Find all sources with status 'active' or 'error' for continuous syncing. + // Sources previously stuck in 'importing'/'syncing' due to a crash will now appear + // as 'error' (set by cleanStaleSessions above) and will be picked up here for retry. const sourcesToSync = await db .select({ id: ingestionSources.id }) .from(ingestionSources) .where(or(eq(ingestionSources.status, 'active'), eq(ingestionSources.status, 'error'))); + logger.info({ count: sourcesToSync.length }, 'Dispatching continuous-sync jobs for sources'); + for (const source of sourcesToSync) { - // The status field on the ingestion source is used to prevent duplicate syncs. + // The status field on the ingestion source prevents duplicate concurrent syncs. await ingestionQueue.add('continuous-sync', { ingestionSourceId: source.id }); } }; diff --git a/packages/backend/src/jobs/processors/sync-cycle-finished.processor.ts b/packages/backend/src/jobs/processors/sync-cycle-finished.processor.ts index 5b4a8626..1774c95d 100644 --- a/packages/backend/src/jobs/processors/sync-cycle-finished.processor.ts +++ b/packages/backend/src/jobs/processors/sync-cycle-finished.processor.ts @@ -1,103 +1,74 @@ import { Job } from 'bullmq'; import { IngestionService } from '../../services/IngestionService'; +import { SyncSessionService } from '../../services/SyncSessionService'; import { logger } from '../../config/logger'; -import { - SyncState, - ProcessMailboxError, - IngestionStatus, - IngestionProvider, -} from '@open-archiver/types'; -import { db } from '../../database'; -import { ingestionSources } from '../../database/schema'; -import { eq } from 'drizzle-orm'; -import { deepmerge } from 'deepmerge-ts'; +import { IngestionStatus } from '@open-archiver/types'; interface ISyncCycleFinishedJob { ingestionSourceId: string; - userCount?: number; // Optional, as it's only relevant for the initial import + sessionId: string; isInitialImport: boolean; } /** - * This processor runs after all 'process-mailbox' jobs for a sync cycle have completed. - * It is responsible for aggregating the results and finalizing the sync status. - * It inspects the return values of all child jobs to identify successes and failures. - * - * If any child jobs returned an error object, this processor will: - * 1. Mark the overall ingestion status as 'error'. - * 2. Aggregate the detailed error messages from all failed jobs. - * 3. Save the sync state from any jobs that *did* succeed, preserving partial progress. - * - * If all child jobs succeeded, it marks the ingestion as 'active' and saves the final - * aggregated sync state from all children. + * Finalizes a sync cycle after all process-mailbox jobs have completed. * + * This processor no longer uses BullMQ's job.getChildrenValues() or deepmerge. + * Instead, it reads the aggregated results from the sync_sessions table in PostgreSQL, + * where each process-mailbox job has already atomically recorded its outcome and + * incrementally merged its SyncState into ingestion_sources.sync_state. */ -export default async (job: Job) => { - const { ingestionSourceId, userCount, isInitialImport } = job.data; +export default async (job: Job) => { + const { ingestionSourceId, sessionId, isInitialImport } = job.data; + logger.info( - { ingestionSourceId, userCount, isInitialImport }, + { ingestionSourceId, sessionId, isInitialImport }, 'Sync cycle finished job started' ); try { - const childrenValues = await job.getChildrenValues(); - const allChildJobs = Object.values(childrenValues); - // if data has error property, it is a failed job - const failedJobs = allChildJobs.filter( - (v) => v && (v as any).error - ) as ProcessMailboxError[]; - // if data doesn't have error property, it is a successful job with SyncState - const successfulJobs = allChildJobs.filter((v) => !v || !(v as any).error) as SyncState[]; - - const finalSyncState = deepmerge( - ...successfulJobs.filter((s) => s && Object.keys(s).length > 0) - ) as SyncState; + const session = await SyncSessionService.findById(sessionId); - const source = await IngestionService.findById(ingestionSourceId); let status: IngestionStatus = 'active'; + let message: string; + const fileBasedIngestions = IngestionService.returnFileBasedIngestions(); + const source = await IngestionService.findById(ingestionSourceId); if (fileBasedIngestions.includes(source.provider)) { status = 'imported'; } - let message: string; - - // Check for a specific rate-limit message from the successful jobs - const rateLimitMessage = successfulJobs.find( - (j) => j.statusMessage && j.statusMessage.includes('rate limit') - )?.statusMessage; - if (failedJobs.length > 0) { + if (session.failedMailboxes > 0) { status = 'error'; - const errorMessages = failedJobs.map((j) => j.message).join('\n'); - message = `Sync cycle completed with ${failedJobs.length} error(s):\n${errorMessages}`; + const errorMessages = session.errorMessages.join('\n'); + message = `Sync cycle completed with ${session.failedMailboxes} error(s):\n${errorMessages}`; logger.error( - { ingestionSourceId, errors: errorMessages }, + { ingestionSourceId, sessionId, errors: errorMessages }, 'Sync cycle finished with errors.' ); - } else if (rateLimitMessage) { - message = rateLimitMessage; - logger.warn({ ingestionSourceId, message }, 'Sync cycle paused due to rate limiting.'); } else { - message = 'Continuous sync cycle finished successfully.'; - if (isInitialImport) { - message = `Initial import finished for ${userCount} mailboxes.`; - } - logger.info({ ingestionSourceId }, 'Successfully updated status and final sync state.'); + message = isInitialImport + ? `Initial import finished for ${session.completedMailboxes} mailboxes.` + : 'Continuous sync cycle finished successfully.'; + logger.info({ ingestionSourceId, sessionId }, 'Sync cycle finished successfully.'); } - await db - .update(ingestionSources) - .set({ - status, - lastSyncFinishedAt: new Date(), - lastSyncStatusMessage: message, - syncState: finalSyncState, - }) - .where(eq(ingestionSources.id, ingestionSourceId)); + // syncState was already merged incrementally by each process-mailbox job via + // SyncSessionService.recordMailboxResult() — no deepmerge needed here. + await IngestionService.update(ingestionSourceId, { + status, + lastSyncFinishedAt: new Date(), + lastSyncStatusMessage: message, + }); + + // Clean up the session row + await SyncSessionService.finalize(sessionId); + + logger.info({ ingestionSourceId, sessionId, status }, 'Sync cycle finalized'); } catch (error) { logger.error( - { err: error, ingestionSourceId }, + { err: error, ingestionSourceId, sessionId }, 'An unexpected error occurred while finalizing the sync cycle.' ); await IngestionService.update(ingestionSourceId, { diff --git a/packages/backend/src/jobs/queues.ts b/packages/backend/src/jobs/queues.ts index 339d2b04..c8d4cf99 100644 --- a/packages/backend/src/jobs/queues.ts +++ b/packages/backend/src/jobs/queues.ts @@ -1,8 +1,6 @@ -import { Queue, FlowProducer } from 'bullmq'; +import { Queue } from 'bullmq'; import { connection } from '../config/redis'; -export const flowProducer = new FlowProducer({ connection }); - // Default job options const defaultJobOptions = { attempts: 5, diff --git a/packages/backend/src/jobs/schedulers/sync-scheduler.ts b/packages/backend/src/jobs/schedulers/sync-scheduler.ts index 90a2edc3..4e355b3c 100644 --- a/packages/backend/src/jobs/schedulers/sync-scheduler.ts +++ b/packages/backend/src/jobs/schedulers/sync-scheduler.ts @@ -1,6 +1,7 @@ import { ingestionQueue } from '../queues'; import { config } from '../../config'; +import { logger } from '@open-archiver/backend/config/logger'; const scheduleContinuousSync = async () => { // This job will run every 15 minutes @@ -17,5 +18,5 @@ const scheduleContinuousSync = async () => { }; scheduleContinuousSync().then(() => { - console.log('Continuous sync scheduler started.'); + logger.info('Continuous sync scheduler started.'); }); diff --git a/packages/backend/src/locales/en/translation.json b/packages/backend/src/locales/en/translation.json index 9ac9c2bc..b993887b 100644 --- a/packages/backend/src/locales/en/translation.json +++ b/packages/backend/src/locales/en/translation.json @@ -66,5 +66,12 @@ }, "api": { "requestBodyInvalid": "Invalid request body." + }, + "upload": { + "invalid_request": "The upload request is invalid or malformed.", + "stream_error": "An error occurred while receiving the file. Please try again.", + "parse_error": "Failed to parse the uploaded file data.", + "storage_error": "Failed to save the uploaded file to storage. Please try again.", + "connection_error": "The connection was lost during the upload." } } diff --git a/packages/backend/src/services/ApiKeyService.ts b/packages/backend/src/services/ApiKeyService.ts index 0f2a6a95..0b477c27 100644 --- a/packages/backend/src/services/ApiKeyService.ts +++ b/packages/backend/src/services/ApiKeyService.ts @@ -31,16 +31,16 @@ export class ApiKeyService { await this.auditService.createAuditLog({ actorIdentifier: actor.id, - actionType: 'GENERATE', - targetType: 'ApiKey', - targetId: name, - actorIp, - details: { - keyName: name, - }, - }); + actionType: 'GENERATE', + targetType: 'ApiKey', + targetId: name, + actorIp, + details: { + keyName: name, + }, + }); - return key; + return key; } catch (error) { throw error; } diff --git a/packages/backend/src/services/IngestionService.ts b/packages/backend/src/services/IngestionService.ts index 037cf41f..e3388afe 100644 --- a/packages/backend/src/services/IngestionService.ts +++ b/packages/backend/src/services/IngestionService.ts @@ -8,13 +8,14 @@ import type { IngestionProvider, PendingEmail, } from '@open-archiver/types'; -import { and, desc, eq } from 'drizzle-orm'; +import { and, desc, eq, or } from 'drizzle-orm'; import { CryptoService } from './CryptoService'; import { EmailProviderFactory } from './EmailProviderFactory'; import { ingestionQueue } from '../jobs/queues'; import type { JobType } from 'bullmq'; import { StorageService } from './StorageService'; import type { IInitialImportJob, EmailObject } from '@open-archiver/types'; +import { stripAttachmentsFromEml } from '../helpers/emlUtils'; import { archivedEmails, attachments as attachmentsSchema, @@ -391,8 +392,9 @@ export class IngestionService { } /** - * Quickly checks if an email exists in the database by its Message-ID header. - * This is used to skip downloading duplicate emails during ingestion. + * Pre-fetch duplicate check to avoid unnecessary API calls during ingestion. + * Checks both providerMessageId (for Google/Microsoft API IDs) and + * messageIdHeader (for IMAP/PST/EML/Mbox RFC Message-IDs and pre-migration rows). */ public static async doesEmailExist( messageId: string, @@ -400,12 +402,14 @@ export class IngestionService { ): Promise { const existingEmail = await db.query.archivedEmails.findFirst({ where: and( - eq(archivedEmails.messageIdHeader, messageId), - eq(archivedEmails.ingestionSourceId, ingestionSourceId) + eq(archivedEmails.ingestionSourceId, ingestionSourceId), + or( + eq(archivedEmails.providerMessageId, messageId), + eq(archivedEmails.messageIdHeader, messageId) + ) ), columns: { id: true }, }); - return !!existingEmail; } @@ -446,7 +450,10 @@ export class IngestionService { return null; } - const emlBuffer = email.eml ?? Buffer.from(email.body, 'utf-8'); + const rawEmlBuffer = email.eml ?? Buffer.from(email.body, 'utf-8'); + // Strip non-inline attachments from the .eml to avoid double-storing + // attachment data (attachments are stored separately). + const emlBuffer = await stripAttachmentsFromEml(rawEmlBuffer); const emailHash = createHash('sha256').update(emlBuffer).digest('hex'); const sanitizedPath = email.path ? email.path : ''; const emailPath = `${config.storage.openArchiverFolderName}/${source.name.replaceAll(' ', '-')}-${source.id}/emails/${sanitizedPath}${email.id}.eml`; @@ -459,6 +466,7 @@ export class IngestionService { userEmail, threadId: email.threadId, messageIdHeader: messageId, + providerMessageId: email.id, sentAt: email.receivedAt, subject: email.subject, senderName: email.from[0]?.name, diff --git a/packages/backend/src/services/IntegrityService.ts b/packages/backend/src/services/IntegrityService.ts index 5e8fd301..167e3c86 100644 --- a/packages/backend/src/services/IntegrityService.ts +++ b/packages/backend/src/services/IntegrityService.ts @@ -28,13 +28,21 @@ export class IntegrityService { const currentEmailHash = createHash('sha256').update(emailBuffer).digest('hex'); if (currentEmailHash === email.storageHashSha256) { - results.push({ type: 'email', id: email.id, isValid: true }); + results.push({ + type: 'email', + id: email.id, + isValid: true, + storedHash: email.storageHashSha256, + computedHash: currentEmailHash, + }); } else { results.push({ type: 'email', id: email.id, isValid: false, reason: 'Stored hash does not match current hash.', + storedHash: email.storageHashSha256, + computedHash: currentEmailHash, }); } @@ -62,6 +70,8 @@ export class IntegrityService { id: attachment.id, filename: attachment.filename, isValid: true, + storedHash: attachment.contentHashSha256, + computedHash: currentAttachmentHash, }); } else { results.push({ @@ -70,6 +80,8 @@ export class IntegrityService { filename: attachment.filename, isValid: false, reason: 'Stored hash does not match current hash.', + storedHash: attachment.contentHashSha256, + computedHash: currentAttachmentHash, }); } } catch (error) { @@ -83,6 +95,8 @@ export class IntegrityService { filename: attachment.filename, isValid: false, reason: 'Could not read attachment file from storage.', + storedHash: attachment.contentHashSha256, + computedHash: '', }); } } diff --git a/packages/backend/src/services/SyncSessionService.ts b/packages/backend/src/services/SyncSessionService.ts new file mode 100644 index 00000000..675471e4 --- /dev/null +++ b/packages/backend/src/services/SyncSessionService.ts @@ -0,0 +1,260 @@ +import { db } from '../database'; +import { syncSessions, ingestionSources } from '../database/schema'; +import { eq, lt, sql } from 'drizzle-orm'; +import type { SyncState, ProcessMailboxError } from '@open-archiver/types'; +import { logger } from '../config/logger'; + +export interface SyncSessionRecord { + id: string; + ingestionSourceId: string; + isInitialImport: boolean; + totalMailboxes: number; + completedMailboxes: number; + failedMailboxes: number; + errorMessages: string[]; + createdAt: Date; + lastActivityAt: Date; +} + +export interface MailboxResultOutcome { + /** True if this was the last mailbox job in the session (should trigger finalization) */ + isLast: boolean; + totalCompleted: number; + totalFailed: number; + errorMessages: string[]; +} + +export class SyncSessionService { + /** + * Creates a new sync session for a given ingestion source and returns its ID. + * Must be called before any process-mailbox jobs are dispatched. + */ + public static async create( + ingestionSourceId: string, + totalMailboxes: number, + isInitialImport: boolean + ): Promise { + const [session] = await db + .insert(syncSessions) + .values({ + ingestionSourceId, + totalMailboxes, + isInitialImport, + completedMailboxes: 0, + failedMailboxes: 0, + errorMessages: [], + }) + .returning({ id: syncSessions.id }); + + logger.info( + { sessionId: session.id, ingestionSourceId, totalMailboxes, isInitialImport }, + 'Sync session created' + ); + + return session.id; + } + + /** + * Atomically records the result of a single process-mailbox job. + * Increments either completedMailboxes or failedMailboxes depending on the result. + * If the result is a successful SyncState, it is merged into the ingestion source's + * syncState column using PostgreSQL's jsonb merge operator. + * + * Returns whether this was the last mailbox job in the session. + */ + public static async recordMailboxResult( + sessionId: string, + result: SyncState | ProcessMailboxError + ): Promise { + const isError = (result as ProcessMailboxError).error === true; + + // Atomically increment the appropriate counter and append error message if needed. + // The RETURNING clause ensures we get the post-update values to check if this is the last job. + const [updated] = await db + .update(syncSessions) + .set({ + completedMailboxes: isError + ? syncSessions.completedMailboxes + : sql`${syncSessions.completedMailboxes} + 1`, + failedMailboxes: isError + ? sql`${syncSessions.failedMailboxes} + 1` + : syncSessions.failedMailboxes, + errorMessages: isError + ? sql`array_append(${syncSessions.errorMessages}, ${(result as ProcessMailboxError).message})` + : syncSessions.errorMessages, + // Touch lastActivityAt on every result so the stale-session detector + // knows this session is still alive, regardless of how long it has been running. + lastActivityAt: new Date(), + }) + .where(eq(syncSessions.id, sessionId)) + .returning({ + completedMailboxes: syncSessions.completedMailboxes, + failedMailboxes: syncSessions.failedMailboxes, + totalMailboxes: syncSessions.totalMailboxes, + errorMessages: syncSessions.errorMessages, + ingestionSourceId: syncSessions.ingestionSourceId, + }); + + if (!updated) { + throw new Error(`Sync session ${sessionId} not found when recording mailbox result.`); + } + + // If the result is a successful SyncState with actual content, merge it into the + // ingestion source's syncState column using PostgreSQL's || jsonb merge operator. + // This is done incrementally per mailbox to avoid the large deepmerge at the end. + if (!isError) { + const syncState = result as SyncState; + if (Object.keys(syncState).length > 0) { + await db + .update(ingestionSources) + .set({ + syncState: sql`COALESCE(${ingestionSources.syncState}, '{}'::jsonb) || ${JSON.stringify(syncState)}::jsonb`, + }) + .where(eq(ingestionSources.id, updated.ingestionSourceId)); + } + } + + const totalProcessed = updated.completedMailboxes + updated.failedMailboxes; + const isLast = totalProcessed >= updated.totalMailboxes; + + logger.info( + { + sessionId, + completed: updated.completedMailboxes, + failed: updated.failedMailboxes, + total: updated.totalMailboxes, + isLast, + }, + 'Mailbox result recorded' + ); + + return { + isLast, + totalCompleted: updated.completedMailboxes, + totalFailed: updated.failedMailboxes, + errorMessages: updated.errorMessages, + }; + } + + /** + * Fetches a sync session by its ID. + */ + public static async findById(sessionId: string): Promise { + const [session] = await db + .select() + .from(syncSessions) + .where(eq(syncSessions.id, sessionId)); + + if (!session) { + throw new Error(`Sync session ${sessionId} not found.`); + } + + return session; + } + + /** + * Updates lastActivityAt for the session without changing any counters. + * Should be called periodically during a long-running process-mailbox job + * to prevent cleanStaleSessions() from incorrectly treating an actively + * processing mailbox as stale. + * + */ + public static async heartbeat(sessionId: string): Promise { + try { + logger.info('heatbeat, ', sessionId); + await db + .update(syncSessions) + .set({ lastActivityAt: new Date() }) + .where(eq(syncSessions.id, sessionId)); + } catch (error) { + logger.warn({ err: error, sessionId }, 'Failed to update session heartbeat'); + } + } + + /** + * Deletes a sync session after finalization to keep the table clean. + */ + public static async finalize(sessionId: string): Promise { + await db.delete(syncSessions).where(eq(syncSessions.id, sessionId)); + logger.info({ sessionId }, 'Sync session finalized and deleted'); + } + + /** + * Finds all sync sessions that are stale and marks the associated ingestion source + * as 'error', then deletes the orphaned session row. + * + * Staleness is determined by lastActivityAt — the timestamp updated every time a + * process-mailbox job reports a result. This correctly handles large imports that run + * for many hours: as long as mailboxes are actively completing, lastActivityAt stays + * fresh and the session is never considered stale. + * + * A session is stale when: + * completedMailboxes + failedMailboxes < totalMailboxes + * AND lastActivityAt < (now - thresholdMs) + * + * Default threshold: 30 minutes of inactivity. This covers the crash scenario where + * the processor died after creating the session but before all process-mailbox jobs + * were enqueued — those jobs will never report back, causing permanent inactivity. + * + * Once cleaned up, the source is set to 'error' so the next scheduler tick will + * re-queue a continuous-sync job. + */ + public static async cleanStaleSessions( + thresholdMs: number = 30 * 60 * 1000 // 30 minutes of inactivity + ): Promise { + const cutoffTime = new Date(Date.now() - thresholdMs); + + // Find sessions with no recent activity (regardless of how old they are) + const staleSessions = await db + .select() + .from(syncSessions) + .where(lt(syncSessions.lastActivityAt, cutoffTime)); + + for (const session of staleSessions) { + const totalProcessed = session.completedMailboxes + session.failedMailboxes; + if (totalProcessed >= session.totalMailboxes) { + // Session finished but was never finalized (e.g., sync-cycle-finished job + // was lost) — clean it up silently without touching the source status. + await db.delete(syncSessions).where(eq(syncSessions.id, session.id)); + logger.warn( + { sessionId: session.id, ingestionSourceId: session.ingestionSourceId }, + 'Cleaned up completed-but-unfinalized stale sync session' + ); + continue; + } + + // Session is genuinely stuck — no mailbox activity for the threshold period. + const inactiveMinutes = Math.round( + (Date.now() - session.lastActivityAt.getTime()) / 60000 + ); + + logger.warn( + { + sessionId: session.id, + ingestionSourceId: session.ingestionSourceId, + totalMailboxes: session.totalMailboxes, + completedMailboxes: session.completedMailboxes, + failedMailboxes: session.failedMailboxes, + inactiveMinutes, + }, + 'Stale sync session detected — marking source as error and cleaning up' + ); + + await db + .update(ingestionSources) + .set({ + status: 'error', + lastSyncFinishedAt: new Date(), + lastSyncStatusMessage: `Sync interrupted: no activity for ${inactiveMinutes} minutes. ${session.completedMailboxes} of ${session.totalMailboxes} mailboxes completed. Will retry on next sync cycle.`, + }) + .where(eq(ingestionSources.id, session.ingestionSourceId)); + + await db.delete(syncSessions).where(eq(syncSessions.id, session.id)); + + logger.info( + { sessionId: session.id, ingestionSourceId: session.ingestionSourceId }, + 'Stale sync session cleaned up, source set to error for retry' + ); + } + } +} diff --git a/packages/backend/src/services/ingestion-connectors/EMLConnector.ts b/packages/backend/src/services/ingestion-connectors/EMLConnector.ts index 1bc15201..81aadaca 100644 --- a/packages/backend/src/services/ingestion-connectors/EMLConnector.ts +++ b/packages/backend/src/services/ingestion-connectors/EMLConnector.ts @@ -78,7 +78,9 @@ export class EMLConnector implements IEmailConnector { if (!fileExist) { if (this.credentials.localFilePath) { - throw Error(`EML Zip file not found at path: ${this.credentials.localFilePath}`); + throw Error( + `EML Zip file not found at path: ${this.credentials.localFilePath}` + ); } else { throw Error( 'Uploaded EML Zip file not found. The upload may not have finished yet, or it failed.' @@ -256,10 +258,7 @@ export class EMLConnector implements IEmailConnector { } } - private async parseMessage( - input: Buffer | Readable, - path: string - ): Promise { + private async parseMessage(input: Buffer | Readable, path: string): Promise { let emlBuffer: Buffer; if (Buffer.isBuffer(input)) { emlBuffer = input; diff --git a/packages/backend/src/services/ingestion-connectors/GoogleWorkspaceConnector.ts b/packages/backend/src/services/ingestion-connectors/GoogleWorkspaceConnector.ts index d742932f..cd704f19 100644 --- a/packages/backend/src/services/ingestion-connectors/GoogleWorkspaceConnector.ts +++ b/packages/backend/src/services/ingestion-connectors/GoogleWorkspaceConnector.ts @@ -225,7 +225,6 @@ export class GoogleWorkspaceConnector implements IEmailConnector { ); }; const threadId = getThreadId(parsedEmail.headers); - console.log('threadId', threadId); yield { id: msgResponse.data.id!, threadId, @@ -348,7 +347,6 @@ export class GoogleWorkspaceConnector implements IEmailConnector { ); }; const threadId = getThreadId(parsedEmail.headers); - console.log('threadId', threadId); yield { id: msgResponse.data.id!, threadId, diff --git a/packages/backend/src/services/ingestion-connectors/ImapConnector.ts b/packages/backend/src/services/ingestion-connectors/ImapConnector.ts index 2ef22eba..d843ed7e 100644 --- a/packages/backend/src/services/ingestion-connectors/ImapConnector.ts +++ b/packages/backend/src/services/ingestion-connectors/ImapConnector.ts @@ -197,7 +197,7 @@ export class ImapConnector implements IEmailConnector { // Only fetch if the mailbox has messages, to avoid errors on empty mailboxes with some IMAP servers. if (mailbox.exists > 0) { - const BATCH_SIZE = 250; // A configurable batch size + const BATCH_SIZE = 250; let startUid = (lastUid || 0) + 1; const maxUidToFetch = currentMaxUid; @@ -205,10 +205,11 @@ export class ImapConnector implements IEmailConnector { const endUid = Math.min(startUid + BATCH_SIZE - 1, maxUidToFetch); const searchCriteria = { uid: `${startUid}:${endUid}` }; + // --- Pass 1: fetch only envelope + uid (no source) for the entire batch. + const uidsToFetch: number[] = []; + for await (const msg of this.client.fetch(searchCriteria, { envelope: true, - source: true, - bodyStructure: true, uid: true, })) { if (lastUid && msg.uid <= lastUid) { @@ -219,9 +220,13 @@ export class ImapConnector implements IEmailConnector { this.newMaxUids[mailboxPath] = msg.uid; } - // Optimization: Verify existence using Message-ID from envelope before fetching full body + // Duplicate check against the Message-ID from the envelope. + // If a duplicate is found we skip fetching the full source entirely, + // avoiding loading attachment binary data into memory for known emails. if (checkDuplicate && msg.envelope?.messageId) { - const isDuplicate = await checkDuplicate(msg.envelope.messageId); + const isDuplicate = await checkDuplicate( + msg.envelope.messageId + ); if (isDuplicate) { logger.debug( { @@ -235,18 +240,42 @@ export class ImapConnector implements IEmailConnector { } } - logger.debug({ mailboxPath, uid: msg.uid }, 'Processing message'); + if (msg.envelope) { + uidsToFetch.push(msg.uid); + } + } - if (msg.envelope && msg.source) { - try { - yield await this.parseMessage(msg, mailboxPath); - } catch (err: any) { - logger.error( - { err, mailboxPath, uid: msg.uid }, - 'Failed to parse message' - ); - throw err; + // --- Pass 2: fetch full source one message at a time for non-duplicate UIDs. + for (const uid of uidsToFetch) { + logger.debug( + { mailboxPath, uid }, + 'Fetching full source for message' + ); + + try { + const fullMsg = await this.withRetry( + async () => + await this.client.fetchOne( + String(uid), + { + envelope: true, + source: true, + bodyStructure: true, + uid: true, + }, + { uid: true } + ) + ); + + if (fullMsg && fullMsg.envelope && fullMsg.source) { + yield await this.parseMessage(fullMsg, mailboxPath); } + } catch (err: any) { + logger.error( + { err, mailboxPath, uid }, + 'Failed to fetch or parse message' + ); + throw err; } } diff --git a/packages/backend/src/services/ingestion-connectors/MboxConnector.ts b/packages/backend/src/services/ingestion-connectors/MboxConnector.ts index 885a0991..b9c80051 100644 --- a/packages/backend/src/services/ingestion-connectors/MboxConnector.ts +++ b/packages/backend/src/services/ingestion-connectors/MboxConnector.ts @@ -93,10 +93,7 @@ export class MboxConnector implements IEmailConnector { return true; } catch (error) { - logger.error( - { error, credentials: this.credentials }, - 'Mbox file validation failed.' - ); + logger.error({ error, credentials: this.credentials }, 'Mbox file validation failed.'); throw error; } } diff --git a/packages/backend/src/services/ingestion-connectors/MicrosoftConnector.ts b/packages/backend/src/services/ingestion-connectors/MicrosoftConnector.ts index 911e2028..83e9457d 100644 --- a/packages/backend/src/services/ingestion-connectors/MicrosoftConnector.ts +++ b/packages/backend/src/services/ingestion-connectors/MicrosoftConnector.ts @@ -136,7 +136,8 @@ export class MicrosoftConnector implements IEmailConnector { */ public async *fetchEmails( userEmail: string, - syncState?: SyncState | null + syncState?: SyncState | null, + checkDuplicate?: (messageId: string) => Promise ): AsyncGenerator { this.newDeltaTokens = syncState?.microsoft?.[userEmail]?.deltaTokens || {}; @@ -152,7 +153,8 @@ export class MicrosoftConnector implements IEmailConnector { userEmail, folder.id, folder.path, - this.newDeltaTokens[folder.id] + this.newDeltaTokens[folder.id], + checkDuplicate ); } } @@ -214,7 +216,8 @@ export class MicrosoftConnector implements IEmailConnector { userEmail: string, folderId: string, path: string, - deltaToken?: string + deltaToken?: string, + checkDuplicate?: (messageId: string) => Promise ): AsyncGenerator { let requestUrl: string | undefined; @@ -235,6 +238,15 @@ export class MicrosoftConnector implements IEmailConnector { for (const message of response.value) { if (message.id && !message['@removed']) { + // Skip fetching raw content for already-imported messages + if (checkDuplicate && (await checkDuplicate(message.id))) { + logger.debug( + { messageId: message.id, userEmail }, + 'Skipping duplicate email (pre-check)' + ); + continue; + } + const rawEmail = await this.getRawEmail(userEmail, message.id); if (rawEmail) { const emailObject = await this.parseEmail( @@ -243,7 +255,7 @@ export class MicrosoftConnector implements IEmailConnector { userEmail, path ); - emailObject.threadId = message.conversationId; // Add conversationId as threadId + emailObject.threadId = message.conversationId; yield emailObject; } } diff --git a/packages/backend/src/services/ingestion-connectors/PSTConnector.ts b/packages/backend/src/services/ingestion-connectors/PSTConnector.ts index f3b139a6..6499d42d 100644 --- a/packages/backend/src/services/ingestion-connectors/PSTConnector.ts +++ b/packages/backend/src/services/ingestion-connectors/PSTConnector.ts @@ -171,10 +171,7 @@ export class PSTConnector implements IEmailConnector { } return true; } catch (error) { - logger.error( - { error, credentials: this.credentials }, - 'PST file validation failed.' - ); + logger.error({ error, credentials: this.credentials }, 'PST file validation failed.'); throw error; } } diff --git a/packages/backend/src/workers/indexing.worker.ts b/packages/backend/src/workers/indexing.worker.ts index 00708c75..5cc6923d 100644 --- a/packages/backend/src/workers/indexing.worker.ts +++ b/packages/backend/src/workers/indexing.worker.ts @@ -1,6 +1,7 @@ import { Worker } from 'bullmq'; import { connection } from '../config/redis'; import indexEmailBatchProcessor from '../jobs/processors/index-email-batch.processor'; +import { logger } from '../config/logger'; const processor = async (job: any) => { switch (job.name) { @@ -21,7 +22,7 @@ const worker = new Worker('indexing', processor, { }, }); -console.log('Indexing worker started'); +logger.info('Indexing worker started'); process.on('SIGINT', () => worker.close()); process.on('SIGTERM', () => worker.close()); diff --git a/packages/backend/src/workers/ingestion.worker.ts b/packages/backend/src/workers/ingestion.worker.ts index 8b9ff6a2..34fe580a 100644 --- a/packages/backend/src/workers/ingestion.worker.ts +++ b/packages/backend/src/workers/ingestion.worker.ts @@ -5,6 +5,7 @@ import continuousSyncProcessor from '../jobs/processors/continuous-sync.processo import scheduleContinuousSyncProcessor from '../jobs/processors/schedule-continuous-sync.processor'; import { processMailboxProcessor } from '../jobs/processors/process-mailbox.processor'; import syncCycleFinishedProcessor from '../jobs/processors/sync-cycle-finished.processor'; +import { logger } from '../config/logger'; const processor = async (job: any) => { switch (job.name) { @@ -25,6 +26,10 @@ const processor = async (job: any) => { const worker = new Worker('ingestion', processor, { connection, + // Configurable via INGESTION_WORKER_CONCURRENCY env var. Tune based on available RAM. + concurrency: process.env.INGESTION_WORKER_CONCURRENCY + ? parseInt(process.env.INGESTION_WORKER_CONCURRENCY, 10) + : 5, removeOnComplete: { count: 100, // keep last 100 jobs }, @@ -33,7 +38,7 @@ const worker = new Worker('ingestion', processor, { }, }); -console.log('Ingestion worker started'); +logger.info('Ingestion worker started'); process.on('SIGINT', () => worker.close()); process.on('SIGTERM', () => worker.close()); diff --git a/packages/frontend/src/lib/components/custom/EmailPreview.svelte b/packages/frontend/src/lib/components/custom/EmailPreview.svelte index 3effe6c1..b54a8905 100644 --- a/packages/frontend/src/lib/components/custom/EmailPreview.svelte +++ b/packages/frontend/src/lib/components/custom/EmailPreview.svelte @@ -1,5 +1,5 @@ @@ -259,15 +274,21 @@ {:else if formData.provider === 'pst_import'}
- +
- +
- +
@@ -305,15 +326,21 @@ {/if} {:else if formData.provider === 'eml_import'}
- +
- +
- +
@@ -351,15 +378,21 @@ {/if} {:else if formData.provider === 'mbox_import'}
- +
- +
- +
diff --git a/packages/frontend/src/lib/components/custom/RetentionPolicyForm.svelte b/packages/frontend/src/lib/components/custom/RetentionPolicyForm.svelte index 3a225d7e..d5de1936 100644 --- a/packages/frontend/src/lib/components/custom/RetentionPolicyForm.svelte +++ b/packages/frontend/src/lib/components/custom/RetentionPolicyForm.svelte @@ -47,18 +47,14 @@ let isEnabled = $state(policy?.isActive ?? true); // Conditions state - let logicalOperator = $state( - policy?.conditions?.logicalOperator ?? 'AND' - ); + let logicalOperator = $state(policy?.conditions?.logicalOperator ?? 'AND'); let rules = $state( policy?.conditions?.rules ? [...policy.conditions.rules] : [] ); // Ingestion scope: set of selected ingestion source IDs // Empty set = null scope = applies to all - let selectedIngestionIds = $state>( - new Set(policy?.ingestionScope ?? []) - ); + let selectedIngestionIds = $state>(new Set(policy?.ingestionScope ?? [])); // The conditions JSON that gets sent as a hidden form field const conditionsJson = $derived(JSON.stringify({ logicalOperator, rules })); @@ -202,11 +198,7 @@
- (isEnabled = v)} - /> + (isEnabled = v)} />
@@ -310,7 +302,8 @@ onValueChange={(v) => updateRuleOperator(i, v as ConditionOperator)} > - {operatorOptions.find((o) => o.value === rule.operator)?.label ?? rule.operator} + {operatorOptions.find((o) => o.value === rule.operator)?.label ?? + rule.operator} {#each operatorOptions as opt} diff --git a/packages/frontend/src/lib/translations/en.json b/packages/frontend/src/lib/translations/en.json index f11998eb..acaccb9d 100644 --- a/packages/frontend/src/lib/translations/en.json +++ b/packages/frontend/src/lib/translations/en.json @@ -35,6 +35,9 @@ "cancel": "Cancel", "not_found": "Email not found.", "integrity_report": "Integrity Report", + "download_integrity_report_pdf": "Download Integrity Report (PDF)", + "downloading_integrity_report": "Generating...", + "integrity_report_download_error": "Failed to generate the integrity report.", "email_eml": "Email (.eml)", "valid": "Valid", "invalid": "Invalid", @@ -229,7 +232,8 @@ "mbox_file": "Mbox File", "heads_up": "Heads up!", "org_wide_warning": "Please note that this is an organization-wide operation. This kind of ingestions will import and index all email inboxes in your organization. If you want to import only specific email inboxes, use the IMAP connector.", - "upload_failed": "Upload Failed, please try again" + "upload_failed": "Upload Failed, please try again", + "upload_network_error": "The server could not process the upload. The file may exceed the configured upload size limit (BODY_SIZE_LIMIT). For very large files, use the Local Path option instead." }, "role_form": { "policies_json": "Policies (JSON)", diff --git a/packages/frontend/src/lib/translations/index.ts b/packages/frontend/src/lib/translations/index.ts index 46b1bcc4..d7565eb2 100644 --- a/packages/frontend/src/lib/translations/index.ts +++ b/packages/frontend/src/lib/translations/index.ts @@ -12,7 +12,7 @@ import nl from './nl.json'; import ja from './ja.json'; import et from './et.json'; import el from './el.json'; -import bg from './bg.json' +import bg from './bg.json'; // This is your config object. // It defines the languages and how to load them. const config: Config = { diff --git a/packages/frontend/src/lib/translations/it.json b/packages/frontend/src/lib/translations/it.json index dc3a06ff..1ddd4aac 100644 --- a/packages/frontend/src/lib/translations/it.json +++ b/packages/frontend/src/lib/translations/it.json @@ -1,400 +1,400 @@ -{ - "app": { - "auth": { - "login": "Accedi", - "login_tip": "Inserisci la tua email qui sotto per accedere al tuo account.", - "email": "Email", - "password": "Password" - }, - "common": { - "working": "In corso", - "read_docs": "Leggi la documentazione" - }, - "archive": { - "title": "Archivio", - "no_subject": "Nessun oggetto", - "from": "Da", - "sent": "Inviato", - "recipients": "Destinatari", - "to": "A", - "meta_data": "Metadati", - "folder": "Cartella", - "tags": "Tag", - "size": "Dimensione", - "email_preview": "Anteprima email", - "attachments": "Allegati", - "download": "Scarica", - "actions": "Azioni", - "download_eml": "Scarica Email (.eml)", - "delete_email": "Elimina Email", - "email_thread": "Thread Email", - "delete_confirmation_title": "Sei sicuro di voler eliminare questa email?", - "delete_confirmation_description": "Questa azione non può essere annullata e rimuoverà definitivamente l'email e i suoi allegati.", - "deleting": "Eliminazione in corso", - "confirm": "Conferma", - "cancel": "Annulla", - "not_found": "Email non trovata.", - "integrity_report": "Rapporto di integrità", - "email_eml": "Email (.eml)", - "valid": "Valido", - "invalid": "Non valido", - "integrity_check_failed_title": "Controllo di integrità non riuscito", - "integrity_check_failed_message": "Impossibile verificare l'integrità dell'email e dei suoi allegati.", - "integrity_report_description": "Questo rapporto verifica che il contenuto delle tue email archiviate non sia stato alterato." - }, - "ingestions": { - "title": "Fonti di acquisizione", - "ingestion_sources": "Fonti di acquisizione", - "bulk_actions": "Azioni di massa", - "force_sync": "Forza sincronizzazione", - "delete": "Elimina", - "create_new": "Crea nuovo", - "name": "Nome", - "provider": "Provider", - "status": "Stato", - "active": "Attivo", - "created_at": "Creato il", - "actions": "Azioni", - "last_sync_message": "Ultimo messaggio di sincronizzazione", - "empty": "Vuoto", - "open_menu": "Apri menu", - "edit": "Modifica", - "create": "Crea", - "ingestion_source": "Fonte di acquisizione", - "edit_description": "Apporta modifiche alla tua fonte di acquisizione qui.", - "create_description": "Aggiungi una nuova fonte di acquisizione per iniziare ad archiviare le email.", - "read": "Leggi", - "docs_here": "documentazione qui", - "delete_confirmation_title": "Sei sicuro di voler eliminare questa acquisizione?", - "delete_confirmation_description": "Questo cancellerà tutte le email archiviate, gli allegati, l'indicizzazione e i file associati a questa acquisizione. Se vuoi solo interrompere la sincronizzazione di nuove email, puoi invece mettere in pausa l'acquisizione.", - "deleting": "Eliminazione in corso", - "confirm": "Conferma", - "cancel": "Annulla", - "bulk_delete_confirmation_title": "Sei sicuro di voler eliminare {{count}} acquisizioni selezionate?", - "bulk_delete_confirmation_description": "Questo cancellerà tutte le email archiviate, gli allegati, l'indicizzazione e i file associati a queste acquisizioni. Se vuoi solo interrompere la sincronizzazione di nuove email, puoi invece mettere in pausa le acquisizioni." - }, - "search": { - "title": "Cerca", - "description": "Cerca email archiviate.", - "email_search": "Ricerca email", - "placeholder": "Cerca per parola chiave, mittente, destinatario...", - "search_button": "Cerca", - "search_options": "Opzioni di ricerca", - "strategy_fuzzy": "Approssimativa", - "strategy_verbatim": "Testuale", - "strategy_frequency": "Frequenza", - "select_strategy": "Seleziona una strategia", - "error": "Errore", - "found_results_in": "Trovati {{total}} risultati in {{seconds}}s", - "found_results": "Trovati {{total}} risultati", - "from": "Da", - "to": "A", - "in_email_body": "Nel corpo dell'email", - "in_attachment": "Nell'allegato: {{filename}}", - "prev": "Prec", - "next": "Succ" - }, - "roles": { - "title": "Gestione ruoli", - "role_management": "Gestione ruoli", - "create_new": "Crea nuovo", - "name": "Nome", - "created_at": "Creato il", - "actions": "Azioni", - "open_menu": "Apri menu", - "view_policy": "Visualizza Policy", - "edit": "Modifica", - "delete": "Elimina", - "no_roles_found": "Nessun ruolo trovato.", - "role_policy": "Policy del ruolo", - "viewing_policy_for_role": "Visualizzazione della policy per il ruolo: {{name}}", - "create": "Crea", - "role": "Ruolo", - "edit_description": "Apporta modifiche al ruolo qui.", - "create_description": "Aggiungi un nuovo ruolo al sistema.", - "delete_confirmation_title": "Sei sicuro di voler eliminare questo ruolo?", - "delete_confirmation_description": "Questa azione non può essere annullata. Questo cancellerà definitivamente il ruolo.", - "deleting": "Eliminazione in corso", - "confirm": "Conferma", - "cancel": "Annulla" - }, - "account": { - "title": "Impostazioni account", - "description": "Gestisci il tuo profilo e le impostazioni di sicurezza.", - "personal_info": "Informazioni personali", - "personal_info_desc": "Aggiorna i tuoi dati personali.", - "security": "Sicurezza", - "security_desc": "Gestisci la tua password e le preferenze di sicurezza.", - "edit_profile": "Modifica profilo", - "change_password": "Cambia password", - "edit_profile_desc": "Apporta modifiche al tuo profilo qui.", - "change_password_desc": "Cambia la tua password. Dovrai inserire la tua password attuale.", - "current_password": "Password attuale", - "new_password": "Nuova password", - "confirm_new_password": "Conferma nuova password", - "operation_successful": "Operazione riuscita", - "passwords_do_not_match": "Le password non corrispondono" - }, - "system_settings": { - "title": "Impostazioni di sistema", - "system_settings": "Impostazioni di sistema", - "description": "Gestisci le impostazioni globali dell'applicazione.", - "language": "Lingua", - "default_theme": "Tema predefinito", - "light": "Chiaro", - "dark": "Scuro", - "system": "Sistema", - "support_email": "Email di supporto", - "saving": "Salvataggio in corso", - "save_changes": "Salva modifiche" - }, - "users": { - "title": "Gestione utenti", - "user_management": "Gestione utenti", - "create_new": "Crea nuovo", - "name": "Nome", - "email": "Email", - "role": "Ruolo", - "created_at": "Creato il", - "actions": "Azioni", - "open_menu": "Apri menu", - "edit": "Modifica", - "delete": "Elimina", - "no_users_found": "Nessun utente trovato.", - "create": "Crea", - "user": "Utente", - "edit_description": "Apporta modifiche all'utente qui.", - "create_description": "Aggiungi un nuovo utente al sistema.", - "delete_confirmation_title": "Sei sicuro di voler eliminare questo utente?", - "delete_confirmation_description": "Questa azione non può essere annullata. Questo cancellerà definitivamente l'utente e rimuoverà i suoi dati dai nostri server.", - "deleting": "Eliminazione in corso", - "confirm": "Conferma", - "cancel": "Annulla" - }, - "components": { - "charts": { - "emails_ingested": "Email acquisite", - "storage_used": "Spazio di archiviazione utilizzato", - "emails": "Email" - }, - "common": { - "submitting": "Invio in corso...", - "submit": "Invia", - "save": "Salva" - }, - "email_preview": { - "loading": "Caricamento anteprima email...", - "render_error": "Impossibile visualizzare l'anteprima dell'email.", - "not_available": "File .eml grezzo non disponibile per questa email." - }, - "footer": { - "all_rights_reserved": "Tutti i diritti riservati.", - "new_version_available": "Nuova versione disponibile" - }, - "ingestion_source_form": { - "provider_generic_imap": "IMAP generico", - "provider_google_workspace": "Google Workspace", - "provider_microsoft_365": "Microsoft 365", - "provider_pst_import": "Importazione PST", - "provider_eml_import": "Importazione EML", - "provider_mbox_import": "Importazione Mbox", - "select_provider": "Seleziona un provider", - "service_account_key": "Chiave dell'account di servizio (JSON)", - "service_account_key_placeholder": "Incolla il contenuto JSON della chiave del tuo account di servizio", - "impersonated_admin_email": "Email dell'amministratore impersonato", - "client_id": "ID applicazione (client)", - "client_secret": "Valore del segreto client", - "client_secret_placeholder": "Inserisci il valore segreto, non l'ID segreto", - "tenant_id": "ID directory (tenant)", - "host": "Host", - "port": "Porta", - "username": "Nome utente", - "use_tls": "Usa TLS", - "allow_insecure_cert": "Consenti certificato non sicuro", - "pst_file": "File PST", - "eml_file": "File EML", - "mbox_file": "File Mbox", - "heads_up": "Attenzione!", - "org_wide_warning": "Tieni presente che questa è un'operazione a livello di organizzazione. Questo tipo di acquisizione importerà e indicizzerà tutte le caselle di posta nella tua organizzazione. Se vuoi importare solo caselle di posta specifiche, usa il connettore IMAP.", - "upload_failed": "Caricamento non riuscito, riprova" - }, - "role_form": { - "policies_json": "Policy (JSON)", - "invalid_json": "Formato JSON non valido per le policy." - }, - "theme_switcher": { - "toggle_theme": "Attiva/disattiva tema" - }, - "user_form": { - "select_role": "Seleziona un ruolo" - } - }, - "setup": { - "title": "Configurazione", - "description": "Configura l'account amministratore iniziale per Open Archiver.", - "welcome": "Benvenuto", - "create_admin_account": "Crea il primo account amministratore per iniziare.", - "first_name": "Nome", - "last_name": "Cognome", - "email": "Email", - "password": "Password", - "creating_account": "Creazione account", - "create_account": "Crea account" - }, - "layout": { - "dashboard": "Dashboard", - "ingestions": "Acquisizioni", - "archived_emails": "Email archiviate", - "search": "Cerca", - "settings": "Impostazioni", - "system": "Sistema", - "users": "Utenti", - "roles": "Ruoli", - "api_keys": "Chiavi API", - "account": "Account", - "logout": "Disconnetti", - "admin": "Amministratore" - }, - "api_keys_page": { - "title": "Chiavi API", - "header": "Chiavi API", - "generate_new_key": "Genera nuova chiave", - "name": "Nome", - "key": "Chiave", - "expires_at": "Scade il", - "created_at": "Creato il", - "actions": "Azioni", - "delete": "Elimina", - "no_keys_found": "Nessuna chiave API trovata.", - "generate_modal_title": "Genera nuova chiave API", - "generate_modal_description": "Fornisci un nome e una scadenza per la tua nuova chiave API.", - "expires_in": "Scade tra", - "select_expiration": "Seleziona una scadenza", - "30_days": "30 giorni", - "60_days": "60 giorni", - "6_months": "6 mesi", - "12_months": "12 mesi", - "24_months": "24 mesi", - "generate": "Genera", - "new_api_key": "Nuova chiave API", - "failed_to_delete": "Impossibile eliminare la chiave API", - "api_key_deleted": "Chiave API eliminata", - "generated_title": "Chiave API generata", - "generated_message": "La tua chiave API è stata generata, copiala e salvala in un luogo sicuro. Questa chiave verrà mostrata solo una volta." - }, - "archived_emails_page": { - "title": "Email archiviate", - "header": "Email archiviate", - "select_ingestion_source": "Seleziona una fonte di acquisizione", - "date": "Data", - "subject": "Oggetto", - "sender": "Mittente", - "inbox": "Posta in arrivo", - "path": "Percorso", - "actions": "Azioni", - "view": "Visualizza", - "no_emails_found": "Nessuna email archiviata trovata.", - "prev": "Prec", - "next": "Succ" - }, - "dashboard_page": { - "title": "Dashboard", - "meta_description": "Panoramica del tuo archivio email.", - "header": "Dashboard", - "create_ingestion": "Crea un'acquisizione", - "no_ingestion_header": "Non hai configurato alcuna fonte di acquisizione.", - "no_ingestion_text": "Aggiungi una fonte di acquisizione per iniziare ad archiviare le tue caselle di posta.", - "total_emails_archived": "Email totali archiviate", - "total_storage_used": "Spazio di archiviazione totale utilizzato", - "failed_ingestions": "Acquisizioni non riuscite (ultimi 7 giorni)", - "ingestion_history": "Cronologia acquisizioni", - "no_ingestion_history": "Nessuna cronologia acquisizioni disponibile.", - "storage_by_source": "Spazio di archiviazione per fonte di acquisizione", - "no_ingestion_sources": "Nessuna fonte di acquisizione disponibile.", - "indexed_insights": "Informazioni indicizzate", - "top_10_senders": "I 10 mittenti principali", - "no_indexed_insights": "Nessuna informazione indicizzata disponibile." - }, - "audit_log": { - "title": "Registro di audit", - "header": "Registro di audit", - "verify_integrity": "Verifica l'integrità del registro", - "log_entries": "Voci di registro", - "timestamp": "Timestamp", - "actor": "Attore", - "action": "Azione", - "target": "Obiettivo", - "details": "Dettagli", - "ip_address": "Indirizzo IP", - "target_type": "Tipo di obiettivo", - "target_id": "ID obiettivo", - "no_logs_found": "Nessun registro di audit trovato.", - "prev": "Prec", - "next": "Succ", - "log_entry_details": "Dettagli della voce di registro", - "viewing_details_for": "Visualizzazione dei dettagli completi per la voce di registro #", - "actor_id": "ID attore", - "previous_hash": "Hash precedente", - "current_hash": "Hash corrente", - "close": "Chiudi", - "verification_successful_title": "Verifica riuscita", - "verification_successful_message": "Integrità del registro di audit verificata con successo.", - "verification_failed_title": "Verifica non riuscita", - "verification_failed_message": "Il controllo di integrità del registro di audit non è riuscito. Controlla i registri di sistema per maggiori dettagli.", - "verification_error_message": "Si è verificato un errore inatteso durante la verifica. Riprova." - }, - "jobs": { - "title": "Code dei lavori", - "queues": "Code dei lavori", - "active": "Attivo", - "completed": "Completato", - "failed": "Fallito", - "delayed": "Ritardato", - "waiting": "In attesa", - "paused": "In pausa", - "back_to_queues": "Torna alle code", - "queue_overview": "Panoramica della coda", - "jobs": "Lavori", - "id": "ID", - "name": "Nome", - "state": "Stato", - - "created_at": "Creato il", - "processed_at": "Elaborato il", - "finished_at": "Terminato il", - "showing": "Visualizzazione di", - "of": "di", - "previous": "Precedente", - "next": "Successivo", - "ingestion_source": "Fonte di acquisizione" - }, - "license_page": { - "title": "Stato della licenza Enterprise", - "meta_description": "Visualizza lo stato attuale della tua licenza Open Archiver Enterprise.", - "revoked_title": "Licenza revocata", - "revoked_message": "La tua licenza è stata revocata dall'amministratore della licenza. Le funzionalità Enterprise verranno disabilitate {{grace_period}}. Contatta il tuo account manager per assistenza.", - "revoked_grace_period": "il {{date}}", - "revoked_immediately": "immediatamente", - "seat_limit_exceeded_title": "Limite di posti superato", - "seat_limit_exceeded_message": "La tua licenza è per {{planSeats}} utenti, ma ne stai attualmente utilizzando {{activeSeats}}. Contatta il reparto vendite per modificare il tuo abbonamento.", - "customer": "Cliente", - "license_details": "Dettagli licenza", - "license_status": "Stato licenza", - "active": "Attivo", - "expired": "Scaduto", - "revoked": "Revocato", - "unknown": "Sconosciuto", - "expires": "Scade", - "seat_usage": "Utilizzo posti", - "seats_used": "{{activeSeats}} di {{planSeats}} posti utilizzati", - "enabled_features": "Funzionalità abilitate", - "enabled_features_description": "Le seguenti funzionalità enterprise sono attualmente abilitate.", - "feature": "Funzionalità", - "status": "Stato", - "enabled": "Abilitato", - "disabled": "Disabilitato", - "could_not_load_title": "Impossibile caricare la licenza", - "could_not_load_message": "Si è verificato un errore inatteso." - } - } -} +{ + "app": { + "auth": { + "login": "Accedi", + "login_tip": "Inserisci la tua email qui sotto per accedere al tuo account.", + "email": "Email", + "password": "Password" + }, + "common": { + "working": "In corso", + "read_docs": "Leggi la documentazione" + }, + "archive": { + "title": "Archivio", + "no_subject": "Nessun oggetto", + "from": "Da", + "sent": "Inviato", + "recipients": "Destinatari", + "to": "A", + "meta_data": "Metadati", + "folder": "Cartella", + "tags": "Tag", + "size": "Dimensione", + "email_preview": "Anteprima email", + "attachments": "Allegati", + "download": "Scarica", + "actions": "Azioni", + "download_eml": "Scarica Email (.eml)", + "delete_email": "Elimina Email", + "email_thread": "Thread Email", + "delete_confirmation_title": "Sei sicuro di voler eliminare questa email?", + "delete_confirmation_description": "Questa azione non può essere annullata e rimuoverà definitivamente l'email e i suoi allegati.", + "deleting": "Eliminazione in corso", + "confirm": "Conferma", + "cancel": "Annulla", + "not_found": "Email non trovata.", + "integrity_report": "Rapporto di integrità", + "email_eml": "Email (.eml)", + "valid": "Valido", + "invalid": "Non valido", + "integrity_check_failed_title": "Controllo di integrità non riuscito", + "integrity_check_failed_message": "Impossibile verificare l'integrità dell'email e dei suoi allegati.", + "integrity_report_description": "Questo rapporto verifica che il contenuto delle tue email archiviate non sia stato alterato." + }, + "ingestions": { + "title": "Fonti di acquisizione", + "ingestion_sources": "Fonti di acquisizione", + "bulk_actions": "Azioni di massa", + "force_sync": "Forza sincronizzazione", + "delete": "Elimina", + "create_new": "Crea nuovo", + "name": "Nome", + "provider": "Provider", + "status": "Stato", + "active": "Attivo", + "created_at": "Creato il", + "actions": "Azioni", + "last_sync_message": "Ultimo messaggio di sincronizzazione", + "empty": "Vuoto", + "open_menu": "Apri menu", + "edit": "Modifica", + "create": "Crea", + "ingestion_source": "Fonte di acquisizione", + "edit_description": "Apporta modifiche alla tua fonte di acquisizione qui.", + "create_description": "Aggiungi una nuova fonte di acquisizione per iniziare ad archiviare le email.", + "read": "Leggi", + "docs_here": "documentazione qui", + "delete_confirmation_title": "Sei sicuro di voler eliminare questa acquisizione?", + "delete_confirmation_description": "Questo cancellerà tutte le email archiviate, gli allegati, l'indicizzazione e i file associati a questa acquisizione. Se vuoi solo interrompere la sincronizzazione di nuove email, puoi invece mettere in pausa l'acquisizione.", + "deleting": "Eliminazione in corso", + "confirm": "Conferma", + "cancel": "Annulla", + "bulk_delete_confirmation_title": "Sei sicuro di voler eliminare {{count}} acquisizioni selezionate?", + "bulk_delete_confirmation_description": "Questo cancellerà tutte le email archiviate, gli allegati, l'indicizzazione e i file associati a queste acquisizioni. Se vuoi solo interrompere la sincronizzazione di nuove email, puoi invece mettere in pausa le acquisizioni." + }, + "search": { + "title": "Cerca", + "description": "Cerca email archiviate.", + "email_search": "Ricerca email", + "placeholder": "Cerca per parola chiave, mittente, destinatario...", + "search_button": "Cerca", + "search_options": "Opzioni di ricerca", + "strategy_fuzzy": "Approssimativa", + "strategy_verbatim": "Testuale", + "strategy_frequency": "Frequenza", + "select_strategy": "Seleziona una strategia", + "error": "Errore", + "found_results_in": "Trovati {{total}} risultati in {{seconds}}s", + "found_results": "Trovati {{total}} risultati", + "from": "Da", + "to": "A", + "in_email_body": "Nel corpo dell'email", + "in_attachment": "Nell'allegato: {{filename}}", + "prev": "Prec", + "next": "Succ" + }, + "roles": { + "title": "Gestione ruoli", + "role_management": "Gestione ruoli", + "create_new": "Crea nuovo", + "name": "Nome", + "created_at": "Creato il", + "actions": "Azioni", + "open_menu": "Apri menu", + "view_policy": "Visualizza Policy", + "edit": "Modifica", + "delete": "Elimina", + "no_roles_found": "Nessun ruolo trovato.", + "role_policy": "Policy del ruolo", + "viewing_policy_for_role": "Visualizzazione della policy per il ruolo: {{name}}", + "create": "Crea", + "role": "Ruolo", + "edit_description": "Apporta modifiche al ruolo qui.", + "create_description": "Aggiungi un nuovo ruolo al sistema.", + "delete_confirmation_title": "Sei sicuro di voler eliminare questo ruolo?", + "delete_confirmation_description": "Questa azione non può essere annullata. Questo cancellerà definitivamente il ruolo.", + "deleting": "Eliminazione in corso", + "confirm": "Conferma", + "cancel": "Annulla" + }, + "account": { + "title": "Impostazioni account", + "description": "Gestisci il tuo profilo e le impostazioni di sicurezza.", + "personal_info": "Informazioni personali", + "personal_info_desc": "Aggiorna i tuoi dati personali.", + "security": "Sicurezza", + "security_desc": "Gestisci la tua password e le preferenze di sicurezza.", + "edit_profile": "Modifica profilo", + "change_password": "Cambia password", + "edit_profile_desc": "Apporta modifiche al tuo profilo qui.", + "change_password_desc": "Cambia la tua password. Dovrai inserire la tua password attuale.", + "current_password": "Password attuale", + "new_password": "Nuova password", + "confirm_new_password": "Conferma nuova password", + "operation_successful": "Operazione riuscita", + "passwords_do_not_match": "Le password non corrispondono" + }, + "system_settings": { + "title": "Impostazioni di sistema", + "system_settings": "Impostazioni di sistema", + "description": "Gestisci le impostazioni globali dell'applicazione.", + "language": "Lingua", + "default_theme": "Tema predefinito", + "light": "Chiaro", + "dark": "Scuro", + "system": "Sistema", + "support_email": "Email di supporto", + "saving": "Salvataggio in corso", + "save_changes": "Salva modifiche" + }, + "users": { + "title": "Gestione utenti", + "user_management": "Gestione utenti", + "create_new": "Crea nuovo", + "name": "Nome", + "email": "Email", + "role": "Ruolo", + "created_at": "Creato il", + "actions": "Azioni", + "open_menu": "Apri menu", + "edit": "Modifica", + "delete": "Elimina", + "no_users_found": "Nessun utente trovato.", + "create": "Crea", + "user": "Utente", + "edit_description": "Apporta modifiche all'utente qui.", + "create_description": "Aggiungi un nuovo utente al sistema.", + "delete_confirmation_title": "Sei sicuro di voler eliminare questo utente?", + "delete_confirmation_description": "Questa azione non può essere annullata. Questo cancellerà definitivamente l'utente e rimuoverà i suoi dati dai nostri server.", + "deleting": "Eliminazione in corso", + "confirm": "Conferma", + "cancel": "Annulla" + }, + "components": { + "charts": { + "emails_ingested": "Email acquisite", + "storage_used": "Spazio di archiviazione utilizzato", + "emails": "Email" + }, + "common": { + "submitting": "Invio in corso...", + "submit": "Invia", + "save": "Salva" + }, + "email_preview": { + "loading": "Caricamento anteprima email...", + "render_error": "Impossibile visualizzare l'anteprima dell'email.", + "not_available": "File .eml grezzo non disponibile per questa email." + }, + "footer": { + "all_rights_reserved": "Tutti i diritti riservati.", + "new_version_available": "Nuova versione disponibile" + }, + "ingestion_source_form": { + "provider_generic_imap": "IMAP generico", + "provider_google_workspace": "Google Workspace", + "provider_microsoft_365": "Microsoft 365", + "provider_pst_import": "Importazione PST", + "provider_eml_import": "Importazione EML", + "provider_mbox_import": "Importazione Mbox", + "select_provider": "Seleziona un provider", + "service_account_key": "Chiave dell'account di servizio (JSON)", + "service_account_key_placeholder": "Incolla il contenuto JSON della chiave del tuo account di servizio", + "impersonated_admin_email": "Email dell'amministratore impersonato", + "client_id": "ID applicazione (client)", + "client_secret": "Valore del segreto client", + "client_secret_placeholder": "Inserisci il valore segreto, non l'ID segreto", + "tenant_id": "ID directory (tenant)", + "host": "Host", + "port": "Porta", + "username": "Nome utente", + "use_tls": "Usa TLS", + "allow_insecure_cert": "Consenti certificato non sicuro", + "pst_file": "File PST", + "eml_file": "File EML", + "mbox_file": "File Mbox", + "heads_up": "Attenzione!", + "org_wide_warning": "Tieni presente che questa è un'operazione a livello di organizzazione. Questo tipo di acquisizione importerà e indicizzerà tutte le caselle di posta nella tua organizzazione. Se vuoi importare solo caselle di posta specifiche, usa il connettore IMAP.", + "upload_failed": "Caricamento non riuscito, riprova" + }, + "role_form": { + "policies_json": "Policy (JSON)", + "invalid_json": "Formato JSON non valido per le policy." + }, + "theme_switcher": { + "toggle_theme": "Attiva/disattiva tema" + }, + "user_form": { + "select_role": "Seleziona un ruolo" + } + }, + "setup": { + "title": "Configurazione", + "description": "Configura l'account amministratore iniziale per Open Archiver.", + "welcome": "Benvenuto", + "create_admin_account": "Crea il primo account amministratore per iniziare.", + "first_name": "Nome", + "last_name": "Cognome", + "email": "Email", + "password": "Password", + "creating_account": "Creazione account", + "create_account": "Crea account" + }, + "layout": { + "dashboard": "Dashboard", + "ingestions": "Acquisizioni", + "archived_emails": "Email archiviate", + "search": "Cerca", + "settings": "Impostazioni", + "system": "Sistema", + "users": "Utenti", + "roles": "Ruoli", + "api_keys": "Chiavi API", + "account": "Account", + "logout": "Disconnetti", + "admin": "Amministratore" + }, + "api_keys_page": { + "title": "Chiavi API", + "header": "Chiavi API", + "generate_new_key": "Genera nuova chiave", + "name": "Nome", + "key": "Chiave", + "expires_at": "Scade il", + "created_at": "Creato il", + "actions": "Azioni", + "delete": "Elimina", + "no_keys_found": "Nessuna chiave API trovata.", + "generate_modal_title": "Genera nuova chiave API", + "generate_modal_description": "Fornisci un nome e una scadenza per la tua nuova chiave API.", + "expires_in": "Scade tra", + "select_expiration": "Seleziona una scadenza", + "30_days": "30 giorni", + "60_days": "60 giorni", + "6_months": "6 mesi", + "12_months": "12 mesi", + "24_months": "24 mesi", + "generate": "Genera", + "new_api_key": "Nuova chiave API", + "failed_to_delete": "Impossibile eliminare la chiave API", + "api_key_deleted": "Chiave API eliminata", + "generated_title": "Chiave API generata", + "generated_message": "La tua chiave API è stata generata, copiala e salvala in un luogo sicuro. Questa chiave verrà mostrata solo una volta." + }, + "archived_emails_page": { + "title": "Email archiviate", + "header": "Email archiviate", + "select_ingestion_source": "Seleziona una fonte di acquisizione", + "date": "Data", + "subject": "Oggetto", + "sender": "Mittente", + "inbox": "Posta in arrivo", + "path": "Percorso", + "actions": "Azioni", + "view": "Visualizza", + "no_emails_found": "Nessuna email archiviata trovata.", + "prev": "Prec", + "next": "Succ" + }, + "dashboard_page": { + "title": "Dashboard", + "meta_description": "Panoramica del tuo archivio email.", + "header": "Dashboard", + "create_ingestion": "Crea un'acquisizione", + "no_ingestion_header": "Non hai configurato alcuna fonte di acquisizione.", + "no_ingestion_text": "Aggiungi una fonte di acquisizione per iniziare ad archiviare le tue caselle di posta.", + "total_emails_archived": "Email totali archiviate", + "total_storage_used": "Spazio di archiviazione totale utilizzato", + "failed_ingestions": "Acquisizioni non riuscite (ultimi 7 giorni)", + "ingestion_history": "Cronologia acquisizioni", + "no_ingestion_history": "Nessuna cronologia acquisizioni disponibile.", + "storage_by_source": "Spazio di archiviazione per fonte di acquisizione", + "no_ingestion_sources": "Nessuna fonte di acquisizione disponibile.", + "indexed_insights": "Informazioni indicizzate", + "top_10_senders": "I 10 mittenti principali", + "no_indexed_insights": "Nessuna informazione indicizzata disponibile." + }, + "audit_log": { + "title": "Registro di audit", + "header": "Registro di audit", + "verify_integrity": "Verifica l'integrità del registro", + "log_entries": "Voci di registro", + "timestamp": "Timestamp", + "actor": "Attore", + "action": "Azione", + "target": "Obiettivo", + "details": "Dettagli", + "ip_address": "Indirizzo IP", + "target_type": "Tipo di obiettivo", + "target_id": "ID obiettivo", + "no_logs_found": "Nessun registro di audit trovato.", + "prev": "Prec", + "next": "Succ", + "log_entry_details": "Dettagli della voce di registro", + "viewing_details_for": "Visualizzazione dei dettagli completi per la voce di registro #", + "actor_id": "ID attore", + "previous_hash": "Hash precedente", + "current_hash": "Hash corrente", + "close": "Chiudi", + "verification_successful_title": "Verifica riuscita", + "verification_successful_message": "Integrità del registro di audit verificata con successo.", + "verification_failed_title": "Verifica non riuscita", + "verification_failed_message": "Il controllo di integrità del registro di audit non è riuscito. Controlla i registri di sistema per maggiori dettagli.", + "verification_error_message": "Si è verificato un errore inatteso durante la verifica. Riprova." + }, + "jobs": { + "title": "Code dei lavori", + "queues": "Code dei lavori", + "active": "Attivo", + "completed": "Completato", + "failed": "Fallito", + "delayed": "Ritardato", + "waiting": "In attesa", + "paused": "In pausa", + "back_to_queues": "Torna alle code", + "queue_overview": "Panoramica della coda", + "jobs": "Lavori", + "id": "ID", + "name": "Nome", + "state": "Stato", + + "created_at": "Creato il", + "processed_at": "Elaborato il", + "finished_at": "Terminato il", + "showing": "Visualizzazione di", + "of": "di", + "previous": "Precedente", + "next": "Successivo", + "ingestion_source": "Fonte di acquisizione" + }, + "license_page": { + "title": "Stato della licenza Enterprise", + "meta_description": "Visualizza lo stato attuale della tua licenza Open Archiver Enterprise.", + "revoked_title": "Licenza revocata", + "revoked_message": "La tua licenza è stata revocata dall'amministratore della licenza. Le funzionalità Enterprise verranno disabilitate {{grace_period}}. Contatta il tuo account manager per assistenza.", + "revoked_grace_period": "il {{date}}", + "revoked_immediately": "immediatamente", + "seat_limit_exceeded_title": "Limite di posti superato", + "seat_limit_exceeded_message": "La tua licenza è per {{planSeats}} utenti, ma ne stai attualmente utilizzando {{activeSeats}}. Contatta il reparto vendite per modificare il tuo abbonamento.", + "customer": "Cliente", + "license_details": "Dettagli licenza", + "license_status": "Stato licenza", + "active": "Attivo", + "expired": "Scaduto", + "revoked": "Revocato", + "unknown": "Sconosciuto", + "expires": "Scade", + "seat_usage": "Utilizzo posti", + "seats_used": "{{activeSeats}} di {{planSeats}} posti utilizzati", + "enabled_features": "Funzionalità abilitate", + "enabled_features_description": "Le seguenti funzionalità enterprise sono attualmente abilitate.", + "feature": "Funzionalità", + "status": "Stato", + "enabled": "Abilitato", + "disabled": "Disabilitato", + "could_not_load_title": "Impossibile caricare la licenza", + "could_not_load_message": "Si è verificato un errore inatteso." + } + } +} diff --git a/packages/frontend/src/routes/+layout.ts b/packages/frontend/src/routes/+layout.ts index 26180869..0ab70677 100644 --- a/packages/frontend/src/routes/+layout.ts +++ b/packages/frontend/src/routes/+layout.ts @@ -11,8 +11,6 @@ export const load: LayoutLoad = async ({ url, data }) => { if (data && data.systemSettings?.language) { initLocale = data.systemSettings.language; } - - console.log(initLocale); await loadTranslations(initLocale, pathname); return { diff --git a/packages/frontend/src/routes/api/[...slug]/+server.ts b/packages/frontend/src/routes/api/[...slug]/+server.ts index e8ace6e5..4072c859 100644 --- a/packages/frontend/src/routes/api/[...slug]/+server.ts +++ b/packages/frontend/src/routes/api/[...slug]/+server.ts @@ -30,11 +30,23 @@ const handleRequest: RequestHandler = async ({ request, params, fetch }) => { const response = await fetch(proxyRequest); return response; - } catch (error) { + } catch (error: any) { console.error('Proxy request failed:', error); + + // Handle SvelteKit HttpError (e.g. from request.arrayBuffer() exceeding BODY_SIZE_LIMIT) + // Or other types of errors, formatting them into the standard ApiErrorResponse + const statusCode = error?.status || 500; + const message = + error?.body?.message || error?.message || 'Failed to connect to the backend service.'; + return json( - { message: `Failed to connect to the backend service. ${JSON.stringify(error)}` }, - { status: 500 } + { + status: 'error', + statusCode: statusCode, + message: message, + errors: null, + }, + { status: statusCode } ); } }; diff --git a/packages/frontend/src/routes/dashboard/+layout.svelte b/packages/frontend/src/routes/dashboard/+layout.svelte index 4beed2e8..3cab2e6b 100644 --- a/packages/frontend/src/routes/dashboard/+layout.svelte +++ b/packages/frontend/src/routes/dashboard/+layout.svelte @@ -146,7 +146,7 @@ OpenArchiver Logo {#if data.enterpriseMode} - Enterprise + Enterprise {/if} diff --git a/packages/frontend/src/routes/dashboard/archived-emails/[id]/+page.server.ts b/packages/frontend/src/routes/dashboard/archived-emails/[id]/+page.server.ts index 06f2d709..6a64489d 100644 --- a/packages/frontend/src/routes/dashboard/archived-emails/[id]/+page.server.ts +++ b/packages/frontend/src/routes/dashboard/archived-emails/[id]/+page.server.ts @@ -120,7 +120,10 @@ export const actions: Actions = { if (!response.ok) { const res = await response.json().catch(() => ({})); - return { success: false, message: (res as { message?: string }).message || 'Failed to apply label' }; + return { + success: false, + message: (res as { message?: string }).message || 'Failed to apply label', + }; } return { success: true, action: 'applied' }; @@ -135,7 +138,10 @@ export const actions: Actions = { if (!response.ok) { const res = await response.json().catch(() => ({})); - return { success: false, message: (res as { message?: string }).message || 'Failed to remove label' }; + return { + success: false, + message: (res as { message?: string }).message || 'Failed to remove label', + }; } return { success: true, action: 'removed' }; diff --git a/packages/frontend/src/routes/dashboard/archived-emails/[id]/+page.svelte b/packages/frontend/src/routes/dashboard/archived-emails/[id]/+page.svelte index 42ff6906..2c5d37c7 100644 --- a/packages/frontend/src/routes/dashboard/archived-emails/[id]/+page.svelte +++ b/packages/frontend/src/routes/dashboard/archived-emails/[id]/+page.svelte @@ -16,7 +16,16 @@ import * as Alert from '$lib/components/ui/alert'; import { Badge } from '$lib/components/ui/badge'; import * as HoverCard from '$lib/components/ui/hover-card'; - import { Clock, Trash2, CalendarClock, AlertCircle, Shield, CircleAlert, Tag } from 'lucide-svelte'; + import { + Clock, + Trash2, + CalendarClock, + AlertCircle, + Shield, + CircleAlert, + Tag, + FileDown, + } from 'lucide-svelte'; import { page } from '$app/state'; import { enhance } from '$app/forms'; import type { LegalHold, EmailLegalHoldInfo } from '@open-archiver/types'; @@ -65,6 +74,9 @@ let isApplyingHold = $state(false); let isRemovingHoldId = $state(null); + // --- Integrity report PDF download state (enterprise only) --- + let isDownloadingReport = $state(false); + // React to form results for label and hold actions $effect(() => { if (form) { @@ -143,6 +155,41 @@ } } + /** Downloads the enterprise integrity verification PDF report. */ + async function downloadIntegrityReportPdf() { + if (!browser || !email) return; + + try { + isDownloadingReport = true; + const response = await api(`/enterprise/integrity-report/${email.id}/pdf`); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `integrity-report-${email.id}.pdf`; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + a.remove(); + } catch (error) { + console.error('Integrity report download failed:', error); + setAlert({ + type: 'error', + title: $t('app.archive.integrity_report_download_error'), + message: '', + duration: 5000, + show: true, + }); + } finally { + isDownloadingReport = false; + } + } + async function confirmDelete() { if (!email) return; try { @@ -193,21 +240,19 @@

{$t('app.archive.recipients')}

- -

- {$t('app.archive.to')}: {email.recipients - .map((r) => r.email || r.name) - .join(', ')} -

-
+

+ {$t('app.archive.to')}: {email.recipients + .map((r) => r.email || r.name) + .join(', ')} +

-
+

{$t('app.archive.meta_data')}

- +
{#if email.path}
{$t('app.archive.folder')}: - {email.path || '/'}
@@ -216,7 +261,7 @@
{$t('app.archive.tags')}: {#each email.tags as tag} - {tag} {/each} @@ -224,11 +269,11 @@ {/if}
{$t('app.archive.size')}: - {formatBytes(email.sizeBytes)}
- +

{$t('app.archive.email_preview')}

@@ -282,12 +327,16 @@ download(email.storagePath, `${email.subject || 'email'}.eml`)} >{$t('app.archive.download_eml')} - - + {#if integrityReport && integrityReport.length > 0} @@ -347,6 +396,22 @@ {/each} + {#if enterpriseMode} + + {/if} {:else} @@ -358,6 +423,17 @@ {/if} + + {#if email.thread && email.thread.length > 1} + + + {$t('app.archive.email_thread')} + + + + + + {/if} {#if enterpriseMode} @@ -382,14 +458,18 @@ {#if emailLegalHolds && emailLegalHolds.length > 0}
{#each emailLegalHolds as holdInfo (holdInfo.legalHoldId)} -
+
{holdInfo.holdName} {#if holdInfo.isActive} - + {$t('app.legal_holds.active')} {:else} @@ -464,15 +544,20 @@ > {#if selectedHoldId} - {legalHolds.find((h) => h.id === selectedHoldId)?.name ?? - $t('app.archive_legal_holds.apply_hold_placeholder')} + {legalHolds.find((h) => h.id === selectedHoldId) + ?.name ?? + $t( + 'app.archive_legal_holds.apply_hold_placeholder' + )} {:else} {$t('app.archive_legal_holds.apply_hold_placeholder')} {/if} {#each legalHolds as hold (hold.id)} - {hold.name} + {hold.name} {/each} @@ -517,11 +602,14 @@ {#if emailRetentionLabel && !emailRetentionLabel.isLabelDisabled} -
+
{$t('app.archive.retention_policy_overridden_by_label')} - {emailRetentionLabel.labelName}. + {emailRetentionLabel.labelName}.
{/if} @@ -542,13 +630,13 @@
{#if scheduledDeletionDate} -
- - {$t('app.archive.retention_scheduled_deletion')}: +
+ + {$t('app.archive.retention_scheduled_deletion')}:
{#each retentionPolicy.matchingPolicyIds as policyId} - + {policyId} {/each} @@ -619,12 +707,19 @@ {emailRetentionLabel.labelName} - + {$t('app.archive_labels.label_inactive')}
-
- +
+

{$t('app.archive_labels.label_inactive_note')}

@@ -668,7 +763,9 @@
- + {$t('app.archive.retention_period')}: @@ -749,7 +846,8 @@ > {#if selectedLabelId} - {retentionLabels.find((l) => l.id === selectedLabelId)?.name ?? + {retentionLabels.find((l) => l.id === selectedLabelId) + ?.name ?? $t('app.archive_labels.select_label_placeholder')} {:else} {$t('app.archive_labels.select_label_placeholder')} @@ -760,13 +858,14 @@ {label.name} - ({label.retentionPeriodDays} {$t('app.retention_labels.days')}) + ({label.retentionPeriodDays} + {$t('app.retention_labels.days')}) {/each} - +
diff --git a/packages/frontend/src/routes/dashboard/compliance/legal-holds/+page.server.ts b/packages/frontend/src/routes/dashboard/compliance/legal-holds/+page.server.ts index 3557a5dc..6cbc722d 100644 --- a/packages/frontend/src/routes/dashboard/compliance/legal-holds/+page.server.ts +++ b/packages/frontend/src/routes/dashboard/compliance/legal-holds/+page.server.ts @@ -98,7 +98,10 @@ export const actions: Actions = { if (!response.ok) { const res = await response.json().catch(() => ({})); - return { success: false, message: (res as { message?: string }).message || 'Failed to delete legal hold.' }; + return { + success: false, + message: (res as { message?: string }).message || 'Failed to delete legal hold.', + }; } return { success: true }; @@ -147,7 +150,8 @@ export const actions: Actions = { if (!response.ok) { return { success: false, - message: (res as { message?: string }).message || 'Failed to release emails from hold.', + message: + (res as { message?: string }).message || 'Failed to release emails from hold.', }; } diff --git a/packages/frontend/src/routes/dashboard/compliance/legal-holds/+page.svelte b/packages/frontend/src/routes/dashboard/compliance/legal-holds/+page.svelte index 5dd20a71..dffd6b93 100644 --- a/packages/frontend/src/routes/dashboard/compliance/legal-holds/+page.svelte +++ b/packages/frontend/src/routes/dashboard/compliance/legal-holds/+page.svelte @@ -10,11 +10,11 @@ import { Label } from '$lib/components/ui/label'; import { Textarea } from '$lib/components/ui/textarea'; import { enhance } from '$app/forms'; - import { MoreHorizontal, Plus, Users } from 'lucide-svelte'; + import { MoreHorizontal, Plus, ShieldCheck } from 'lucide-svelte'; import { setAlert } from '$lib/components/custom/alert/alert-state.svelte'; import type { LegalHold } from '@open-archiver/types'; - let { data, form }: { data: PageData; form: ActionData } = $props(); + let { data }: { data: PageData; form: ActionData } = $props(); let holds = $derived(data.holds); @@ -85,9 +85,6 @@

{$t('app.legal_holds.header')}

-

- {$t('app.legal_holds.header_description')} -

-