diff --git a/.env.example b/.env.example index 42ca561..d307d03 100644 --- a/.env.example +++ b/.env.example @@ -104,3 +104,24 @@ ENCRYPTION_KEY= # Apache Tika Integration # ONLY active if TIKA_URL is set TIKA_URL=http://tika:9998 + + +# Enterprise features (Skip this part if you are using the open-source version) + +# Batch size for managing retention policy lifecycle. (This number of emails will be checked each time when retention policy scans the database. Adjust based on your system capability.) +RETENTION_BATCH_SIZE=1000 + +# --- SMTP Journaling (Enterprise only) --- +# The port the embedded SMTP journaling listener binds to inside the container. +# This is the port your MTA (Exchange, MS365, Postfix, etc.) will send journal reports to. +# The docker-compose.yml maps this same port on the host side by default. +SMTP_JOURNALING_PORT=2525 +# The domain used to generate routing addresses for journaling sources. +# Each source gets a unique address like journal-@. +# Set this to the domain/subdomain whose MX record points to this server. +SMTP_JOURNALING_DOMAIN=journal.yourdomain.com +# Maximum number of waiting jobs in the journal queue before the SMTP listener +# returns 4xx temporary failures (backpressure). The MTA will retry automatically. +JOURNAL_QUEUE_BACKPRESSURE_THRESHOLD=10000 +#BullMQ worker concurrency for processing journaled emails. Increase on servers with more CPU cores. +JOURNAL_WORKER_CONCURRENCY=3 \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index e73b9bc..ec77e67 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,7 +6,7 @@ services: container_name: open-archiver restart: unless-stopped ports: - - '3000:3000' # Frontend + - '${PORT_FRONTEND:-3000}:3000' # Frontend env_file: - .env volumes: @@ -42,7 +42,7 @@ services: - open-archiver-net meilisearch: - image: getmeili/meilisearch:v1.15 + image: getmeili/meilisearch:v1.38 container_name: meilisearch restart: unless-stopped environment: diff --git a/docs/api/ingestion.md b/docs/api/ingestion.md index fb0baa7..4423a1c 100644 --- a/docs/api/ingestion.md +++ b/docs/api/ingestion.md @@ -37,3 +37,7 @@ Manage ingestion sources — the configured connections to email providers (Goog ## Force Sync + +## Unmerge an Ingestion Source + + diff --git a/docs/api/openapi.json b/docs/api/openapi.json index 46d8ccb..c89ef83 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -15,7 +15,7 @@ }, "servers": [ { - "url": "http://localhost:3001", + "url": "http://localhost:3000", "description": "Local development" } ], diff --git a/docs/user-guides/email-providers/index.md b/docs/user-guides/email-providers/index.md index 7fa816c..1e7c607 100644 --- a/docs/user-guides/email-providers/index.md +++ b/docs/user-guides/email-providers/index.md @@ -10,3 +10,4 @@ Choose your provider from the list below to get started: - [EML Import](./eml.md) - [PST Import](./pst.md) - [Mbox Import](./mbox.md) +- [Merging Ingestion Sources](./merging-sources.md) diff --git a/docs/user-guides/email-providers/merging-sources.md b/docs/user-guides/email-providers/merging-sources.md new file mode 100644 index 0000000..4303363 --- /dev/null +++ b/docs/user-guides/email-providers/merging-sources.md @@ -0,0 +1,105 @@ +# Merging Ingestion Sources + +Merged ingestion groups let you combine multiple ingestion sources so that their emails appear unified in browsing, search, and thread views. This is useful when you want to pair a historical archive (for example, a PST or Mbox import) with a live connection, or when migrating between providers. + +## Concepts + +| Term | Definition | +| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Root source** | An ingestion source where no merge parent is set. Shown as the primary row in the Ingestions table. All emails in the group are physically owned by the root. | +| **Child source** | An ingestion source merged into a root. Acts as a fetch assistant — it connects to the provider and retrieves emails, but all data is stored under the root source. | +| **Group** | A root source and all its children. All emails from every member are stored under and owned by the root. | + +The hierarchy is **flat** — only one level of nesting is supported. If you merge a source into a child, the system automatically redirects the relationship to the root. + +## Root Ownership — How Storage and Data Work + +This is the key design principle of merged sources: + +> **Child sources are assistants. They fetch emails from their provider but never own any stored data. Every email ingested by a child is written to the root source's storage folder and assigned the root source's ID in the database.** + +In practical terms: + +- The storage path for every email belongs to the root: `openarchiver/{root-name}-{root-id}/emails/...` +- Every `archived_emails` database row created by a child ingestion will have `ingestionSourceId` set to the **root's ID**, not the child's. +- Attachments are also stored under the root's folder and scoped to the root's ID. +- The root's **Preserve Original File** (GoBD compliance) setting is inherited by all children in the group. A child's own `preserveOriginalFile` setting is ignored during ingestion — only the root's setting applies. + +This means browsing the root source's emails will show all emails from the entire group, including those fetched by child sources, without any extra configuration. + +## When to Use Merged Sources + +- **Historical + live**: Import a PST archive and merge it into an active IMAP or Google Workspace connection so historical and current emails appear in one unified mailbox. +- **Provider migration**: Add a new Microsoft 365 connector and merge it with your existing Google Workspace connector during a cutover period. +- **Backfill**: Import an Mbox export and merge it with a live connection to cover a gap in the archive. + +## How to Merge a New Source Into an Existing One + +Merging can only be configured **at creation time**. + +1. Navigate to the **Ingestions** page. +2. Click **Create New** to open the ingestion source form. +3. Fill in the provider details as usual. +4. Expand the **Advanced Options** section at the bottom of the form. This section is only visible when at least one ingestion source already exists. +5. Check **Merge into existing ingestion** and select the target root source from the dropdown. +6. Click **Submit**. + +The new source will run its initial import normally. Once complete, its emails will appear alongside those of the root source — all stored under the root. + +## How Emails Appear When Merged + +When you browse archived emails for a root source, you see all emails in the group because they are all physically owned by the root. There is nothing to aggregate — the data is already unified at the storage and database level. + +The same applies to search: filtering by a root source ID returns all emails in the group. + +Threads also span the merge group. If a reply arrived via a different source than the original message, it still appears in the correct thread. + +## How Syncing Works + +Each source syncs **independently**. The scheduler picks up all sources with status `active` or `error`, regardless of whether they are merged. + +- File-based imports (PST, EML, Mbox) finish with status `imported` and are never re-synced automatically. +- Live sources (IMAP, Google Workspace, Microsoft 365) continue their normal sync cycle. + +When you trigger **Force Sync** on a root source, the system also queues a sync for all non-file-based children that are currently `active` or `error`. + +## Deduplication Across the Group + +When ingesting emails, duplicate detection covers the **entire merge group**. If the same email (matched by its RFC `Message-ID` header or provider-specific ID) already exists anywhere in the group, it is skipped and not stored again. + +## Preserve Original File (GoBD Compliance) and Merged Sources + +The **Preserve Original File** setting on the root source governs the entire group. When this setting is enabled on the root: + +- All emails ingested by child sources are also stored unmodified (raw EML, no attachment stripping). +- The child's own `preserveOriginalFile` setting has no effect — the root's setting is always used. + +This ensures consistent compliance behaviour across the group. If you require GoBD or SEC 17a-4 compliance for an entire merged group, enable **Preserve Original File** on the root source before adding any children. + +## Editing Sources in a Group + +Each source in a group can be edited independently. Expand the group row in the Ingestions table by clicking the chevron, then use the **⋮** actions menu on the specific source (root or child) you want to edit. + +## Unmerging a Child Source + +To detach a child from its group and make it standalone: + +1. Expand the group row by clicking the chevron next to the root source name. +2. Open the **⋮** actions menu on the child source. +3. Click **Unmerge**. + +The child becomes an independent root source. No email data is moved or deleted. + +> **Note:** Because all emails fetched by the child were stored under the root source's ID, unmerging the child does not transfer those emails. Historical emails ingested while the source was a child remain owned by the root. Only new emails ingested after unmerging will be stored under the (now standalone) child. + +## Deleting Sources in a Group + +- **Deleting a root source** also deletes all its children: their configuration, and all emails, attachments, storage files, and search index entries owned by the root are all removed. Because all group emails are stored under the root, this effectively removes the entire group's archive. +- **Deleting a child source** removes only the child's configuration and sync state. Emails already ingested by the child are stored under the root and are **not** deleted. + +A warning is shown in the delete confirmation dialog when a root source has children. + +## Known Limitations + +- **Merging existing standalone sources is not supported.** You can only merge a source into a group at creation time. To merge two existing sources, you must delete one and recreate it with the merge target selected. +- **Historical data from a child source before unmerging remains with the root.** If you unmerge a child, emails it previously ingested stay owned by the root and are not migrated to the child. diff --git a/package.json b/package.json index 2ceda0d..fd589f6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-archiver", - "version": "0.5.0", + "version": "0.5.1", "private": true, "license": "SEE LICENSE IN LICENSE file", "scripts": { diff --git a/packages/backend/scripts/generate-openapi-spec.mjs b/packages/backend/scripts/generate-openapi-spec.mjs index e6613c2..ae49ec7 100644 --- a/packages/backend/scripts/generate-openapi-spec.mjs +++ b/packages/backend/scripts/generate-openapi-spec.mjs @@ -31,7 +31,7 @@ const options = { }, servers: [ { - url: 'http://localhost:3001', + url: 'http://localhost:3000', description: 'Local development', }, ], diff --git a/packages/backend/src/api/controllers/ingestion.controller.ts b/packages/backend/src/api/controllers/ingestion.controller.ts index f0d05d3..03edebc 100644 --- a/packages/backend/src/api/controllers/ingestion.controller.ts +++ b/packages/backend/src/api/controllers/ingestion.controller.ts @@ -177,6 +177,31 @@ export class IngestionController { } }; + public unmerge = async (req: Request, res: Response): Promise => { + try { + 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') }); + } + const updatedSource = await IngestionService.unmerge(id, actor, req.ip || 'unknown'); + const safeSource = this.toSafeIngestionSource(updatedSource); + return res.status(200).json(safeSource); + } catch (error) { + logger.error({ err: error }, `Unmerge ingestion source ${req.params.id} error`); + if (error instanceof Error && error.message === 'Ingestion source not found') { + return res.status(404).json({ message: req.t('ingestion.notFound') }); + } else if (error instanceof Error) { + return res.status(400).json({ message: error.message }); + } + return res.status(500).json({ message: req.t('errors.internalServerError') }); + } + }; + public triggerForceSync = async (req: Request, res: Response): Promise => { try { const { id } = req.params; diff --git a/packages/backend/src/api/routes/ingestion.routes.ts b/packages/backend/src/api/routes/ingestion.routes.ts index 7d3efe2..fa7d78f 100644 --- a/packages/backend/src/api/routes/ingestion.routes.ts +++ b/packages/backend/src/api/routes/ingestion.routes.ts @@ -291,5 +291,43 @@ export const createIngestionRouter = ( ingestionController.triggerForceSync ); + /** + * @openapi + * /v1/ingestion-sources/{id}/unmerge: + * post: + * summary: Unmerge a child ingestion source + * description: Detaches a child source from its merge group, making it a standalone root source. Requires `update:ingestion` permission. + * operationId: unmergeIngestionSource + * tags: + * - Ingestion + * security: + * - bearerAuth: [] + * - apiKeyAuth: [] + * parameters: + * - name: id + * in: path + * required: true + * schema: + * type: string + * responses: + * '200': + * description: Source unmerged. Returns the updated source. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/SafeIngestionSource' + * '400': + * description: Source is not merged into another source. + * '401': + * $ref: '#/components/responses/Unauthorized' + * '404': + * $ref: '#/components/responses/NotFound' + */ + router.post( + '/:id/unmerge', + requirePermission('update', 'ingestion'), + ingestionController.unmerge + ); + return router; }; diff --git a/packages/backend/src/database/migrations/0030_strong_ultron.sql b/packages/backend/src/database/migrations/0030_strong_ultron.sql new file mode 100644 index 0000000..4127e0d --- /dev/null +++ b/packages/backend/src/database/migrations/0030_strong_ultron.sql @@ -0,0 +1,20 @@ +CREATE TYPE "public"."journaling_source_status" AS ENUM('active', 'paused');--> statement-breakpoint +ALTER TYPE "public"."ingestion_provider" ADD VALUE 'smtp_journaling';--> statement-breakpoint +ALTER TYPE "public"."audit_log_target_type" ADD VALUE 'JournalingSource' BEFORE 'RetentionPolicy';--> statement-breakpoint +CREATE TABLE "journaling_sources" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "name" text NOT NULL, + "allowed_ips" jsonb NOT NULL, + "require_tls" boolean DEFAULT true NOT NULL, + "smtp_username" text, + "smtp_password_hash" text, + "status" "journaling_source_status" DEFAULT 'active' NOT NULL, + "ingestion_source_id" uuid NOT NULL, + "routing_address" text NOT NULL, + "total_received" integer DEFAULT 0 NOT NULL, + "last_received_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "journaling_sources" ADD CONSTRAINT "journaling_sources_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/0031_bouncy_boomerang.sql b/packages/backend/src/database/migrations/0031_bouncy_boomerang.sql new file mode 100644 index 0000000..3bd74e3 --- /dev/null +++ b/packages/backend/src/database/migrations/0031_bouncy_boomerang.sql @@ -0,0 +1 @@ +ALTER TABLE "ingestion_sources" ADD COLUMN "preserve_original_file" boolean DEFAULT false NOT NULL; \ No newline at end of file diff --git a/packages/backend/src/database/migrations/0032_exotic_the_twelve.sql b/packages/backend/src/database/migrations/0032_exotic_the_twelve.sql new file mode 100644 index 0000000..481cc29 --- /dev/null +++ b/packages/backend/src/database/migrations/0032_exotic_the_twelve.sql @@ -0,0 +1,3 @@ +ALTER TABLE "archived_emails" ADD COLUMN "is_journaled" boolean DEFAULT false;--> statement-breakpoint +ALTER TABLE "ingestion_sources" ADD COLUMN "merged_into_id" uuid;--> statement-breakpoint +CREATE INDEX "idx_merged_into" ON "ingestion_sources" USING btree ("merged_into_id"); \ No newline at end of file diff --git a/packages/backend/src/database/migrations/0033_adorable_lockheed.sql b/packages/backend/src/database/migrations/0033_adorable_lockheed.sql new file mode 100644 index 0000000..6b7c90a --- /dev/null +++ b/packages/backend/src/database/migrations/0033_adorable_lockheed.sql @@ -0,0 +1 @@ +ALTER TABLE "ingestion_sources" ADD CONSTRAINT "ingestion_sources_merged_into_id_ingestion_sources_id_fk" FOREIGN KEY ("merged_into_id") REFERENCES "public"."ingestion_sources"("id") ON DELETE set null ON UPDATE no action; \ No newline at end of file diff --git a/packages/backend/src/database/migrations/0034_stiff_toad.sql b/packages/backend/src/database/migrations/0034_stiff_toad.sql new file mode 100644 index 0000000..ab7fd49 --- /dev/null +++ b/packages/backend/src/database/migrations/0034_stiff_toad.sql @@ -0,0 +1 @@ +ALTER TYPE "public"."ingestion_status" ADD VALUE 'partially_active'; \ No newline at end of file diff --git a/packages/backend/src/database/migrations/meta/0030_snapshot.json b/packages/backend/src/database/migrations/meta/0030_snapshot.json new file mode 100644 index 0000000..358d257 --- /dev/null +++ b/packages/backend/src/database/migrations/meta/0030_snapshot.json @@ -0,0 +1,1727 @@ +{ + "id": "a9094976-87e1-4a52-b5a5-ddec968bbecd", + "prevId": "5b69110d-3df3-41e0-982c-57413d5956f5", + "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 + }, + "public.journaling_sources": { + "name": "journaling_sources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allowed_ips": { + "name": "allowed_ips", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "require_tls": { + "name": "require_tls", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "smtp_username": { + "name": "smtp_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "smtp_password_hash": { + "name": "smtp_password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "journaling_source_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "ingestion_source_id": { + "name": "ingestion_source_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "routing_address": { + "name": "routing_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_received": { + "name": "total_received", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_received_at": { + "name": "last_received_at", + "type": "timestamp with time zone", + "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": { + "journaling_sources_ingestion_source_id_ingestion_sources_id_fk": { + "name": "journaling_sources_ingestion_source_id_ingestion_sources_id_fk", + "tableFrom": "journaling_sources", + "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", + "smtp_journaling" + ] + }, + "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", + "JournalingSource", + "RetentionPolicy", + "RetentionLabel", + "LegalHold", + "Role", + "SystemEvent", + "SystemSettings", + "User", + "File" + ] + }, + "public.journaling_source_status": { + "name": "journaling_source_status", + "schema": "public", + "values": ["active", "paused"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/backend/src/database/migrations/meta/0031_snapshot.json b/packages/backend/src/database/migrations/meta/0031_snapshot.json new file mode 100644 index 0000000..6ec2420 --- /dev/null +++ b/packages/backend/src/database/migrations/meta/0031_snapshot.json @@ -0,0 +1,1734 @@ +{ + "id": "a1e1d446-db1b-4316-961b-82dcd0e1423d", + "prevId": "a9094976-87e1-4a52-b5a5-ddec968bbecd", + "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 + }, + "preserve_original_file": { + "name": "preserve_original_file", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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 + }, + "public.journaling_sources": { + "name": "journaling_sources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allowed_ips": { + "name": "allowed_ips", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "require_tls": { + "name": "require_tls", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "smtp_username": { + "name": "smtp_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "smtp_password_hash": { + "name": "smtp_password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "journaling_source_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "ingestion_source_id": { + "name": "ingestion_source_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "routing_address": { + "name": "routing_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_received": { + "name": "total_received", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_received_at": { + "name": "last_received_at", + "type": "timestamp with time zone", + "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": { + "journaling_sources_ingestion_source_id_ingestion_sources_id_fk": { + "name": "journaling_sources_ingestion_source_id_ingestion_sources_id_fk", + "tableFrom": "journaling_sources", + "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", + "smtp_journaling" + ] + }, + "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", + "JournalingSource", + "RetentionPolicy", + "RetentionLabel", + "LegalHold", + "Role", + "SystemEvent", + "SystemSettings", + "User", + "File" + ] + }, + "public.journaling_source_status": { + "name": "journaling_source_status", + "schema": "public", + "values": ["active", "paused"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/backend/src/database/migrations/meta/0032_snapshot.json b/packages/backend/src/database/migrations/meta/0032_snapshot.json new file mode 100644 index 0000000..257f730 --- /dev/null +++ b/packages/backend/src/database/migrations/meta/0032_snapshot.json @@ -0,0 +1,1763 @@ +{ + "id": "9fab872e-0b67-41d9-be9e-c0494f845207", + "prevId": "a1e1d446-db1b-4316-961b-82dcd0e1423d", + "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 + }, + "is_journaled": { + "name": "is_journaled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "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 + }, + "preserve_original_file": { + "name": "preserve_original_file", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "merged_into_id": { + "name": "merged_into_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": { + "idx_merged_into": { + "name": "idx_merged_into", + "columns": [ + { + "expression": "merged_into_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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 + }, + "public.journaling_sources": { + "name": "journaling_sources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allowed_ips": { + "name": "allowed_ips", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "require_tls": { + "name": "require_tls", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "smtp_username": { + "name": "smtp_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "smtp_password_hash": { + "name": "smtp_password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "journaling_source_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "ingestion_source_id": { + "name": "ingestion_source_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "routing_address": { + "name": "routing_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_received": { + "name": "total_received", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_received_at": { + "name": "last_received_at", + "type": "timestamp with time zone", + "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": { + "journaling_sources_ingestion_source_id_ingestion_sources_id_fk": { + "name": "journaling_sources_ingestion_source_id_ingestion_sources_id_fk", + "tableFrom": "journaling_sources", + "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", + "smtp_journaling" + ] + }, + "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", + "JournalingSource", + "RetentionPolicy", + "RetentionLabel", + "LegalHold", + "Role", + "SystemEvent", + "SystemSettings", + "User", + "File" + ] + }, + "public.journaling_source_status": { + "name": "journaling_source_status", + "schema": "public", + "values": ["active", "paused"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/backend/src/database/migrations/meta/0033_snapshot.json b/packages/backend/src/database/migrations/meta/0033_snapshot.json new file mode 100644 index 0000000..6922651 --- /dev/null +++ b/packages/backend/src/database/migrations/meta/0033_snapshot.json @@ -0,0 +1,1772 @@ +{ + "id": "3715c0a0-8472-483d-b5ff-69329de9f952", + "prevId": "9fab872e-0b67-41d9-be9e-c0494f845207", + "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 + }, + "is_journaled": { + "name": "is_journaled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "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 + }, + "preserve_original_file": { + "name": "preserve_original_file", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "merged_into_id": { + "name": "merged_into_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": { + "idx_merged_into": { + "name": "idx_merged_into", + "columns": [ + { + "expression": "merged_into_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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" + }, + "ingestion_sources_merged_into_id_ingestion_sources_id_fk": { + "name": "ingestion_sources_merged_into_id_ingestion_sources_id_fk", + "tableFrom": "ingestion_sources", + "tableTo": "ingestion_sources", + "columnsFrom": ["merged_into_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "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 + }, + "public.journaling_sources": { + "name": "journaling_sources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allowed_ips": { + "name": "allowed_ips", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "require_tls": { + "name": "require_tls", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "smtp_username": { + "name": "smtp_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "smtp_password_hash": { + "name": "smtp_password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "journaling_source_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "ingestion_source_id": { + "name": "ingestion_source_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "routing_address": { + "name": "routing_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_received": { + "name": "total_received", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_received_at": { + "name": "last_received_at", + "type": "timestamp with time zone", + "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": { + "journaling_sources_ingestion_source_id_ingestion_sources_id_fk": { + "name": "journaling_sources_ingestion_source_id_ingestion_sources_id_fk", + "tableFrom": "journaling_sources", + "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", + "smtp_journaling" + ] + }, + "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", + "JournalingSource", + "RetentionPolicy", + "RetentionLabel", + "LegalHold", + "Role", + "SystemEvent", + "SystemSettings", + "User", + "File" + ] + }, + "public.journaling_source_status": { + "name": "journaling_source_status", + "schema": "public", + "values": ["active", "paused"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/backend/src/database/migrations/meta/0034_snapshot.json b/packages/backend/src/database/migrations/meta/0034_snapshot.json new file mode 100644 index 0000000..456906d --- /dev/null +++ b/packages/backend/src/database/migrations/meta/0034_snapshot.json @@ -0,0 +1,1773 @@ +{ + "id": "09b136d4-82c6-480c-8e84-9e46d09304f9", + "prevId": "3715c0a0-8472-483d-b5ff-69329de9f952", + "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 + }, + "is_journaled": { + "name": "is_journaled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "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 + }, + "preserve_original_file": { + "name": "preserve_original_file", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "merged_into_id": { + "name": "merged_into_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": { + "idx_merged_into": { + "name": "idx_merged_into", + "columns": [ + { + "expression": "merged_into_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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" + }, + "ingestion_sources_merged_into_id_ingestion_sources_id_fk": { + "name": "ingestion_sources_merged_into_id_ingestion_sources_id_fk", + "tableFrom": "ingestion_sources", + "tableTo": "ingestion_sources", + "columnsFrom": ["merged_into_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "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 + }, + "public.journaling_sources": { + "name": "journaling_sources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allowed_ips": { + "name": "allowed_ips", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "require_tls": { + "name": "require_tls", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "smtp_username": { + "name": "smtp_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "smtp_password_hash": { + "name": "smtp_password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "journaling_source_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "ingestion_source_id": { + "name": "ingestion_source_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "routing_address": { + "name": "routing_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_received": { + "name": "total_received", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_received_at": { + "name": "last_received_at", + "type": "timestamp with time zone", + "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": { + "journaling_sources_ingestion_source_id_ingestion_sources_id_fk": { + "name": "journaling_sources_ingestion_source_id_ingestion_sources_id_fk", + "tableFrom": "journaling_sources", + "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", + "smtp_journaling" + ] + }, + "public.ingestion_status": { + "name": "ingestion_status", + "schema": "public", + "values": [ + "active", + "paused", + "error", + "pending_auth", + "syncing", + "importing", + "auth_success", + "imported", + "partially_active" + ] + }, + "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", + "JournalingSource", + "RetentionPolicy", + "RetentionLabel", + "LegalHold", + "Role", + "SystemEvent", + "SystemSettings", + "User", + "File" + ] + }, + "public.journaling_source_status": { + "name": "journaling_source_status", + "schema": "public", + "values": ["active", "paused"] + } + }, + "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 561be85..0bd4d48 100644 --- a/packages/backend/src/database/migrations/meta/_journal.json +++ b/packages/backend/src/database/migrations/meta/_journal.json @@ -211,6 +211,41 @@ "when": 1773927678269, "tag": "0029_lethal_brood", "breakpoints": true + }, + { + "idx": 30, + "version": "7", + "when": 1774440788278, + "tag": "0030_strong_ultron", + "breakpoints": true + }, + { + "idx": 31, + "version": "7", + "when": 1774623960683, + "tag": "0031_bouncy_boomerang", + "breakpoints": true + }, + { + "idx": 32, + "version": "7", + "when": 1774709286830, + "tag": "0032_exotic_the_twelve", + "breakpoints": true + }, + { + "idx": 33, + "version": "7", + "when": 1774719684064, + "tag": "0033_adorable_lockheed", + "breakpoints": true + }, + { + "idx": 34, + "version": "7", + "when": 1774900882674, + "tag": "0034_stiff_toad", + "breakpoints": true } ] } diff --git a/packages/backend/src/database/schema.ts b/packages/backend/src/database/schema.ts index 444423b..3af887f 100644 --- a/packages/backend/src/database/schema.ts +++ b/packages/backend/src/database/schema.ts @@ -10,3 +10,4 @@ export * from './schema/api-keys'; export * from './schema/audit-logs'; export * from './schema/enums'; export * from './schema/sync-sessions'; +export * from './schema/journaling-sources'; diff --git a/packages/backend/src/database/schema/archived-emails.ts b/packages/backend/src/database/schema/archived-emails.ts index 42aa2bb..893c46d 100644 --- a/packages/backend/src/database/schema/archived-emails.ts +++ b/packages/backend/src/database/schema/archived-emails.ts @@ -26,6 +26,7 @@ export const archivedEmails = pgTable( isIndexed: boolean('is_indexed').notNull().default(false), hasAttachments: boolean('has_attachments').notNull().default(false), isOnLegalHold: boolean('is_on_legal_hold').notNull().default(false), + isJournaled: boolean('is_journaled').default(false), archivedAt: timestamp('archived_at', { withTimezone: true }).notNull().defaultNow(), path: text('path'), tags: jsonb('tags'), diff --git a/packages/backend/src/database/schema/ingestion-sources.ts b/packages/backend/src/database/schema/ingestion-sources.ts index 98d937d..6cda0c8 100644 --- a/packages/backend/src/database/schema/ingestion-sources.ts +++ b/packages/backend/src/database/schema/ingestion-sources.ts @@ -1,4 +1,14 @@ -import { jsonb, pgEnum, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core'; +import { + boolean, + index, + jsonb, + pgEnum, + pgTable, + text, + timestamp, + uuid, + type AnyPgColumn, +} from 'drizzle-orm/pg-core'; import { users } from './users'; import { relations } from 'drizzle-orm'; @@ -9,6 +19,7 @@ export const ingestionProviderEnum = pgEnum('ingestion_provider', [ 'pst_import', 'eml_import', 'mbox_import', + 'smtp_journaling', ]); export const ingestionStatusEnum = pgEnum('ingestion_status', [ @@ -20,26 +31,47 @@ export const ingestionStatusEnum = pgEnum('ingestion_status', [ 'importing', 'auth_success', 'imported', + 'partially_active', ]); -export const ingestionSources = pgTable('ingestion_sources', { - id: uuid('id').primaryKey().defaultRandom(), - userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }), - name: text('name').notNull(), - provider: ingestionProviderEnum('provider').notNull(), - credentials: text('credentials'), - status: ingestionStatusEnum('status').notNull().default('pending_auth'), - lastSyncStartedAt: timestamp('last_sync_started_at', { withTimezone: true }), - lastSyncFinishedAt: timestamp('last_sync_finished_at', { withTimezone: true }), - lastSyncStatusMessage: text('last_sync_status_message'), - syncState: jsonb('sync_state'), - createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), - updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), -}); +export const ingestionSources = pgTable( + 'ingestion_sources', + { + id: uuid('id').primaryKey().defaultRandom(), + userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }), + name: text('name').notNull(), + provider: ingestionProviderEnum('provider').notNull(), + credentials: text('credentials'), + status: ingestionStatusEnum('status').notNull().default('pending_auth'), + lastSyncStartedAt: timestamp('last_sync_started_at', { withTimezone: true }), + lastSyncFinishedAt: timestamp('last_sync_finished_at', { withTimezone: true }), + lastSyncStatusMessage: text('last_sync_status_message'), + syncState: jsonb('sync_state'), + preserveOriginalFile: boolean('preserve_original_file').notNull().default(false), + /** Self-referencing FK for merge groups. When set, this source is a child + * whose emails are logically grouped with the root source. Flat hierarchy only. */ + mergedIntoId: uuid('merged_into_id').references((): AnyPgColumn => ingestionSources.id, { + onDelete: 'set null', + }), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [index('idx_merged_into').on(table.mergedIntoId)] +); -export const ingestionSourcesRelations = relations(ingestionSources, ({ one }) => ({ +export const ingestionSourcesRelations = relations(ingestionSources, ({ one, many }) => ({ user: one(users, { fields: [ingestionSources.userId], references: [users.id], }), + /** The root source this child is merged into (null if this is a root). */ + mergedInto: one(ingestionSources, { + fields: [ingestionSources.mergedIntoId], + references: [ingestionSources.id], + relationName: 'mergedChildren', + }), + /** Child sources that are merged into this root. */ + children: many(ingestionSources, { + relationName: 'mergedChildren', + }), })); diff --git a/packages/backend/src/database/schema/journaling-sources.ts b/packages/backend/src/database/schema/journaling-sources.ts new file mode 100644 index 0000000..bc12591 --- /dev/null +++ b/packages/backend/src/database/schema/journaling-sources.ts @@ -0,0 +1,47 @@ +import { + boolean, + integer, + jsonb, + pgEnum, + pgTable, + text, + timestamp, + uuid, +} from 'drizzle-orm/pg-core'; +import { relations } from 'drizzle-orm'; +import { ingestionSources } from './ingestion-sources'; + +export const journalingSourceStatusEnum = pgEnum('journaling_source_status', ['active', 'paused']); + +export const journalingSources = pgTable('journaling_sources', { + id: uuid('id').primaryKey().defaultRandom(), + name: text('name').notNull(), + /** CIDR blocks or IP addresses allowed to send journal reports */ + allowedIps: jsonb('allowed_ips').notNull().$type(), + /** Whether to reject non-TLS connections (GDPR compliance) */ + requireTls: boolean('require_tls').notNull().default(true), + /** Optional SMTP AUTH username */ + smtpUsername: text('smtp_username'), + /** Bcrypt-hashed SMTP AUTH password */ + smtpPasswordHash: text('smtp_password_hash'), + status: journalingSourceStatusEnum('status').notNull().default('active'), + /** The backing ingestion source that owns all archived emails */ + ingestionSourceId: uuid('ingestion_source_id') + .notNull() + .references(() => ingestionSources.id, { onDelete: 'cascade' }), + /** Persisted SMTP routing address generated at creation time (immutable unless regenerated) */ + routingAddress: text('routing_address').notNull(), + /** Running count of emails received via this journaling endpoint */ + totalReceived: integer('total_received').notNull().default(0), + /** Timestamp of the last email received */ + lastReceivedAt: timestamp('last_received_at', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), +}); + +export const journalingSourcesRelations = relations(journalingSources, ({ one }) => ({ + ingestionSource: one(ingestionSources, { + fields: [journalingSources.ingestionSourceId], + references: [ingestionSources.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 1774c95..1fdd89a 100644 --- a/packages/backend/src/jobs/processors/sync-cycle-finished.processor.ts +++ b/packages/backend/src/jobs/processors/sync-cycle-finished.processor.ts @@ -57,7 +57,7 @@ export default async (job: Job) => { // syncState was already merged incrementally by each process-mailbox job via // SyncSessionService.recordMailboxResult() — no deepmerge needed here. await IngestionService.update(ingestionSourceId, { - status, + status: source.status === 'paused' ? 'paused' : status, // Don't override paused status lastSyncFinishedAt: new Date(), lastSyncStatusMessage: message, }); diff --git a/packages/backend/src/locales/bg/translation.json b/packages/backend/src/locales/bg/translation.json index bf33ebe..d9e6067 100644 --- a/packages/backend/src/locales/bg/translation.json +++ b/packages/backend/src/locales/bg/translation.json @@ -14,7 +14,8 @@ "demoMode": "Тази операция не е разрешена в демо режим", "unauthorized": "Неоторизирано", "unknown": "Възникна неизвестна грешка", - "noPermissionToAction": "Нямате разрешение да извършите текущото действие." + "noPermissionToAction": "Нямате разрешение да извършите текущото действие.", + "deletion_disabled": "Изтриването е деактивирано за тази инстанция." }, "user": { "notFound": "Потребителят не е открит", @@ -65,5 +66,12 @@ }, "api": { "requestBodyInvalid": "Невалидно съдържание на заявката." + }, + "upload": { + "invalid_request": "Заявката за качване е невалидна или неправилно формирана.", + "stream_error": "При получаването на файла възникна грешка. Моля, опитайте отново.", + "parse_error": "Данните на качения файл не можаха да бъдат обработени.", + "storage_error": "Каченият файл не можа да бъде запазен. Моля, опитайте отново.", + "connection_error": "Връзката беше прекъсната по време на качването." } } diff --git a/packages/backend/src/locales/de/translation.json b/packages/backend/src/locales/de/translation.json index 4a861ec..5bbf6b9 100644 --- a/packages/backend/src/locales/de/translation.json +++ b/packages/backend/src/locales/de/translation.json @@ -59,5 +59,19 @@ "invalidFilePath": "Ungültiger Dateipfad", "fileNotFound": "Datei nicht gefunden", "downloadError": "Fehler beim Herunterladen der Datei" + }, + "apiKeys": { + "generateSuccess": "API-Schlüssel erfolgreich generiert.", + "deleteSuccess": "API-Schlüssel erfolgreich gelöscht." + }, + "api": { + "requestBodyInvalid": "Ungültiger Anfrage-Body." + }, + "upload": { + "invalid_request": "Die Upload-Anfrage ist ungültig oder fehlerhaft.", + "stream_error": "Beim Empfangen der Datei ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.", + "parse_error": "Die hochgeladenen Dateidaten konnten nicht verarbeitet werden.", + "storage_error": "Die hochgeladene Datei konnte nicht gespeichert werden. Bitte versuchen Sie es erneut.", + "connection_error": "Die Verbindung wurde während des Uploads unterbrochen." } } diff --git a/packages/backend/src/locales/el/translation.json b/packages/backend/src/locales/el/translation.json index 7656b73..a653431 100644 --- a/packages/backend/src/locales/el/translation.json +++ b/packages/backend/src/locales/el/translation.json @@ -14,7 +14,8 @@ "demoMode": "Αυτή η λειτουργία δεν επιτρέπεται σε λειτουργία επίδειξης.", "unauthorized": "Μη εξουσιοδοτημένο", "unknown": "Παρουσιάστηκε ένα άγνωστο σφάλμα", - "noPermissionToAction": "Δεν έχετε την άδεια να εκτελέσετε την τρέχουσα ενέργεια." + "noPermissionToAction": "Δεν έχετε την άδεια να εκτελέσετε την τρέχουσα ενέργεια.", + "deletion_disabled": "Η διαγραφή είναι απενεργοποιημένη για αυτήν την εγκατάσταση." }, "user": { "notFound": "Ο χρήστης δεν βρέθηκε", @@ -58,5 +59,19 @@ "invalidFilePath": "Μη έγκυρη διαδρομή αρχείου", "fileNotFound": "Το αρχείο δεν βρέθηκε", "downloadError": "Σφάλμα κατά τη λήψη του αρχείου" + }, + "apiKeys": { + "generateSuccess": "Το κλειδί API δημιουργήθηκε με επιτυχία.", + "deleteSuccess": "Το κλειδί API διαγράφηκε με επιτυχία." + }, + "api": { + "requestBodyInvalid": "Μη έγκυρο σώμα αιτήματος." + }, + "upload": { + "invalid_request": "Το αίτημα αποστολής είναι μη έγκυρο ή κακοσχηματισμένο.", + "stream_error": "Παρουσιάστηκε σφάλμα κατά τη λήψη του αρχείου. Παρακαλώ δοκιμάστε ξανά.", + "parse_error": "Αποτυχία επεξεργασίας των δεδομένων του αναρτημένου αρχείου.", + "storage_error": "Αποτυχία αποθήκευσης του αναρτημένου αρχείου. Παρακαλώ δοκιμάστε ξανά.", + "connection_error": "Η σύνδεση χάθηκε κατά τη διάρκεια της αποστολής." } } diff --git a/packages/backend/src/locales/es/translation.json b/packages/backend/src/locales/es/translation.json index d35be87..6c41ccd 100644 --- a/packages/backend/src/locales/es/translation.json +++ b/packages/backend/src/locales/es/translation.json @@ -14,7 +14,8 @@ "demoMode": "Esta operación no está permitida en modo de demostración.", "unauthorized": "No autorizado", "unknown": "Ocurrió un error desconocido", - "noPermissionToAction": "No tienes permiso para realizar la acción actual." + "noPermissionToAction": "No tienes permiso para realizar la acción actual.", + "deletion_disabled": "La eliminación está deshabilitada para esta instancia." }, "user": { "notFound": "Usuario no encontrado", @@ -58,5 +59,19 @@ "invalidFilePath": "Ruta de archivo no válida", "fileNotFound": "Archivo no encontrado", "downloadError": "Error al descargar el archivo" + }, + "apiKeys": { + "generateSuccess": "Clave de API generada correctamente.", + "deleteSuccess": "Clave de API eliminada correctamente." + }, + "api": { + "requestBodyInvalid": "El cuerpo de la solicitud no es válido." + }, + "upload": { + "invalid_request": "La solicitud de carga no es válida o está malformada.", + "stream_error": "Se produjo un error al recibir el archivo. Por favor, inténtalo de nuevo.", + "parse_error": "No se pudieron procesar los datos del archivo cargado.", + "storage_error": "No se pudo guardar el archivo cargado. Por favor, inténtalo de nuevo.", + "connection_error": "La conexión se perdió durante la carga." } } diff --git a/packages/backend/src/locales/et/translation.json b/packages/backend/src/locales/et/translation.json index ee545f1..c4f4738 100644 --- a/packages/backend/src/locales/et/translation.json +++ b/packages/backend/src/locales/et/translation.json @@ -14,7 +14,8 @@ "demoMode": "See toiming pole demorežiimis lubatud.", "unauthorized": "Volitamata", "unknown": "Ilmnes tundmatu viga", - "noPermissionToAction": "Teil pole praeguse toimingu tegemiseks luba." + "noPermissionToAction": "Teil pole praeguse toimingu tegemiseks luba.", + "deletion_disabled": "Kustutamine on selle eksemplari jaoks keelatud." }, "user": { "notFound": "Kasutajat ei leitud", @@ -58,5 +59,19 @@ "invalidFilePath": "Kehtetu faili tee", "fileNotFound": "Faili ei leitud", "downloadError": "Faili allalaadimisel ilmnes viga" + }, + "apiKeys": { + "generateSuccess": "API-võti genereeriti edukalt.", + "deleteSuccess": "API-võti kustutati edukalt." + }, + "api": { + "requestBodyInvalid": "Vigane päringu sisu." + }, + "upload": { + "invalid_request": "Üleslaadimispäring on vigane või valesti vormindatud.", + "stream_error": "Faili vastuvõtmisel ilmnes viga. Palun proovige uuesti.", + "parse_error": "Üleslaaditud faili andmeid ei õnnestunud töödelda.", + "storage_error": "Üleslaaditud faili salvestamine ebaõnnestus. Palun proovige uuesti.", + "connection_error": "Ühendus katkes üleslaadimise ajal." } } diff --git a/packages/backend/src/locales/fr/translation.json b/packages/backend/src/locales/fr/translation.json index 6984a2e..c38dc35 100644 --- a/packages/backend/src/locales/fr/translation.json +++ b/packages/backend/src/locales/fr/translation.json @@ -14,7 +14,8 @@ "demoMode": "Cette opération n'est pas autorisée en mode démo.", "unauthorized": "Non autorisé", "unknown": "Une erreur inconnue s'est produite", - "noPermissionToAction": "Vous n'avez pas la permission d'effectuer l'action en cours." + "noPermissionToAction": "Vous n'avez pas la permission d'effectuer l'action en cours.", + "deletion_disabled": "La suppression est désactivée pour cette instance." }, "user": { "notFound": "Utilisateur non trouvé", @@ -58,5 +59,19 @@ "invalidFilePath": "Chemin de fichier invalide", "fileNotFound": "Fichier non trouvé", "downloadError": "Erreur lors du téléchargement du fichier" + }, + "apiKeys": { + "generateSuccess": "Clé API générée avec succès.", + "deleteSuccess": "Clé API supprimée avec succès." + }, + "api": { + "requestBodyInvalid": "Corps de la requête invalide." + }, + "upload": { + "invalid_request": "La demande d'envoi est invalide ou malformée.", + "stream_error": "Une erreur s'est produite lors de la réception du fichier. Veuillez réessayer.", + "parse_error": "Impossible de traiter les données du fichier envoyé.", + "storage_error": "Impossible d'enregistrer le fichier envoyé. Veuillez réessayer.", + "connection_error": "La connexion a été perdue pendant l'envoi." } } diff --git a/packages/backend/src/locales/it/translation.json b/packages/backend/src/locales/it/translation.json index 1ce882e..d6834a8 100644 --- a/packages/backend/src/locales/it/translation.json +++ b/packages/backend/src/locales/it/translation.json @@ -5,7 +5,7 @@ "alreadyCompleted": "La configurazione è già stata completata." }, "login": { - "emailAndPasswordRequired": "Email and password are required", + "emailAndPasswordRequired": "Email e password sono obbligatorie", "invalidCredentials": "Credenziali non valide" } }, @@ -14,7 +14,8 @@ "demoMode": "Questa operazione non è consentita in modalità demo.", "unauthorized": "Non autorizzato", "unknown": "Si è verificato un errore sconosciuto", - "noPermissionToAction": "Non hai il permesso di eseguire l'azione corrente." + "noPermissionToAction": "Non hai il permesso di eseguire l'azione corrente.", + "deletion_disabled": "L'eliminazione è disabilitata per questa istanza." }, "user": { "notFound": "Utente non trovato", @@ -58,5 +59,19 @@ "invalidFilePath": "Percorso del file non valido", "fileNotFound": "File non trovato", "downloadError": "Errore durante il download del file" + }, + "apiKeys": { + "generateSuccess": "Chiave API generata con successo.", + "deleteSuccess": "Chiave API eliminata con successo." + }, + "api": { + "requestBodyInvalid": "Corpo della richiesta non valido." + }, + "upload": { + "invalid_request": "La richiesta di caricamento non è valida o è malformata.", + "stream_error": "Si è verificato un errore durante la ricezione del file. Si prega di riprovare.", + "parse_error": "Impossibile elaborare i dati del file caricato.", + "storage_error": "Impossibile salvare il file caricato. Si prega di riprovare.", + "connection_error": "La connessione è stata persa durante il caricamento." } } diff --git a/packages/backend/src/locales/ja/translation.json b/packages/backend/src/locales/ja/translation.json index fc67aa3..87c3a9c 100644 --- a/packages/backend/src/locales/ja/translation.json +++ b/packages/backend/src/locales/ja/translation.json @@ -14,7 +14,8 @@ "demoMode": "この操作はデモモードでは許可されていません。", "unauthorized": "不正なアクセス", "unknown": "不明なエラーが発生しました", - "noPermissionToAction": "現在の操作を実行する権限がありません。" + "noPermissionToAction": "現在の操作を実行する権限がありません。", + "deletion_disabled": "このインスタンスでは削除が無効になっています。" }, "user": { "notFound": "ユーザーが見つかりません", @@ -58,5 +59,19 @@ "invalidFilePath": "無効なファイルパス", "fileNotFound": "ファイルが見つかりません", "downloadError": "ファイルのダウンロード中にエラーが発生しました" + }, + "apiKeys": { + "generateSuccess": "APIキーが正常に生成されました。", + "deleteSuccess": "APIキーが正常に削除されました。" + }, + "api": { + "requestBodyInvalid": "リクエストボディが無効です。" + }, + "upload": { + "invalid_request": "アップロードリクエストが無効または不正な形式です。", + "stream_error": "ファイルの受信中にエラーが発生しました。もう一度お試しください。", + "parse_error": "アップロードされたファイルのデータを処理できませんでした。", + "storage_error": "アップロードされたファイルを保存できませんでした。もう一度お試しください。", + "connection_error": "アップロード中に接続が切断されました。" } } diff --git a/packages/backend/src/locales/nl/translation.json b/packages/backend/src/locales/nl/translation.json index 72197ad..b7cb015 100644 --- a/packages/backend/src/locales/nl/translation.json +++ b/packages/backend/src/locales/nl/translation.json @@ -14,7 +14,8 @@ "demoMode": "Deze bewerking is niet toegestaan in de demomodus.", "unauthorized": "Ongeautoriseerd", "unknown": "Er is een onbekende fout opgetreden", - "noPermissionToAction": "U heeft geen toestemming om de huidige actie uit te voeren." + "noPermissionToAction": "U heeft geen toestemming om de huidige actie uit te voeren.", + "deletion_disabled": "Verwijderen is uitgeschakeld voor deze instantie." }, "user": { "notFound": "Gebruiker niet gevonden", @@ -58,5 +59,19 @@ "invalidFilePath": "Ongeldig bestandspad", "fileNotFound": "Bestand niet gevonden", "downloadError": "Fout bij het downloaden van het bestand" + }, + "apiKeys": { + "generateSuccess": "API-sleutel succesvol gegenereerd.", + "deleteSuccess": "API-sleutel succesvol verwijderd." + }, + "api": { + "requestBodyInvalid": "Ongeldige inhoud van het verzoek." + }, + "upload": { + "invalid_request": "Het uploadverzoek is ongeldig of onjuist geformatteerd.", + "stream_error": "Er is een fout opgetreden bij het ontvangen van het bestand. Probeer het opnieuw.", + "parse_error": "De gegevens van het geüploade bestand konden niet worden verwerkt.", + "storage_error": "Het geüploade bestand kon niet worden opgeslagen. Probeer het opnieuw.", + "connection_error": "De verbinding is verbroken tijdens het uploaden." } } diff --git a/packages/backend/src/locales/pt/translation.json b/packages/backend/src/locales/pt/translation.json index bf2c253..c586167 100644 --- a/packages/backend/src/locales/pt/translation.json +++ b/packages/backend/src/locales/pt/translation.json @@ -14,7 +14,8 @@ "demoMode": "Esta operação não é permitida no modo de demonstração.", "unauthorized": "Não autorizado", "unknown": "Ocorreu um erro desconhecido", - "noPermissionToAction": "Você não tem permissão para executar a ação atual." + "noPermissionToAction": "Você não tem permissão para executar a ação atual.", + "deletion_disabled": "A exclusão está desabilitada para esta instância." }, "user": { "notFound": "Usuário não encontrado", @@ -58,5 +59,19 @@ "invalidFilePath": "Caminho de arquivo inválido", "fileNotFound": "Arquivo não encontrado", "downloadError": "Erro ao baixar o arquivo" + }, + "apiKeys": { + "generateSuccess": "Chave de API gerada com sucesso.", + "deleteSuccess": "Chave de API excluída com sucesso." + }, + "api": { + "requestBodyInvalid": "Corpo da requisição inválido." + }, + "upload": { + "invalid_request": "A requisição de envio é inválida ou malformada.", + "stream_error": "Ocorreu um erro ao receber o arquivo. Por favor, tente novamente.", + "parse_error": "Não foi possível processar os dados do arquivo enviado.", + "storage_error": "Não foi possível salvar o arquivo enviado. Por favor, tente novamente.", + "connection_error": "A conexão foi perdida durante o envio." } } diff --git a/packages/backend/src/services/ArchivedEmailService.ts b/packages/backend/src/services/ArchivedEmailService.ts index ec7518f..8341fe4 100644 --- a/packages/backend/src/services/ArchivedEmailService.ts +++ b/packages/backend/src/services/ArchivedEmailService.ts @@ -1,4 +1,4 @@ -import { count, desc, eq, asc, and } from 'drizzle-orm'; +import { count, desc, eq, asc, and, inArray } from 'drizzle-orm'; import { db } from '../database'; import { archivedEmails, @@ -16,11 +16,13 @@ import type { } from '@open-archiver/types'; import { StorageService } from './StorageService'; import { SearchService } from './SearchService'; +import { IngestionService } from './IngestionService'; import type { Readable } from 'stream'; import { AuditService } from './AuditService'; import { User } from '@open-archiver/types'; import { checkDeletionEnabled } from '../helpers/deletionGuard'; import { RetentionHook } from '../hooks/RetentionHook'; +import { logger } from '../config/logger'; interface DbRecipients { to: { name: string; address: string }[]; @@ -58,7 +60,14 @@ export class ArchivedEmailService { ): Promise { const offset = (page - 1) * limit; const { drizzleFilter } = await FilterBuilder.create(userId, 'archive', 'read'); - const where = and(eq(archivedEmails.ingestionSourceId, ingestionSourceId), drizzleFilter); + + // Expand to the full merge group so emails from children appear when browsing a root source + const groupIds = await IngestionService.findGroupSourceIds(ingestionSourceId); + const sourceFilter = + groupIds.length === 1 + ? eq(archivedEmails.ingestionSourceId, groupIds[0]) + : inArray(archivedEmails.ingestionSourceId, groupIds); + const where = and(sourceFilter, drizzleFilter); const countQuery = db .select({ @@ -136,12 +145,15 @@ export class ArchivedEmailService { let threadEmails: ThreadEmail[] = []; + // Expand thread query to the full merge group so threads can span across merged sources if (email.threadId) { + const groupIds = await IngestionService.findGroupSourceIds(email.ingestionSourceId); + const sourceFilter = + groupIds.length === 1 + ? eq(archivedEmails.ingestionSourceId, groupIds[0]) + : inArray(archivedEmails.ingestionSourceId, groupIds); threadEmails = await db.query.archivedEmails.findMany({ - where: and( - eq(archivedEmails.threadId, email.threadId), - eq(archivedEmails.ingestionSourceId, email.ingestionSourceId) - ), + where: and(eq(archivedEmails.threadId, email.threadId), sourceFilter), orderBy: [asc(archivedEmails.sentAt)], columns: { id: true, @@ -263,7 +275,13 @@ export class ArchivedEmailService { } } } catch (error) { - console.error('Failed to delete email attachments', error); + logger.error( + { + emailId, + error: error instanceof Error ? error.message : String(error), + }, + 'Failed to delete email attachments' + ); throw new Error('Failed to delete email attachments'); } } diff --git a/packages/backend/src/services/EmailProviderFactory.ts b/packages/backend/src/services/EmailProviderFactory.ts index 42bd8ad..d52d592 100644 --- a/packages/backend/src/services/EmailProviderFactory.ts +++ b/packages/backend/src/services/EmailProviderFactory.ts @@ -17,6 +17,18 @@ import { PSTConnector } from './ingestion-connectors/PSTConnector'; import { EMLConnector } from './ingestion-connectors/EMLConnector'; import { MboxConnector } from './ingestion-connectors/MboxConnector'; +/** + * Options passed to connectors to control ingestion behaviour. + * Currently used to skip extracting full attachment binary content + * in preserve-original-file (GoBD) mode, where attachments are never + * stored separately and the raw EML is kept as-is. + */ +export interface ConnectorOptions { + /** When true, connectors omit attachment binary content from the + * yielded EmailObject to avoid unnecessary memory allocation. */ + preserveOriginalFile: boolean; +} + // Define a common interface for all connectors export interface IEmailConnector { testConnection(): Promise; @@ -34,20 +46,26 @@ export class EmailProviderFactory { static createConnector(source: IngestionSource): IEmailConnector { // Credentials are now decrypted by the IngestionService before being passed around const credentials = source.credentials; + const options: ConnectorOptions = { + preserveOriginalFile: source.preserveOriginalFile ?? false, + }; switch (source.provider) { case 'google_workspace': - return new GoogleWorkspaceConnector(credentials as GoogleWorkspaceCredentials); + return new GoogleWorkspaceConnector( + credentials as GoogleWorkspaceCredentials, + options + ); case 'microsoft_365': - return new MicrosoftConnector(credentials as Microsoft365Credentials); + return new MicrosoftConnector(credentials as Microsoft365Credentials, options); case 'generic_imap': - return new ImapConnector(credentials as GenericImapCredentials); + return new ImapConnector(credentials as GenericImapCredentials, options); case 'pst_import': - return new PSTConnector(credentials as PSTImportCredentials); + return new PSTConnector(credentials as PSTImportCredentials, options); case 'eml_import': - return new EMLConnector(credentials as EMLImportCredentials); + return new EMLConnector(credentials as EMLImportCredentials, options); case 'mbox_import': - return new MboxConnector(credentials as MboxImportCredentials); + return new MboxConnector(credentials as MboxImportCredentials, options); default: throw new Error(`Unsupported provider: ${source.provider}`); } diff --git a/packages/backend/src/services/IndexingService.ts b/packages/backend/src/services/IndexingService.ts index a2f847a..e954f73 100644 --- a/packages/backend/src/services/IndexingService.ts +++ b/packages/backend/src/services/IndexingService.ts @@ -12,7 +12,7 @@ import { DatabaseService } from './DatabaseService'; import { archivedEmails, attachments, emailAttachments } from '../database/schema'; import { eq } from 'drizzle-orm'; import { streamToBuffer } from '../helpers/streamToBuffer'; -import { simpleParser } from 'mailparser'; +import { simpleParser, type Attachment as ParsedAttachment } from 'mailparser'; import { logger } from '../config/logger'; interface DbRecipients { @@ -351,9 +351,13 @@ export class IndexingService { content: textContent, }); } catch (error) { - console.error( - `Failed to extract text from attachment: ${attachment.filename}`, - error + logger.error( + { + filename: attachment.filename, + mimeType: attachment.mimeType, + error: error instanceof Error ? error.message : String(error), + }, + 'Failed to extract text from attachment' ); } } @@ -378,8 +382,6 @@ export class IndexingService { attachments: Attachment[], userEmail: string //the owner of the email inbox ): Promise { - const attachmentContents = await this.extractAttachmentContents(attachments); - const emailBodyStream = await this.storageService.get(email.storagePath); const emailBodyBuffer = await streamToBuffer(emailBodyStream); const parsedEmail = await simpleParser(emailBodyBuffer); @@ -389,6 +391,20 @@ export class IndexingService { (await extractText(emailBodyBuffer, 'text/plain')) || ''; + // If there are linked attachment records, extract text from storage (default mode). + // Otherwise, if the email has attachments but no records (preserve original file mode), + // extract attachment text directly from the parsed EML body. + let attachmentContents: { filename: string; content: string }[]; + if (attachments.length > 0) { + attachmentContents = await this.extractAttachmentContents(attachments); + } else if (email.hasAttachments && parsedEmail.attachments.length > 0) { + attachmentContents = await this.extractInlineAttachmentContents( + parsedEmail.attachments + ); + } else { + attachmentContents = []; + } + const recipients = email.recipients as DbRecipients; // console.log('email.userEmail', email.userEmail); return { @@ -406,6 +422,40 @@ export class IndexingService { }; } + /** + * Extracts text content from attachments embedded in the parsed EML. + * Used in preserve-original-file (GoBD) mode where no separate attachment + * records exist — the full MIME body is stored unmodified, so we parse + * attachments directly from the in-memory parsed email. + */ + private async extractInlineAttachmentContents( + parsedAttachments: ParsedAttachment[] + ): Promise<{ filename: string; content: string }[]> { + const extracted: { filename: string; content: string }[] = []; + for (const attachment of parsedAttachments) { + try { + const textContent = await extractText( + attachment.content, + attachment.contentType || '' + ); + extracted.push({ + filename: attachment.filename || 'untitled', + content: textContent, + }); + } catch (error) { + logger.warn( + { + filename: attachment.filename, + mimeType: attachment.contentType, + error: error instanceof Error ? error.message : String(error), + }, + 'Failed to extract text from inline attachment in preserve-original mode' + ); + } + } + return extracted; + } + private async extractAttachmentContents( attachments: Attachment[] ): Promise<{ filename: string; content: string }[]> { diff --git a/packages/backend/src/services/IngestionService.ts b/packages/backend/src/services/IngestionService.ts index e3388af..b836cb0 100644 --- a/packages/backend/src/services/IngestionService.ts +++ b/packages/backend/src/services/IngestionService.ts @@ -8,7 +8,7 @@ import type { IngestionProvider, PendingEmail, } from '@open-archiver/types'; -import { and, desc, eq, or } from 'drizzle-orm'; +import { and, desc, eq, inArray, or } from 'drizzle-orm'; import { CryptoService } from './CryptoService'; import { EmailProviderFactory } from './EmailProviderFactory'; import { ingestionQueue } from '../jobs/queues'; @@ -22,6 +22,7 @@ import { emailAttachments, } from '../database/schema'; import { createHash, randomUUID } from 'crypto'; +import { readFile, unlink } from 'fs/promises'; import { logger } from '../config/logger'; import { SearchService } from './SearchService'; import { config } from '../config/index'; @@ -60,14 +61,22 @@ export class IngestionService { actor: User, actorIp: string ): Promise { - const { providerConfig, ...rest } = dto; + const { providerConfig, mergedIntoId, ...rest } = dto; const encryptedCredentials = CryptoService.encryptObject(providerConfig); + // Resolve merge target: if mergedIntoId points to a child, follow to the root. + let resolvedMergedIntoId: string | undefined; + if (mergedIntoId) { + const target = await this.findById(mergedIntoId); + resolvedMergedIntoId = target.mergedIntoId ?? target.id; + } + const valuesToInsert = { userId, ...rest, status: 'pending_auth' as const, credentials: encryptedCredentials, + mergedIntoId: resolvedMergedIntoId ?? null, }; const [newSource] = await db.insert(ingestionSources).values(valuesToInsert).returning(); @@ -206,6 +215,60 @@ export class IngestionService { return decryptedSource; } + /** + * Returns all ingestionSourceId values in a merge group given any member's ID. + * If the source is standalone (no parent, no children), returns just its own ID. + */ + public static async findGroupSourceIds(sourceId: string): Promise { + const source = await this.findById(sourceId); + const rootId = source.mergedIntoId ?? source.id; + + const children = await db + .select({ id: ingestionSources.id }) + .from(ingestionSources) + .where(eq(ingestionSources.mergedIntoId, rootId)); + + return [rootId, ...children.map((c) => c.id)]; + } + + /** + * Detaches a child source from its merge group, making it standalone. + */ + public static async unmerge( + id: string, + actor: User, + actorIp: string + ): Promise { + const source = await this.findById(id); + if (!source.mergedIntoId) { + throw new Error('Source is not merged into another source.'); + } + + const [updated] = await db + .update(ingestionSources) + .set({ mergedIntoId: null }) + .where(eq(ingestionSources.id, id)) + .returning(); + + await this.auditService.createAuditLog({ + actorIdentifier: actor.id, + actionType: 'UPDATE', + targetType: 'IngestionSource', + targetId: id, + actorIp, + details: { + action: 'unmerge', + previousParentId: source.mergedIntoId, + }, + }); + + const decrypted = this.decryptSource(updated); + if (!decrypted) { + throw new Error('Failed to decrypt unmerged source.'); + } + return decrypted; + } + public static async delete( id: string, actor: User, @@ -220,6 +283,18 @@ export class IngestionService { throw new Error('Ingestion source not found'); } + // If this is a root source with children, delete all children first + if (!source.mergedIntoId) { + const children = await db + .select({ id: ingestionSources.id }) + .from(ingestionSources) + .where(eq(ingestionSources.mergedIntoId, id)); + + for (const child of children) { + await this.delete(child.id, actor, actorIp, force); + } + } + // Delete all emails and attachments from storage const storage = new StorageService(); const emailPath = `${config.storage.openArchiverFolderName}/${source.name.replaceAll(' ', '-')}-${source.id}/`; @@ -326,6 +401,32 @@ export class IngestionService { }); await ingestionQueue.add('continuous-sync', { ingestionSourceId: source.id }); + + // If this is a root source, also trigger sync for all non-file-based active/error children + if (!source.mergedIntoId) { + const fileBasedProviders = this.returnFileBasedIngestions(); + const children = await db + .select({ + id: ingestionSources.id, + provider: ingestionSources.provider, + status: ingestionSources.status, + }) + .from(ingestionSources) + .where(eq(ingestionSources.mergedIntoId, id)); + + for (const child of children) { + if ( + !fileBasedProviders.includes(child.provider) && + (child.status === 'active' || child.status === 'error') + ) { + logger.info( + { childId: child.id, parentId: id }, + 'Cascading force sync to child source.' + ); + await ingestionQueue.add('continuous-sync', { ingestionSourceId: child.id }); + } + } + } } public static async performBulkImport( @@ -395,14 +496,23 @@ export class IngestionService { * 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). + * + * The check is scoped to the full merge group so that emails already archived + * by a sibling source are not re-downloaded and stored again. */ public static async doesEmailExist( messageId: string, ingestionSourceId: string ): Promise { + const groupIds = await this.findGroupSourceIds(ingestionSourceId); + const sourceFilter = + groupIds.length === 1 + ? eq(archivedEmails.ingestionSourceId, groupIds[0]) + : inArray(archivedEmails.ingestionSourceId, groupIds); + const existingEmail = await db.query.archivedEmails.findFirst({ where: and( - eq(archivedEmails.ingestionSourceId, ingestionSourceId), + sourceFilter, or( eq(archivedEmails.providerMessageId, messageId), eq(archivedEmails.messageIdHeader, messageId) @@ -420,6 +530,16 @@ export class IngestionService { userEmail: string ): Promise { try { + // Read the raw bytes from the temp file written by the connector + const rawEmlBuffer = await readFile(email.tempFilePath); + + // If this source is a child in a merge group, redirect all storage and DB + // ownership to the root source. Child sources are "assistants" — they fetch + // emails on behalf of the root but never own any stored content. + const effectiveSource = source.mergedIntoId + ? await IngestionService.findById(source.mergedIntoId) + : source; + // Generate a unique message ID for the email. If the email already has a message-id header, use that. // Otherwise, generate a new one based on the email's hash, source ID, and email ID. const messageIdHeader = email.headers.get('message-id'); @@ -431,15 +551,20 @@ export class IngestionService { } if (!messageId) { messageId = `generated-${createHash('sha256') - .update(email.eml ?? Buffer.from(email.body, 'utf-8')) + .update(rawEmlBuffer) .digest('hex')}-${source.id}-${email.id}`; } - // Check if an email with the same message ID has already been imported for the current ingestion source. This is to prevent duplicate imports when an email is present in multiple mailboxes (e.g., "Inbox" and "All Mail"). + // Check if an email with the same message ID has already been imported + // within the merge group. This prevents duplicate imports when the same + // email exists in multiple mailboxes or across merged ingestion sources. + const groupIds = await IngestionService.findGroupSourceIds(source.id); + const groupSourceFilter = + groupIds.length === 1 + ? eq(archivedEmails.ingestionSourceId, groupIds[0]) + : inArray(archivedEmails.ingestionSourceId, groupIds); + const existingEmail = await db.query.archivedEmails.findFirst({ - where: and( - eq(archivedEmails.messageIdHeader, messageId), - eq(archivedEmails.ingestionSourceId, source.id) - ), + where: and(eq(archivedEmails.messageIdHeader, messageId), groupSourceFilter), }); if (existingEmail) { @@ -450,19 +575,81 @@ export class IngestionService { return null; } - const rawEmlBuffer = email.eml ?? Buffer.from(email.body, 'utf-8'); - // Strip non-inline attachments from the .eml to avoid double-storing + const sanitizedPath = email.path ? email.path : ''; + // Use effectiveSource (root) for storage path and DB ownership. + // Child sources are assistants; all content physically belongs to the root. + const emailPath = `${config.storage.openArchiverFolderName}/${effectiveSource.name.replaceAll(' ', '-')}-${effectiveSource.id}/emails/${sanitizedPath}${email.id}.eml`; + + // GoBD / Preserve Original File mode: store the unmodified raw EML as-is. + // No attachment stripping, no attachment table records — the full MIME body + // including attachments is preserved in the single .eml file. + // Use the root (effectiveSource) compliance mode as authoritative. + if (effectiveSource.preserveOriginalFile) { + const emailHash = createHash('sha256').update(rawEmlBuffer).digest('hex'); + + // Message-level deduplication by file hash, scoped to the effective (root) source + const hashDuplicate = await db.query.archivedEmails.findFirst({ + where: and( + eq(archivedEmails.storageHashSha256, emailHash), + eq(archivedEmails.ingestionSourceId, effectiveSource.id) + ), + columns: { id: true }, + }); + + if (hashDuplicate) { + logger.info( + { emailHash, ingestionSourceId: effectiveSource.id }, + 'Skipping duplicate email (hash-level dedup, preserve original mode)' + ); + return null; + } + + // Store the unmodified raw buffer — no modifications + await storage.put(emailPath, rawEmlBuffer); + + const [archivedEmail] = await db + .insert(archivedEmails) + .values({ + // Always assign to root (effectiveSource) + ingestionSourceId: effectiveSource.id, + userEmail, + threadId: email.threadId, + messageIdHeader: messageId, + providerMessageId: email.id, + sentAt: email.receivedAt, + subject: email.subject, + senderName: email.from[0]?.name, + senderEmail: email.from[0]?.address, + recipients: { + to: email.to, + cc: email.cc, + bcc: email.bcc, + }, + storagePath: emailPath, + storageHashSha256: emailHash, + sizeBytes: rawEmlBuffer.length, + hasAttachments: email.attachments.length > 0, + path: email.path, + tags: email.tags, + }) + .returning(); + + return { + archivedEmailId: archivedEmail.id, + }; + } + + // Default mode: 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`; await storage.put(emailPath, emlBuffer); const [archivedEmail] = await db .insert(archivedEmails) .values({ - ingestionSourceId: source.id, + // Always assign to root (effectiveSource) + ingestionSourceId: effectiveSource.id, userEmail, threadId: email.threadId, messageIdHeader: messageId, @@ -492,54 +679,45 @@ export class IngestionService { .update(attachmentBuffer) .digest('hex'); - // Check if an attachment with the same hash already exists for this source + // Check if an attachment with the same hash already exists for the root source const existingAttachment = await db.query.attachments.findFirst({ where: and( eq(attachmentsSchema.contentHashSha256, attachmentHash), - eq(attachmentsSchema.ingestionSourceId, source.id) + eq(attachmentsSchema.ingestionSourceId, effectiveSource.id) ), }); - let storagePath: string; + let attachmentId: string; if (existingAttachment) { - // If it exists, reuse the storage path and don't save the file again - storagePath = existingAttachment.storagePath; + attachmentId = existingAttachment.id; logger.info( { attachmentHash, - ingestionSourceId: source.id, - reusedPath: storagePath, + ingestionSourceId: effectiveSource.id, + reusedPath: existingAttachment.storagePath, }, 'Reusing existing attachment file for deduplication.' ); } else { - // If it's a new attachment, create a unique path and save it + // New attachment: store under the root source's folder const uniqueId = randomUUID().slice(0, 7); - storagePath = `${config.storage.openArchiverFolderName}/${source.name.replaceAll(' ', '-')}-${source.id}/attachments/${uniqueId}-${attachment.filename}`; - await storage.put(storagePath, attachmentBuffer); - } - - let attachmentRecord = existingAttachment; - - if (!attachmentRecord) { - // If it's a new attachment, create a unique path and save it - const uniqueId = randomUUID().slice(0, 5); - const storagePath = `${config.storage.openArchiverFolderName}/${source.name.replaceAll(' ', '-')}-${source.id}/attachments/${uniqueId}-${attachment.filename}`; + const storagePath = `${config.storage.openArchiverFolderName}/${effectiveSource.name.replaceAll(' ', '-')}-${effectiveSource.id}/attachments/${uniqueId}-${attachment.filename}`; await storage.put(storagePath, attachmentBuffer); - // Insert a new attachment record - [attachmentRecord] = await db + const [newRecord] = await db .insert(attachmentsSchema) .values({ filename: attachment.filename, mimeType: attachment.contentType, sizeBytes: attachment.size, contentHashSha256: attachmentHash, - storagePath: storagePath, - ingestionSourceId: source.id, + storagePath, + // Always assign attachment ownership to root (effectiveSource) + ingestionSourceId: effectiveSource.id, }) .returning(); + attachmentId = newRecord.id; } // Link the attachment record (either new or existing) to the email @@ -547,7 +725,7 @@ export class IngestionService { .insert(emailAttachments) .values({ emailId: archivedEmail.id, - attachmentId: attachmentRecord.id, + attachmentId, }) .onConflictDoNothing(); } @@ -564,6 +742,14 @@ export class IngestionService { ingestionSourceId: source.id, }); return null; + } finally { + // Always clean up the temp file, regardless of success or failure + await unlink(email.tempFilePath).catch((err) => + logger.warn( + { err, tempFilePath: email.tempFilePath }, + 'Failed to delete temp email file' + ) + ); } } } diff --git a/packages/backend/src/services/SearchService.ts b/packages/backend/src/services/SearchService.ts index 3d784be..c8baf59 100644 --- a/packages/backend/src/services/SearchService.ts +++ b/packages/backend/src/services/SearchService.ts @@ -9,6 +9,7 @@ import type { } from '@open-archiver/types'; import { FilterBuilder } from './FilterBuilder'; import { AuditService } from './AuditService'; +import { IngestionService } from './IngestionService'; export class SearchService { private client: MeiliSearch; @@ -75,13 +76,24 @@ export class SearchService { }; if (filters) { - const filterStrings = Object.entries(filters).map(([key, value]) => { - if (typeof value === 'string') { - return `${key} = '${value}'`; + const filterParts: string[] = []; + for (const [key, value] of Object.entries(filters)) { + // Expand ingestionSourceId to the full merge group + if (key === 'ingestionSourceId' && typeof value === 'string') { + const groupIds = await IngestionService.findGroupSourceIds(value); + if (groupIds.length === 1) { + filterParts.push(`ingestionSourceId = '${groupIds[0]}'`); + } else { + const inList = groupIds.map((id) => `'${id}'`).join(', '); + filterParts.push(`ingestionSourceId IN [${inList}]`); + } + } else if (typeof value === 'string') { + filterParts.push(`${key} = '${value}'`); + } else { + filterParts.push(`${key} = ${value}`); } - return `${key} = ${value}`; - }); - searchParams.filter = filterStrings.join(' AND '); + } + searchParams.filter = filterParts.join(' AND '); } // Create a filter based on the user's permissions. diff --git a/packages/backend/src/services/ingestion-connectors/EMLConnector.ts b/packages/backend/src/services/ingestion-connectors/EMLConnector.ts index 81aadac..bd86c14 100644 --- a/packages/backend/src/services/ingestion-connectors/EMLConnector.ts +++ b/packages/backend/src/services/ingestion-connectors/EMLConnector.ts @@ -5,10 +5,11 @@ import type { SyncState, MailboxUser, } from '@open-archiver/types'; -import type { IEmailConnector } from '../EmailProviderFactory'; +import type { IEmailConnector, ConnectorOptions } from '../EmailProviderFactory'; import { simpleParser, ParsedMail, Attachment, AddressObject } from 'mailparser'; import { logger } from '../../config/logger'; import { getThreadId } from './helpers/utils'; +import { writeEmailToTempFile } from './helpers/tempFile'; import { StorageService } from '../StorageService'; import { Readable } from 'stream'; import { createHash } from 'crypto'; @@ -27,8 +28,13 @@ const streamToBuffer = (stream: Readable): Promise => { export class EMLConnector implements IEmailConnector { private storage: StorageService; + private options: ConnectorOptions; - constructor(private credentials: EMLImportCredentials) { + constructor( + private credentials: EMLImportCredentials, + options?: ConnectorOptions + ) { + this.options = options ?? { preserveOriginalFile: false }; this.storage = new StorageService(); } @@ -266,13 +272,18 @@ export class EMLConnector implements IEmailConnector { emlBuffer = await streamToBuffer(input); } + const tempFilePath = await writeEmailToTempFile(emlBuffer); const parsedEmail: ParsedMail = await simpleParser(emlBuffer); + // In preserve-original mode, skip extracting full attachment binary content + // to avoid unnecessary memory allocation — the raw EML on disk is the source of truth. const attachments = parsedEmail.attachments.map((attachment: Attachment) => ({ filename: attachment.filename || 'untitled', contentType: attachment.contentType, size: attachment.size, - content: attachment.content as Buffer, + content: this.options.preserveOriginalFile + ? Buffer.alloc(0) + : (attachment.content as Buffer), })); const mapAddresses = ( @@ -313,7 +324,7 @@ export class EMLConnector implements IEmailConnector { headers: parsedEmail.headers, attachments, receivedAt: parsedEmail.date || new Date(), - eml: emlBuffer, + tempFilePath, path, }; } diff --git a/packages/backend/src/services/ingestion-connectors/GoogleWorkspaceConnector.ts b/packages/backend/src/services/ingestion-connectors/GoogleWorkspaceConnector.ts index cd704f1..ca16f8b 100644 --- a/packages/backend/src/services/ingestion-connectors/GoogleWorkspaceConnector.ts +++ b/packages/backend/src/services/ingestion-connectors/GoogleWorkspaceConnector.ts @@ -7,10 +7,11 @@ import type { SyncState, MailboxUser, } from '@open-archiver/types'; -import type { IEmailConnector } from '../EmailProviderFactory'; +import type { IEmailConnector, ConnectorOptions } from '../EmailProviderFactory'; import { logger } from '../../config/logger'; import { simpleParser, ParsedMail, Attachment, AddressObject, Headers } from 'mailparser'; import { getThreadId } from './helpers/utils'; +import { writeEmailToTempFile } from './helpers/tempFile'; /** * A connector for Google Workspace that uses a service account with domain-wide delegation @@ -20,9 +21,11 @@ export class GoogleWorkspaceConnector implements IEmailConnector { private credentials: GoogleWorkspaceCredentials; private serviceAccountCreds: { client_email: string; private_key: string }; private newHistoryId: string | undefined; + private options: ConnectorOptions; - constructor(credentials: GoogleWorkspaceCredentials) { + constructor(credentials: GoogleWorkspaceCredentials, options?: ConnectorOptions) { this.credentials = credentials; + this.options = options ?? { preserveOriginalFile: false }; try { // Pre-parse the JSON key to catch errors early. const parsedKey = JSON.parse(this.credentials.serviceAccountKeyJson); @@ -201,48 +204,13 @@ export class GoogleWorkspaceConnector implements IEmailConnector { if (msgResponse.data.raw) { const rawEmail = Buffer.from(msgResponse.data.raw, 'base64url'); - const parsedEmail: ParsedMail = await simpleParser(rawEmail); - const attachments = parsedEmail.attachments.map( - (attachment: Attachment) => ({ - filename: attachment.filename || 'untitled', - contentType: attachment.contentType, - size: attachment.size, - content: attachment.content as Buffer, - }) + yield this.parseRawEmail( + rawEmail, + msgResponse.data.id!, + userEmail, + labels.path, + labels.tags ); - const mapAddresses = ( - addresses: AddressObject | AddressObject[] | undefined - ): EmailAddress[] => { - if (!addresses) return []; - const addressArray = Array.isArray(addresses) - ? addresses - : [addresses]; - return addressArray.flatMap((a) => - a.value.map((v) => ({ - name: v.name, - address: v.address || '', - })) - ); - }; - const threadId = getThreadId(parsedEmail.headers); - yield { - id: msgResponse.data.id!, - threadId, - userEmail: userEmail, - eml: rawEmail, - from: mapAddresses(parsedEmail.from), - to: mapAddresses(parsedEmail.to), - cc: mapAddresses(parsedEmail.cc), - bcc: mapAddresses(parsedEmail.bcc), - subject: parsedEmail.subject || '', - body: parsedEmail.text || '', - html: parsedEmail.html || '', - headers: parsedEmail.headers, - attachments, - receivedAt: parsedEmail.date || new Date(), - path: labels.path, - tags: labels.tags, - }; } } catch (error: any) { if (error.code === 404) { @@ -326,45 +294,13 @@ export class GoogleWorkspaceConnector implements IEmailConnector { if (msgResponse.data.raw) { const rawEmail = Buffer.from(msgResponse.data.raw, 'base64url'); - const parsedEmail: ParsedMail = await simpleParser(rawEmail); - const attachments = parsedEmail.attachments.map( - (attachment: Attachment) => ({ - filename: attachment.filename || 'untitled', - contentType: attachment.contentType, - size: attachment.size, - content: attachment.content as Buffer, - }) + yield this.parseRawEmail( + rawEmail, + msgResponse.data.id!, + userEmail, + labels.path, + labels.tags ); - const mapAddresses = ( - addresses: AddressObject | AddressObject[] | undefined - ): EmailAddress[] => { - if (!addresses) return []; - const addressArray = Array.isArray(addresses) - ? addresses - : [addresses]; - return addressArray.flatMap((a) => - a.value.map((v) => ({ name: v.name, address: v.address || '' })) - ); - }; - const threadId = getThreadId(parsedEmail.headers); - yield { - id: msgResponse.data.id!, - threadId, - userEmail: userEmail, - eml: rawEmail, - from: mapAddresses(parsedEmail.from), - to: mapAddresses(parsedEmail.to), - cc: mapAddresses(parsedEmail.cc), - bcc: mapAddresses(parsedEmail.bcc), - subject: parsedEmail.subject || '', - body: parsedEmail.text || '', - html: parsedEmail.html || '', - headers: parsedEmail.headers, - attachments, - receivedAt: parsedEmail.date || new Date(), - path: labels.path, - tags: labels.tags, - }; } } catch (error: any) { if (error.code === 404) { @@ -382,6 +318,63 @@ export class GoogleWorkspaceConnector implements IEmailConnector { } while (pageToken); } + /** + * Parses a raw email buffer into an EmailObject, extracting metadata via simpleParser. + * In preserve-original mode, attachment binary content is omitted to save memory. + */ + private async parseRawEmail( + rawEmail: Buffer, + messageId: string, + userEmail: string, + path: string, + tags: string[] + ): Promise { + const tempFilePath = await writeEmailToTempFile(rawEmail); + const parsedEmail: ParsedMail = await simpleParser(rawEmail); + + // In preserve-original mode, skip extracting full attachment binary content + // to avoid unnecessary memory allocation — the raw EML on disk is the source of truth. + const attachments = parsedEmail.attachments.map((attachment: Attachment) => ({ + filename: attachment.filename || 'untitled', + contentType: attachment.contentType, + size: attachment.size, + content: this.options.preserveOriginalFile + ? Buffer.alloc(0) + : (attachment.content as Buffer), + })); + + const mapAddresses = ( + addresses: AddressObject | AddressObject[] | undefined + ): EmailAddress[] => { + if (!addresses) return []; + const addressArray = Array.isArray(addresses) ? addresses : [addresses]; + return addressArray.flatMap((a) => + a.value.map((v) => ({ name: v.name, address: v.address || '' })) + ); + }; + + const threadId = getThreadId(parsedEmail.headers); + + return { + id: messageId, + threadId, + userEmail, + tempFilePath, + from: mapAddresses(parsedEmail.from), + to: mapAddresses(parsedEmail.to), + cc: mapAddresses(parsedEmail.cc), + bcc: mapAddresses(parsedEmail.bcc), + subject: parsedEmail.subject || '', + body: parsedEmail.text || '', + html: parsedEmail.html || '', + headers: parsedEmail.headers, + attachments, + receivedAt: parsedEmail.date || new Date(), + path, + tags, + }; + } + public getUpdatedSyncState(userEmail: string): SyncState { if (!this.newHistoryId) { return {}; diff --git a/packages/backend/src/services/ingestion-connectors/ImapConnector.ts b/packages/backend/src/services/ingestion-connectors/ImapConnector.ts index d843ed7..2764052 100644 --- a/packages/backend/src/services/ingestion-connectors/ImapConnector.ts +++ b/packages/backend/src/services/ingestion-connectors/ImapConnector.ts @@ -5,19 +5,25 @@ import type { SyncState, MailboxUser, } from '@open-archiver/types'; -import type { IEmailConnector } from '../EmailProviderFactory'; +import type { IEmailConnector, ConnectorOptions } from '../EmailProviderFactory'; import { ImapFlow } from 'imapflow'; import { simpleParser, ParsedMail, Attachment, AddressObject, Headers } from 'mailparser'; import { config } from '../../config'; import { logger } from '../../config/logger'; import { getThreadId } from './helpers/utils'; +import { writeEmailToTempFile } from './helpers/tempFile'; export class ImapConnector implements IEmailConnector { private client: ImapFlow; private newMaxUids: { [mailboxPath: string]: number } = {}; private statusMessage: string | undefined; + private options: ConnectorOptions; - constructor(private credentials: GenericImapCredentials) { + constructor( + private credentials: GenericImapCredentials, + options?: ConnectorOptions + ) { + this.options = options ?? { preserveOriginalFile: false }; this.client = this.createClient(); } @@ -298,12 +304,21 @@ export class ImapConnector implements IEmailConnector { } private async parseMessage(msg: any, mailboxPath: string): Promise { + // Write raw bytes to temp file to keep large buffers off the JS heap + const tempFilePath = await writeEmailToTempFile(msg.source); + + // Parse only for metadata extraction (read-only) const parsedEmail: ParsedMail = await simpleParser(msg.source); + + // In preserve-original mode, skip extracting full attachment binary content + // to avoid unnecessary memory allocation — the raw EML on disk is the source of truth. const attachments = parsedEmail.attachments.map((attachment: Attachment) => ({ filename: attachment.filename || 'untitled', contentType: attachment.contentType, size: attachment.size, - content: attachment.content as Buffer, + content: this.options.preserveOriginalFile + ? Buffer.alloc(0) + : (attachment.content as Buffer), })); const mapAddresses = ( @@ -331,7 +346,7 @@ export class ImapConnector implements IEmailConnector { headers: parsedEmail.headers, attachments, receivedAt: parsedEmail.date || new Date(), - eml: msg.source, + tempFilePath, path: mailboxPath, }; } diff --git a/packages/backend/src/services/ingestion-connectors/MboxConnector.ts b/packages/backend/src/services/ingestion-connectors/MboxConnector.ts index b9c8005..9d5707b 100644 --- a/packages/backend/src/services/ingestion-connectors/MboxConnector.ts +++ b/packages/backend/src/services/ingestion-connectors/MboxConnector.ts @@ -5,10 +5,11 @@ import type { SyncState, MailboxUser, } from '@open-archiver/types'; -import type { IEmailConnector } from '../EmailProviderFactory'; +import type { IEmailConnector, ConnectorOptions } from '../EmailProviderFactory'; import { simpleParser, ParsedMail, Attachment, AddressObject } from 'mailparser'; import { logger } from '../../config/logger'; import { getThreadId } from './helpers/utils'; +import { writeEmailToTempFile } from './helpers/tempFile'; import { StorageService } from '../StorageService'; import { Readable, Transform } from 'stream'; import { createHash } from 'crypto'; @@ -54,8 +55,13 @@ class MboxSplitter extends Transform { export class MboxConnector implements IEmailConnector { private storage: StorageService; + private options: ConnectorOptions; - constructor(private credentials: MboxImportCredentials) { + constructor( + private credentials: MboxImportCredentials, + options?: ConnectorOptions + ) { + this.options = options ?? { preserveOriginalFile: false }; this.storage = new StorageService(); } @@ -164,14 +170,42 @@ export class MboxConnector implements IEmailConnector { } } - private async parseMessage(emlBuffer: Buffer, path: string): Promise { + /** + * Strips the mbox "From " envelope line from the raw buffer. + * The mbox format prepends each message with a "From sender@... timestamp\n" + * line that is NOT part of the RFC 5322 message. Storing this line in the + * .eml would produce an invalid file and corrupt the SHA-256 hash for GoBD + * compliance purposes. + */ + private stripMboxEnvelope(buffer: Buffer): Buffer { + // The "From " line ends at the first \n — everything after is the real RFC 5322 message. + const fromPrefix = Buffer.from('From '); + if (buffer.subarray(0, fromPrefix.length).equals(fromPrefix)) { + const newlineIndex = buffer.indexOf(0x0a); // \n + if (newlineIndex !== -1) { + return buffer.subarray(newlineIndex + 1); + } + } + return buffer; + } + + private async parseMessage(rawMboxBuffer: Buffer, path: string): Promise { + // Strip the mbox "From " envelope line before writing to temp file. + // This line is an mbox transport artifact, not part of the RFC 5322 message. + const emlBuffer = this.stripMboxEnvelope(rawMboxBuffer); + + const tempFilePath = await writeEmailToTempFile(emlBuffer); const parsedEmail: ParsedMail = await simpleParser(emlBuffer); + // In preserve-original mode, skip extracting full attachment binary content + // to avoid unnecessary memory allocation — the raw EML on disk is the source of truth. const attachments = parsedEmail.attachments.map((attachment: Attachment) => ({ filename: attachment.filename || 'untitled', contentType: attachment.contentType, size: attachment.size, - content: attachment.content as Buffer, + content: this.options.preserveOriginalFile + ? Buffer.alloc(0) + : (attachment.content as Buffer), })); const mapAddresses = ( @@ -226,7 +260,7 @@ export class MboxConnector implements IEmailConnector { headers: parsedEmail.headers, attachments, receivedAt: parsedEmail.date || new Date(), - eml: emlBuffer, + tempFilePath, path: finalPath, }; } diff --git a/packages/backend/src/services/ingestion-connectors/MicrosoftConnector.ts b/packages/backend/src/services/ingestion-connectors/MicrosoftConnector.ts index 83e9457..89cc371 100644 --- a/packages/backend/src/services/ingestion-connectors/MicrosoftConnector.ts +++ b/packages/backend/src/services/ingestion-connectors/MicrosoftConnector.ts @@ -6,9 +6,10 @@ import type { SyncState, MailboxUser, } from '@open-archiver/types'; -import type { IEmailConnector } from '../EmailProviderFactory'; +import type { IEmailConnector, ConnectorOptions } from '../EmailProviderFactory'; import { logger } from '../../config/logger'; import { simpleParser, ParsedMail, Attachment, AddressObject } from 'mailparser'; +import { writeEmailToTempFile } from './helpers/tempFile'; import { ConfidentialClientApplication, Configuration, LogLevel } from '@azure/msal-node'; import { Client } from '@microsoft/microsoft-graph-client'; import type { User, MailFolder } from 'microsoft-graph'; @@ -23,9 +24,11 @@ export class MicrosoftConnector implements IEmailConnector { private graphClient: Client; // Store delta tokens for each folder during a sync operation. private newDeltaTokens: { [folderId: string]: string }; + private options: ConnectorOptions; - constructor(credentials: Microsoft365Credentials) { + constructor(credentials: Microsoft365Credentials, options?: ConnectorOptions) { this.credentials = credentials; + this.options = options ?? { preserveOriginalFile: false }; this.newDeltaTokens = {}; // Initialize as an empty object const msalConfig: Configuration = { @@ -299,12 +302,18 @@ export class MicrosoftConnector implements IEmailConnector { userEmail: string, path: string ): Promise { + const tempFilePath = await writeEmailToTempFile(rawEmail); const parsedEmail: ParsedMail = await simpleParser(rawEmail); + + // In preserve-original mode, skip extracting full attachment binary content + // to avoid unnecessary memory allocation — the raw EML on disk is the source of truth. const attachments = parsedEmail.attachments.map((attachment: Attachment) => ({ filename: attachment.filename || 'untitled', contentType: attachment.contentType, size: attachment.size, - content: attachment.content as Buffer, + content: this.options.preserveOriginalFile + ? Buffer.alloc(0) + : (attachment.content as Buffer), })); const mapAddresses = ( addresses: AddressObject | AddressObject[] | undefined @@ -319,7 +328,7 @@ export class MicrosoftConnector implements IEmailConnector { return { id: messageId, userEmail: userEmail, - eml: rawEmail, + tempFilePath, from: mapAddresses(parsedEmail.from), to: mapAddresses(parsedEmail.to), cc: mapAddresses(parsedEmail.cc), diff --git a/packages/backend/src/services/ingestion-connectors/PSTConnector.ts b/packages/backend/src/services/ingestion-connectors/PSTConnector.ts index 6499d42..f3d9e88 100644 --- a/packages/backend/src/services/ingestion-connectors/PSTConnector.ts +++ b/packages/backend/src/services/ingestion-connectors/PSTConnector.ts @@ -5,13 +5,13 @@ import type { SyncState, MailboxUser, } from '@open-archiver/types'; -import type { IEmailConnector } from '../EmailProviderFactory'; +import type { IEmailConnector, ConnectorOptions } from '../EmailProviderFactory'; import { PSTFile, PSTFolder, PSTMessage } from 'pst-extractor'; import { simpleParser, ParsedMail, Attachment, AddressObject } from 'mailparser'; import { logger } from '../../config/logger'; import { getThreadId } from './helpers/utils'; +import { writeEmailToTempFile } from './helpers/tempFile'; import { StorageService } from '../StorageService'; -import { Readable } from 'stream'; import { createHash } from 'crypto'; import { join } from 'path'; import { createWriteStream, createReadStream, promises as fs } from 'fs'; @@ -106,8 +106,13 @@ const JUNK_FOLDERS = new Set([ export class PSTConnector implements IEmailConnector { private storage: StorageService; + private options: ConnectorOptions; - constructor(private credentials: PSTImportCredentials) { + constructor( + private credentials: PSTImportCredentials, + options?: ConnectorOptions + ) { + this.options = options ?? { preserveOriginalFile: false }; this.storage = new StorageService(); } @@ -263,7 +268,10 @@ export class PSTConnector implements IEmailConnector { try { email = folder.getNextChild(); } catch (error) { - console.warn("Folder doesn't have child"); + logger.warn( + { folder: folder.displayName, error }, + "Folder doesn't have child or failed to read next child." + ); email = null; } } @@ -283,13 +291,18 @@ export class PSTConnector implements IEmailConnector { ): Promise { const emlContent = await this.constructEml(msg); const emlBuffer = Buffer.from(emlContent, 'utf-8'); + const tempFilePath = await writeEmailToTempFile(emlBuffer); const parsedEmail: ParsedMail = await simpleParser(emlBuffer); + // In preserve-original mode, skip extracting full attachment binary content + // to avoid unnecessary memory allocation — the raw EML on disk is the source of truth. const attachments = parsedEmail.attachments.map((attachment: Attachment) => ({ filename: attachment.filename || 'untitled', contentType: attachment.contentType, size: attachment.size, - content: attachment.content as Buffer, + content: this.options.preserveOriginalFile + ? Buffer.alloc(0) + : (attachment.content as Buffer), })); const mapAddresses = ( @@ -336,7 +349,7 @@ export class PSTConnector implements IEmailConnector { headers: parsedEmail.headers, attachments, receivedAt: parsedEmail.date || new Date(), - eml: emlBuffer, + tempFilePath, path, }; } diff --git a/packages/backend/src/services/ingestion-connectors/helpers/tempFile.ts b/packages/backend/src/services/ingestion-connectors/helpers/tempFile.ts new file mode 100644 index 0000000..a5b0368 --- /dev/null +++ b/packages/backend/src/services/ingestion-connectors/helpers/tempFile.ts @@ -0,0 +1,15 @@ +import { tmpdir } from 'os'; +import { join } from 'path'; +import { writeFile } from 'fs/promises'; +import { randomUUID } from 'crypto'; + +/** + * Writes a raw email buffer to a temporary file on disk and returns the path. + * This keeps large buffers off the JS heap between connector yield and processEmail(). + * The caller (IngestionService.processEmail) is responsible for deleting the file. + */ +export async function writeEmailToTempFile(buffer: Buffer): Promise { + const tempFilePath = join(tmpdir(), `oa-email-${randomUUID()}.eml`); + await writeFile(tempFilePath, buffer); + return tempFilePath; +} diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 787556d..fef99d0 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -28,7 +28,8 @@ "svelte-persisted-store": "^0.12.0", "sveltekit-i18n": "^2.4.2", "tailwind-merge": "^3.3.1", - "tailwind-variants": "^1.0.0" + "tailwind-variants": "^1.0.0", + "tippy.js": "^6.3.7" }, "devDependencies": { "@internationalized/date": "^3.8.2", diff --git a/packages/frontend/src/lib/components/custom/Footer.svelte b/packages/frontend/src/lib/components/custom/Footer.svelte index 1a40aba..7c2d2fe 100644 --- a/packages/frontend/src/lib/components/custom/Footer.svelte +++ b/packages/frontend/src/lib/components/custom/Footer.svelte @@ -27,9 +27,7 @@ {/if}

© {new Date().getFullYear()} - Open Archiver. {$t( - 'app.components.footer.all_rights_reserved' - )} + Open Archiver

Version: {currentVersion} diff --git a/packages/frontend/src/lib/components/custom/IngestionSourceForm.svelte b/packages/frontend/src/lib/components/custom/IngestionSourceForm.svelte index c6e1e14..bf7e46f 100644 --- a/packages/frontend/src/lib/components/custom/IngestionSourceForm.svelte +++ b/packages/frontend/src/lib/components/custom/IngestionSourceForm.svelte @@ -1,5 +1,5 @@

@@ -439,6 +457,101 @@ {/if} + + +
+ + + {#if showAdvanced} +
+
+
+ + + + +
+ +
+ + + {#if !source && mergeableRootSources.length > 0} +
+
+ + + + +
+ +
+ + {#if mergeEnabled} +
+
+
+ + + {mergeTriggerContent} + + + {#each mergeableRootSources as rootSource} + + {rootSource.name} ({rootSource.provider + .split('_') + .join(' ')}) + + {/each} + + +
+
+ {/if} + {/if} +
+ {/if} +
+ + + {/each} + + + {/if} @@ -918,6 +1014,38 @@ + + + + + + + {$t('app.archive.embedded_attachment_title')} + + + {selectedEmbeddedFilename} +

+ {$t('app.archive.embedded_attachment_description')} +
+
+ + + + + + +
+
{:else}

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

{/if} 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 6cbc722..7c18a0b 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 @@ -1,5 +1,5 @@ import { api } from '$lib/server/api'; -import { error } from '@sveltejs/kit'; +import { error, fail } from '@sveltejs/kit'; import type { Actions, PageServerLoad } from './$types'; import type { LegalHold, SearchQuery } from '@open-archiver/types'; @@ -40,7 +40,10 @@ export const actions: Actions = { const res = await response.json(); if (!response.ok) { - return { success: false, message: res.message || 'Failed to create legal hold.' }; + return fail(response.status, { + success: false, + message: res.message || 'Failed to create legal hold.', + }); } return { success: true }; @@ -63,7 +66,10 @@ export const actions: Actions = { const res = await response.json(); if (!response.ok) { - return { success: false, message: res.message || 'Failed to update legal hold.' }; + return fail(response.status, { + success: false, + message: res.message || 'Failed to update legal hold.', + }); } return { success: true }; @@ -82,7 +88,10 @@ export const actions: Actions = { const res = await response.json(); if (!response.ok) { - return { success: false, message: res.message || 'Failed to update legal hold.' }; + return fail(response.status, { + success: false, + message: res.message || 'Failed to update legal hold.', + }); } return { success: true, isActive }; @@ -98,10 +107,10 @@ export const actions: Actions = { if (!response.ok) { const res = await response.json().catch(() => ({})); - return { + return fail(response.status, { success: false, message: (res as { message?: string }).message || 'Failed to delete legal hold.', - }; + }); } return { success: true }; @@ -116,7 +125,7 @@ export const actions: Actions = { try { searchQuery = JSON.parse(rawQuery) as SearchQuery; } catch { - return { success: false, message: 'Invalid search query format.' }; + return fail(400, { success: false, message: 'Invalid search query format.' }); } const response = await api(`/enterprise/legal-holds/holds/${holdId}/bulk-apply`, event, { @@ -127,10 +136,10 @@ export const actions: Actions = { const res = await response.json(); if (!response.ok) { - return { + return fail(response.status, { success: false, message: (res as { message?: string }).message || 'Bulk apply failed.', - }; + }); } const result = res as { emailsLinked: number }; @@ -148,11 +157,11 @@ export const actions: Actions = { const res = await response.json(); if (!response.ok) { - return { + return fail(response.status, { success: false, message: (res as { message?: string }).message || 'Failed to release emails from hold.', - }; + }); } const result = res as { emailsReleased: number }; 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 dffd6b9..fda4b13 100644 --- a/packages/frontend/src/routes/dashboard/compliance/legal-holds/+page.svelte +++ b/packages/frontend/src/routes/dashboard/compliance/legal-holds/+page.svelte @@ -186,10 +186,7 @@ action="?/toggleActive" use:enhance={() => { return async ({ result, update }) => { - if ( - result.type === 'success' && - result.data?.success !== false - ) { + if (result.type === 'success') { const newState = result.data ?.isActive as boolean; setAlert({ @@ -205,10 +202,7 @@ duration: 3000, show: true, }); - } else if ( - result.type === 'success' && - result.data?.success === false - ) { + } else if (result.type === 'failure') { setAlert({ type: 'error', title: $t('app.legal_holds.update_error'), @@ -275,7 +269,7 @@ isFormLoading = true; return async ({ result, update }) => { isFormLoading = false; - if (result.type === 'success' && result.data?.success !== false) { + if (result.type === 'success') { isCreateOpen = false; setAlert({ type: 'success', @@ -284,7 +278,7 @@ duration: 3000, show: true, }); - } else if (result.type === 'success' && result.data?.success === false) { + } else if (result.type === 'failure') { setAlert({ type: 'error', title: $t('app.legal_holds.create_error'), @@ -353,7 +347,7 @@ isFormLoading = true; return async ({ result, update }) => { isFormLoading = false; - if (result.type === 'success' && result.data?.success !== false) { + if (result.type === 'success') { isEditOpen = false; selectedHold = null; setAlert({ @@ -363,7 +357,7 @@ duration: 3000, show: true, }); - } else if (result.type === 'success' && result.data?.success === false) { + } else if (result.type === 'failure') { setAlert({ type: 'error', title: $t('app.legal_holds.update_error'), @@ -425,7 +419,7 @@ isFormLoading = true; return async ({ result, update }) => { isFormLoading = false; - if (result.type === 'success' && result.data?.success !== false) { + if (result.type === 'success') { isBulkApplyOpen = false; const count = result.data?.emailsLinked as number; setAlert({ @@ -435,7 +429,7 @@ duration: 5000, show: true, }); - } else if (result.type === 'success' && result.data?.success === false) { + } else if (result.type === 'failure') { setAlert({ type: 'error', title: $t('app.legal_holds.bulk_apply_error'), @@ -539,7 +533,7 @@ isFormLoading = true; return async ({ result, update }) => { isFormLoading = false; - if (result.type === 'success' && result.data?.success !== false) { + if (result.type === 'success') { isReleaseAllOpen = false; const count = result.data?.emailsReleased as number; setAlert({ @@ -550,14 +544,11 @@ show: true, }); selectedHold = null; - } else { + } else if (result.type === 'failure') { setAlert({ type: 'error', title: $t('app.legal_holds.release_all_error'), - message: - result.type === 'success' - ? String(result.data?.message ?? '') - : '', + message: String(result.data?.message ?? ''), duration: 5000, show: true, }); @@ -605,7 +596,7 @@ isFormLoading = true; return async ({ result, update }) => { isFormLoading = false; - if (result.type === 'success' && result.data?.success !== false) { + if (result.type === 'success') { isDeleteOpen = false; setAlert({ type: 'success', @@ -615,14 +606,11 @@ show: true, }); selectedHold = null; - } else { + } else if (result.type === 'failure') { setAlert({ type: 'error', title: $t('app.legal_holds.delete_error'), - message: - result.type === 'success' - ? String(result.data?.message ?? '') - : '', + message: String(result.data?.message ?? ''), duration: 5000, show: true, }); diff --git a/packages/frontend/src/routes/dashboard/compliance/retention-labels/+page.server.ts b/packages/frontend/src/routes/dashboard/compliance/retention-labels/+page.server.ts index af5abe2..9c5eee2 100644 --- a/packages/frontend/src/routes/dashboard/compliance/retention-labels/+page.server.ts +++ b/packages/frontend/src/routes/dashboard/compliance/retention-labels/+page.server.ts @@ -1,5 +1,5 @@ import { api } from '$lib/server/api'; -import { error } from '@sveltejs/kit'; +import { error, fail } from '@sveltejs/kit'; import type { Actions, PageServerLoad } from './$types'; import type { RetentionLabel } from '@open-archiver/types'; @@ -41,7 +41,10 @@ export const actions: Actions = { const res = await response.json(); if (!response.ok) { - return { success: false, message: res.message || 'Failed to create label' }; + return fail(response.status, { + success: false, + message: res.message || 'Failed to create label', + }); } return { success: true }; @@ -70,7 +73,10 @@ export const actions: Actions = { const res = await response.json(); if (!response.ok) { - return { success: false, message: res.message || 'Failed to update label' }; + return fail(response.status, { + success: false, + message: res.message || 'Failed to update label', + }); } return { success: true }; @@ -86,7 +92,10 @@ export const actions: Actions = { if (!response.ok) { const res = await response.json().catch(() => ({})); - return { success: false, message: res.message || 'Failed to delete label' }; + return fail(response.status, { + success: false, + message: res.message || 'Failed to delete label', + }); } const result = await response.json(); diff --git a/packages/frontend/src/routes/dashboard/compliance/retention-labels/+page.svelte b/packages/frontend/src/routes/dashboard/compliance/retention-labels/+page.svelte index f971a54..63b085a 100644 --- a/packages/frontend/src/routes/dashboard/compliance/retention-labels/+page.svelte +++ b/packages/frontend/src/routes/dashboard/compliance/retention-labels/+page.svelte @@ -171,7 +171,7 @@ isFormLoading = true; return async ({ result, update }) => { isFormLoading = false; - if (result.type === 'success' && result.data?.success !== false) { + if (result.type === 'success') { isCreateOpen = false; setAlert({ type: 'success', @@ -180,7 +180,7 @@ duration: 3000, show: true, }); - } else if (result.type === 'success' && result.data?.success === false) { + } else if (result.type === 'failure') { setAlert({ type: 'error', title: $t('app.retention_labels.create_error'), @@ -261,7 +261,7 @@ isFormLoading = true; return async ({ result, update }) => { isFormLoading = false; - if (result.type === 'success' && result.data?.success !== false) { + if (result.type === 'success') { isEditOpen = false; selectedLabel = null; setAlert({ @@ -271,7 +271,7 @@ duration: 3000, show: true, }); - } else if (result.type === 'success' && result.data?.success === false) { + } else if (result.type === 'failure') { setAlert({ type: 'error', title: $t('app.retention_labels.update_error'), @@ -376,7 +376,7 @@ isDeleting = true; return async ({ result, update }) => { isDeleting = false; - if (result.type === 'success' && result.data?.success !== false) { + if (result.type === 'success') { isDeleteOpen = false; const action = result.data?.action; setAlert({ @@ -390,14 +390,11 @@ show: true, }); selectedLabel = null; - } else { + } else if (result.type === 'failure') { setAlert({ type: 'error', title: $t('app.retention_labels.delete_error'), - message: - result.type === 'success' - ? String(result.data?.message ?? '') - : '', + message: String(result.data?.message ?? ''), duration: 5000, show: true, }); diff --git a/packages/frontend/src/routes/dashboard/compliance/retention-policies/+page.server.ts b/packages/frontend/src/routes/dashboard/compliance/retention-policies/+page.server.ts index c1b913b..2654cd6 100644 --- a/packages/frontend/src/routes/dashboard/compliance/retention-policies/+page.server.ts +++ b/packages/frontend/src/routes/dashboard/compliance/retention-policies/+page.server.ts @@ -1,5 +1,5 @@ import { api } from '$lib/server/api'; -import { error } from '@sveltejs/kit'; +import { error, fail } from '@sveltejs/kit'; import type { Actions, PageServerLoad } from './$types'; import type { RetentionPolicy, @@ -76,7 +76,10 @@ export const actions: Actions = { const res = await response.json(); if (!response.ok) { - return { success: false, message: res.message || 'Failed to create policy' }; + return fail(response.status, { + success: false, + message: res.message || 'Failed to create policy', + }); } return { success: true }; @@ -118,7 +121,10 @@ export const actions: Actions = { const res = await response.json(); if (!response.ok) { - return { success: false, message: res.message || 'Failed to update policy' }; + return fail(response.status, { + success: false, + message: res.message || 'Failed to update policy', + }); } return { success: true }; @@ -134,7 +140,10 @@ export const actions: Actions = { if (!response.ok) { const res = await response.json().catch(() => ({})); - return { success: false, message: res.message || 'Failed to delete policy' }; + return fail(response.status, { + success: false, + message: res.message || 'Failed to delete policy', + }); } return { success: true }; @@ -173,11 +182,11 @@ export const actions: Actions = { const res = await response.json(); if (!response.ok) { - return { + return fail(response.status, { success: false, message: res.message || 'Failed to evaluate policies', evaluationResult: null as PolicyEvaluationResult | null, - }; + }); } return { diff --git a/packages/frontend/src/routes/dashboard/compliance/retention-policies/+page.svelte b/packages/frontend/src/routes/dashboard/compliance/retention-policies/+page.svelte index 90a94a2..3927225 100644 --- a/packages/frontend/src/routes/dashboard/compliance/retention-policies/+page.svelte +++ b/packages/frontend/src/routes/dashboard/compliance/retention-policies/+page.svelte @@ -46,7 +46,7 @@ // React to form results (errors and evaluation results) $effect(() => { - if (form && form.success === false && form.message) { + if (form && 'success' in form && form.success === false && form.message) { toast.error(form.message); } if (form && 'evaluationResult' in form) { @@ -449,8 +449,13 @@ isDeleteOpen = false; selectedPolicy = null; toast.success($t('app.retention_policies.delete_success')); - } else { - toast.error($t('app.retention_policies.delete_error')); + } else if (result.type === 'failure') { + toast.error( + String( + result.data?.message ?? + $t('app.retention_policies.delete_error') + ) + ); } await update(); }; diff --git a/packages/frontend/src/routes/dashboard/ingestions/+page.server.ts b/packages/frontend/src/routes/dashboard/ingestions/+page.server.ts index d6a5326..fd069dc 100644 --- a/packages/frontend/src/routes/dashboard/ingestions/+page.server.ts +++ b/packages/frontend/src/routes/dashboard/ingestions/+page.server.ts @@ -1,6 +1,6 @@ import { api } from '$lib/server/api'; import type { PageServerLoad } from './$types'; -import type { IngestionSource } from '@open-archiver/types'; +import type { SafeIngestionSource } from '@open-archiver/types'; import { error } from '@sveltejs/kit'; export const load: PageServerLoad = async (event) => { const response = await api('/ingestion-sources', event); @@ -8,7 +8,7 @@ export const load: PageServerLoad = async (event) => { if (!response.ok) { throw error(response.status, responseText.message || 'Failed to fetch ingestions.'); } - const ingestionSources: IngestionSource[] = responseText; + const ingestionSources: SafeIngestionSource[] = responseText; return { ingestionSources, }; diff --git a/packages/frontend/src/routes/dashboard/ingestions/+page.svelte b/packages/frontend/src/routes/dashboard/ingestions/+page.svelte index 35116ec..7dbceec 100644 --- a/packages/frontend/src/routes/dashboard/ingestions/+page.svelte +++ b/packages/frontend/src/routes/dashboard/ingestions/+page.svelte @@ -3,43 +3,94 @@ import * as Table from '$lib/components/ui/table'; import { Button } from '$lib/components/ui/button'; import * as DropdownMenu from '$lib/components/ui/dropdown-menu'; - import { MoreHorizontal, Trash, RefreshCw } from 'lucide-svelte'; + import { MoreHorizontal, Trash, RefreshCw, ChevronRight } from 'lucide-svelte'; import * as Dialog from '$lib/components/ui/dialog'; import { Switch } from '$lib/components/ui/switch'; import { Checkbox } from '$lib/components/ui/checkbox'; import IngestionSourceForm from '$lib/components/custom/IngestionSourceForm.svelte'; import { api } from '$lib/api.client'; - import type { IngestionSource, CreateIngestionSourceDto } from '@open-archiver/types'; + import type { SafeIngestionSource, CreateIngestionSourceDto } from '@open-archiver/types'; import Badge from '$lib/components/ui/badge/badge.svelte'; import { setAlert } from '$lib/components/custom/alert/alert-state.svelte'; import * as HoverCard from '$lib/components/ui/hover-card/index.js'; import { t } from '$lib/translations'; let { data }: { data: PageData } = $props(); - let ingestionSources = $state(data.ingestionSources); + let ingestionSources = $state(data.ingestionSources as SafeIngestionSource[]); let isDialogOpen = $state(false); let isDeleteDialogOpen = $state(false); - let selectedSource = $state(null); - let sourceToDelete = $state(null); + let selectedSource = $state(null); + let sourceToDelete = $state(null); let isDeleting = $state(false); let selectedIds = $state([]); let isBulkDeleteDialogOpen = $state(false); + let isUnmergeDialogOpen = $state(false); + let sourceToUnmerge = $state(null); + let isUnmerging = $state(false); + /** Tracks which root source groups are expanded in the table */ + let expandedGroups = $state>(new Set()); + + // Group sources: roots (mergedIntoId is null/undefined) and their children + const rootSources = $derived(ingestionSources.filter((s) => !s.mergedIntoId)); + + /** Returns children for a given root source ID */ + function getChildren(rootId: string): SafeIngestionSource[] { + return ingestionSources.filter((s) => s.mergedIntoId === rootId); + } + + /** Returns aggregated status for a group. + * If the root is paused but children are still active, show 'active' + * so the group does not appear fully paused when children are running. */ + function getGroupStatus( + root: SafeIngestionSource, + children: SafeIngestionSource[] + ): SafeIngestionSource['status'] { + const all = [root, ...children]; + if (all.some((s) => s.status === 'error')) return 'error'; + if (all.some((s) => s.status === 'syncing')) return 'syncing'; + if (all.some((s) => s.status === 'importing')) return 'importing'; + if (all.every((s) => s.status === 'paused')) return 'paused'; + // Root paused but some children are active/imported — show active so the + // group badge reflects that ingestion is still ongoing via the children. + if ( + root.status === 'paused' && + children.some((s) => ['active', 'imported', 'syncing', 'importing'].includes(s.status)) + ) + return 'partially_active'; + if (all.every((s) => ['imported', 'active'].includes(s.status))) return 'active'; + return root.status; + } + + const toggleGroup = (rootId: string) => { + const next = new Set(expandedGroups); + if (next.has(rootId)) { + next.delete(rootId); + } else { + next.add(rootId); + } + expandedGroups = next; + }; const openCreateDialog = () => { selectedSource = null; isDialogOpen = true; }; - const openEditDialog = (source: IngestionSource) => { - selectedSource = source; + const openEditDialog = (source: SafeIngestionSource) => { + selectedSource = source as SafeIngestionSource; isDialogOpen = true; }; - const openDeleteDialog = (source: IngestionSource) => { + const openDeleteDialog = (source: SafeIngestionSource) => { sourceToDelete = source; isDeleteDialogOpen = true; }; + /** Count of children that will be deleted alongside a root source */ + const deleteChildCount = $derived( + sourceToDelete && !sourceToDelete.mergedIntoId ? getChildren(sourceToDelete.id).length : 0 + ); + const confirmDelete = async () => { if (!sourceToDelete) return; isDeleting = true; @@ -56,7 +107,11 @@ }); return; } - ingestionSources = ingestionSources.filter((s) => s.id !== sourceToDelete!.id); + // Remove the deleted source and any children from state + const deletedId = sourceToDelete.id; + ingestionSources = ingestionSources.filter( + (s) => s.id !== deletedId && s.mergedIntoId !== deletedId + ); isDeleteDialogOpen = false; sourceToDelete = null; } finally { @@ -77,16 +132,15 @@ }); return; } - const updatedSources = ingestionSources.map((s) => { + ingestionSources = ingestionSources.map((s) => { if (s.id === id) { return { ...s, status: 'syncing' as const }; } return s; }); - ingestionSources = updatedSources; }; - const handleToggle = async (source: IngestionSource) => { + const handleToggle = async (source: SafeIngestionSource) => { try { const isPaused = source.status === 'paused'; const newStatus = isPaused ? 'active' : 'paused'; @@ -126,6 +180,46 @@ } }; + const openUnmergeDialog = (source: SafeIngestionSource) => { + sourceToUnmerge = source; + isUnmergeDialogOpen = true; + }; + + const confirmUnmerge = async () => { + if (!sourceToUnmerge) return; + isUnmerging = true; + try { + const res = await api(`/ingestion-sources/${sourceToUnmerge.id}/unmerge`, { + method: 'POST', + }); + if (!res.ok) { + const errorBody = await res.json(); + throw Error(errorBody.message || 'Unmerge failed'); + } + const updated: SafeIngestionSource = await res.json(); + ingestionSources = ingestionSources.map((s) => (s.id === updated.id ? updated : s)); + isUnmergeDialogOpen = false; + sourceToUnmerge = null; + setAlert({ + type: 'success', + title: $t('app.ingestions.unmerge_success'), + message: '', + duration: 3000, + show: true, + }); + } catch (e) { + setAlert({ + type: 'error', + title: 'Failed to unmerge', + message: e instanceof Error ? e.message : JSON.stringify(e), + duration: 5000, + show: true, + }); + } finally { + isUnmerging = false; + } + }; + const handleBulkDelete = async () => { isDeleting = true; try { @@ -143,7 +237,11 @@ return; } } - ingestionSources = ingestionSources.filter((s) => !selectedIds.includes(s.id)); + // Remove deleted roots and their children from local state + // (backend cascades child deletion, so we mirror that here) + ingestionSources = ingestionSources.filter( + (s) => !selectedIds.includes(s.id) && !selectedIds.includes(s.mergedIntoId ?? '') + ); selectedIds = []; isBulkDeleteDialogOpen = false; } finally { @@ -166,13 +264,25 @@ }); } } - const updatedSources = ingestionSources.map((s) => { + // Backend cascades force sync to non-file-based children, + // so optimistically mark root + eligible children as syncing + const fileBasedProviders = ['pst_import', 'eml_import', 'mbox_import']; + ingestionSources = ingestionSources.map((s) => { + // Mark selected roots as syncing if (selectedIds.includes(s.id)) { return { ...s, status: 'syncing' as const }; } + // Mark non-file-based children of selected roots as syncing + if ( + s.mergedIntoId && + selectedIds.includes(s.mergedIntoId) && + !fileBasedProviders.includes(s.provider) && + (s.status === 'active' || s.status === 'error') + ) { + return { ...s, status: 'syncing' as const }; + } return s; }); - ingestionSources = updatedSources; selectedIds = []; } catch (e) { setAlert({ @@ -230,10 +340,12 @@ } }; - function getStatusClasses(status: IngestionSource['status']): string { + function getStatusClasses(status: SafeIngestionSource['status']): string { switch (status) { case 'active': return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300'; + case 'partially_active': + return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300'; case 'imported': return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300'; case 'paused': @@ -299,13 +411,13 @@ { if (checked) { - selectedIds = ingestionSources.map((s) => s.id); + selectedIds = rootSources.map((s) => s.id); } else { selectedIds = []; } }} - checked={ingestionSources.length > 0 && - selectedIds.length === ingestionSources.length + checked={rootSources.length > 0 && + selectedIds.length === rootSources.length ? true : ((selectedIds.length > 0 ? 'indeterminate' : false) as any)} /> @@ -319,8 +431,16 @@ - {#if ingestionSources.length > 0} - {#each ingestionSources as source (source.id)} + {#if rootSources.length > 0} + {#each rootSources as source (source.id)} + {@const children = getChildren(source.id)} + {@const hasChildren = children.length > 0} + {@const isExpanded = expandedGroups.has(source.id)} + {@const displayStatus = hasChildren + ? getGroupStatus(source, children) + : source.status} + + - {source.name} +
+ {#if hasChildren} + + {/if} + {source.name} + {#if hasChildren} + ({children.length} + {$t('app.ingestions.merged_sources')}) + {/if} +
{source.provider.split('_').join(' ')} - {source.status.split('_').join(' ')} + {displayStatus.split('_').join(' ')} - +

{$t('app.ingestions.last_sync_message')}: @@ -374,8 +517,6 @@ class="cursor-pointer" checked={source.status !== 'paused'} onCheckedChange={() => handleToggle(source)} - disabled={source.status === 'importing' || - source.status === 'syncing'} /> + + + {#if hasChildren && isExpanded} + {#each children as child (child.id)} + + + + + +

+ + + {child.name} +
+ + {child.provider.split('_').join(' ')} + + + + + {child.status.split('_').join(' ')} + + + +
+

+ {$t( + 'app.ingestions.last_sync_message' + )}: + {child.lastSyncStatusMessage || + $t('app.ingestions.empty')} +

+
+
+
+
+ + handleToggle(child)} + /> + + {new Date( + child.createdAt + ).toLocaleDateString()} + + + + {#snippet child({ props })} + + {/snippet} + + + {$t( + 'app.ingestions.actions' + )} + openEditDialog(child)} + >{$t('app.ingestions.edit')} + handleSync(child.id)} + >{$t( + 'app.ingestions.force_sync' + )} + openUnmergeDialog(child)} + > + {$t('app.ingestions.unmerge')} + + + openDeleteDialog(child)} + >{$t( + 'app.ingestions.delete' + )} + + + + + {/each} + {/if} {/each} {:else} @@ -451,7 +706,11 @@ > - + @@ -461,6 +720,13 @@ {$t('app.ingestions.delete_confirmation_title')} {$t('app.ingestions.delete_confirmation_description')} + {#if deleteChildCount > 0} +

+ {$t('app.ingestions.delete_root_warning', { + count: deleteChildCount, + } as any)} +

+ {/if}
@@ -512,3 +778,31 @@ + + + + + + {$t('app.ingestions.unmerge_confirmation_title')} + + {$t('app.ingestions.unmerge_confirmation_description')} + + +
    +
  • {$t('app.ingestions.unmerge_warning_emails')}
  • +
  • {$t('app.ingestions.unmerge_warning_future')}
  • +
+ + + + + + +
+
diff --git a/packages/frontend/src/routes/dashboard/ingestions/journaling/+page.server.ts b/packages/frontend/src/routes/dashboard/ingestions/journaling/+page.server.ts new file mode 100644 index 0000000..0ab669b --- /dev/null +++ b/packages/frontend/src/routes/dashboard/ingestions/journaling/+page.server.ts @@ -0,0 +1,171 @@ +import { api } from '$lib/server/api'; +import { error, fail } from '@sveltejs/kit'; +import type { Actions, PageServerLoad } from './$types'; +import type { JournalingSource } from '@open-archiver/types'; + +export const load: PageServerLoad = async (event) => { + if (!event.locals.enterpriseMode) { + throw error( + 403, + 'This feature is only available in the Enterprise Edition. Please contact Open Archiver to upgrade.' + ); + } + + const sourcesRes = await api('/enterprise/journaling', event); + const sourcesJson = await sourcesRes.json(); + + if (!sourcesRes.ok) { + throw error(sourcesRes.status, sourcesJson.message || JSON.stringify(sourcesJson)); + } + + const sources: JournalingSource[] = sourcesJson; + + // Fetch SMTP listener health status + const healthRes = await api('/enterprise/journaling/health', event); + const healthJson = (await healthRes.json()) as { smtp: string; port: string }; + + return { + sources, + smtpHealth: healthRes.ok ? healthJson : { smtp: 'down', port: '2525' }, + }; +}; + +export const actions: Actions = { + create: async (event) => { + const data = await event.request.formData(); + + const rawIps = (data.get('allowedIps') as string) || ''; + const allowedIps = rawIps + .split(',') + .map((ip) => ip.trim()) + .filter(Boolean); + + const body: Record = { + name: data.get('name') as string, + allowedIps, + requireTls: data.get('requireTls') === 'on', + }; + + const smtpUsername = data.get('smtpUsername') as string; + const smtpPassword = data.get('smtpPassword') as string; + if (smtpUsername) body.smtpUsername = smtpUsername; + if (smtpPassword) body.smtpPassword = smtpPassword; + + const response = await api('/enterprise/journaling', event, { + method: 'POST', + body: JSON.stringify(body), + }); + + const res = await response.json(); + + if (!response.ok) { + return fail(response.status, { + success: false, + message: res.message || 'Failed to create journaling source.', + }); + } + + return { success: true }; + }, + + update: async (event) => { + const data = await event.request.formData(); + const id = data.get('id') as string; + + const rawIps = (data.get('allowedIps') as string) || ''; + const allowedIps = rawIps + .split(',') + .map((ip) => ip.trim()) + .filter(Boolean); + + const body: Record = { + name: data.get('name') as string, + allowedIps, + requireTls: data.get('requireTls') === 'on', + }; + + const smtpUsername = data.get('smtpUsername') as string; + const smtpPassword = data.get('smtpPassword') as string; + if (smtpUsername) body.smtpUsername = smtpUsername; + if (smtpPassword) body.smtpPassword = smtpPassword; + + const response = await api(`/enterprise/journaling/${id}`, event, { + method: 'PUT', + body: JSON.stringify(body), + }); + + const res = await response.json(); + + if (!response.ok) { + return fail(response.status, { + success: false, + message: res.message || 'Failed to update journaling source.', + }); + } + + return { success: true }; + }, + + toggleStatus: async (event) => { + const data = await event.request.formData(); + const id = data.get('id') as string; + const status = data.get('status') as string; + + const response = await api(`/enterprise/journaling/${id}`, event, { + method: 'PUT', + body: JSON.stringify({ status }), + }); + + const res = await response.json(); + + if (!response.ok) { + return fail(response.status, { + success: false, + message: res.message || 'Failed to update status.', + }); + } + + return { success: true, status }; + }, + + regenerateAddress: async (event) => { + const data = await event.request.formData(); + const id = data.get('id') as string; + + const response = await api(`/enterprise/journaling/${id}/regenerate-address`, event, { + method: 'POST', + }); + + if (!response.ok) { + const res = await response.json().catch(() => ({})); + return fail(response.status, { + success: false, + message: + (res as { message?: string }).message || + 'Failed to regenerate routing address.', + }); + } + + return { success: true }; + }, + + delete: async (event) => { + const data = await event.request.formData(); + const id = data.get('id') as string; + + const response = await api(`/enterprise/journaling/${id}`, event, { + method: 'DELETE', + }); + + if (!response.ok) { + const res = await response.json().catch(() => ({})); + return fail(response.status, { + success: false, + message: + (res as { message?: string }).message || 'Failed to delete journaling source.', + }); + } + + return { success: true }; + }, +}; diff --git a/packages/frontend/src/routes/dashboard/ingestions/journaling/+page.svelte b/packages/frontend/src/routes/dashboard/ingestions/journaling/+page.svelte new file mode 100644 index 0000000..62f3b5e --- /dev/null +++ b/packages/frontend/src/routes/dashboard/ingestions/journaling/+page.svelte @@ -0,0 +1,737 @@ + + + + {$t('app.journaling.title')} - Open Archiver + + + + +
+
+

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

+

+ {$t('app.journaling.header_description')} +

+
+ +
+ + +
+
+ + + {smtpHealth.smtp === 'listening' + ? $t('app.journaling.health_listening') + : $t('app.journaling.health_down')} + +
+ + {$t('app.journaling.smtp_port')}: {smtpHealth.port} + +
+ +
+ + + + {$t('app.journaling.name')} + {$t('app.journaling.allowed_ips')} + {$t('app.journaling.total_received')} + {$t('app.journaling.status')} + {$t('app.journaling.last_received_at')} + {$t('app.journaling.actions')} + + + + {#if sources && sources.length > 0} + {#each sources as source (source.id)} + + +
+
{source.name}
+
+ {source.routingAddress} + +
+
+
+ +
+ {#each source.allowedIps.slice(0, 3) as ip} + + {ip} + + {/each} + {#if source.allowedIps.length > 3} + + +{source.allowedIps.length - 3} + + {/if} +
+
+ +
+ + 0 ? 'secondary' : 'outline'}> + {source.totalReceived} + +
+
+ + {#if source.status === 'active'} + + {$t('app.journaling.active')} + + {:else} + + {$t('app.journaling.paused')} + + {/if} + + + {#if source.lastReceivedAt} + {new Date(source.lastReceivedAt).toLocaleString()} + {:else} + + {$t('app.journaling.never')} + + {/if} + + + + + {#snippet child({ props })} + + {/snippet} + + + openEdit(source)}> + {$t('app.journaling.edit')} + + + { + return async ({ result, update }) => { + if (result.type === 'success') { + setAlert({ + type: 'success', + title: $t('app.journaling.update_success'), + message: '', + duration: 3000, + show: true, + }); + } else if (result.type === 'failure') { + setAlert({ + type: 'error', + title: $t('app.journaling.update_error'), + message: String(result.data?.message ?? ''), + duration: 5000, + show: true, + }); + } + await update(); + }; + }} + > + + + + + + + + openDelete(source)} + > + {$t('app.journaling.delete')} + + + + +
+ {/each} + {:else} + + + {$t('app.journaling.no_sources_found')} + + + {/if} +
+
+
+ + + + + + {$t('app.journaling.create')} + + {$t('app.journaling.create_description')} + + +
{ + isFormLoading = true; + return async ({ result, update }) => { + isFormLoading = false; + if (result.type === 'success') { + isCreateOpen = false; + setAlert({ + type: 'success', + title: $t('app.journaling.create_success'), + message: '', + duration: 3000, + show: true, + }); + } else if (result.type === 'failure') { + setAlert({ + type: 'error', + title: $t('app.journaling.create_error'), + message: String(result.data?.message ?? ''), + duration: 5000, + show: true, + }); + } + await update(); + }; + }} + > +
+ + +
+
+ + +

+ {$t('app.journaling.allowed_ips_hint')} +

+
+
+ + +
+
+

+ {$t('app.journaling.smtp_auth_hint')} +

+
+ + +
+
+ + +
+
+
+ + +
+
+
+
+ + + + + + {$t('app.journaling.edit')} + + {$t('app.journaling.edit_description')} + + + {#if selectedSource} +
{ + isFormLoading = true; + return async ({ result, update }) => { + isFormLoading = false; + if (result.type === 'success') { + isEditOpen = false; + selectedSource = null; + setAlert({ + type: 'success', + title: $t('app.journaling.update_success'), + message: '', + duration: 3000, + show: true, + }); + } else if (result.type === 'failure') { + setAlert({ + type: 'error', + title: $t('app.journaling.update_error'), + message: String(result.data?.message ?? ''), + duration: 5000, + show: true, + }); + } + await update(); + }; + }} + > + + + +
+

+ {$t('app.journaling.smtp_connection_info')} +

+ + +
+ {$t('app.journaling.routing_address')} +
+ {selectedSource.routingAddress} + +
+

+ {$t('app.journaling.routing_address_hint')} +

+ +
+ +

+ {$t('app.journaling.regenerate_address_warning')} +

+
+
+ +
+
+ {$t('app.journaling.smtp_host')} +
+ {typeof window !== 'undefined' + ? window.location.hostname + : 'localhost'} + +
+
+
+ {$t('app.journaling.smtp_port')} +
+ {smtpHealth.port} + +
+
+
+
+ +
+ + +
+
+ + +

+ {$t('app.journaling.allowed_ips_hint')} +

+
+
+ + +
+
+

+ {$t('app.journaling.smtp_auth_hint')} +

+
+ + +
+
+ + +
+
+
+ + +
+
+ {/if} +
+
+ + + + + + {$t('app.journaling.delete_confirmation_title')} + + {$t('app.journaling.delete_confirmation_description')} + + + + + {#if selectedSource} +
{ + isFormLoading = true; + return async ({ result, update }) => { + isFormLoading = false; + if (result.type === 'success') { + isDeleteOpen = false; + setAlert({ + type: 'success', + title: $t('app.journaling.delete_success'), + message: '', + duration: 3000, + show: true, + }); + selectedSource = null; + } else if (result.type === 'failure') { + setAlert({ + type: 'error', + title: $t('app.journaling.delete_error'), + message: String(result.data?.message ?? ''), + duration: 5000, + show: true, + }); + } + await update(); + }; + }} + > + + +
+ {/if} +
+
+
+ + + + + + {$t('app.journaling.regenerate_address')} + + {$t('app.journaling.regenerate_address_confirm')} + + + + + + + + diff --git a/packages/types/src/archived-emails.types.ts b/packages/types/src/archived-emails.types.ts index 3a402d5..b184468 100644 --- a/packages/types/src/archived-emails.types.ts +++ b/packages/types/src/archived-emails.types.ts @@ -43,6 +43,7 @@ export interface ArchivedEmail { isIndexed: boolean; hasAttachments: boolean; isOnLegalHold: boolean; + isJournaled: boolean | null; archivedAt: Date; attachments?: Attachment[]; raw?: Buffer; diff --git a/packages/types/src/audit-log.enums.ts b/packages/types/src/audit-log.enums.ts index 9407f7d..63d3572 100644 --- a/packages/types/src/audit-log.enums.ts +++ b/packages/types/src/audit-log.enums.ts @@ -27,6 +27,7 @@ export const AuditLogTargetTypes = [ 'ArchivedEmail', 'Dashboard', 'IngestionSource', + 'JournalingSource', 'RetentionPolicy', 'RetentionLabel', 'LegalHold', diff --git a/packages/types/src/email.types.ts b/packages/types/src/email.types.ts index 03be5d3..16531fb 100644 --- a/packages/types/src/email.types.ts +++ b/packages/types/src/email.types.ts @@ -45,8 +45,10 @@ export interface EmailObject { attachments: EmailAttachment[]; /** The date and time when the email was received. */ receivedAt: Date; - /** An optional buffer containing the full raw EML content of the email, which is useful for archival and compliance purposes. */ - eml?: Buffer; + /** Path to a temporary file on disk containing the raw EML bytes. + * Connectors write the raw email to tmpdir() and pass only the path, + * keeping large buffers off the JS heap between yield and processEmail(). */ + tempFilePath: string; /** The email address of the user whose mailbox this email belongs to. */ userEmail?: string; /** The folder path of the email in the source mailbox. */ diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index a0b08aa..4423354 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -14,3 +14,4 @@ export * from './integrity.types'; export * from './jobs.types'; export * from './license.types'; export * from './retention.types'; +export * from './journaling.types'; diff --git a/packages/types/src/ingestion.types.ts b/packages/types/src/ingestion.types.ts index 180233e..27a195a 100644 --- a/packages/types/src/ingestion.types.ts +++ b/packages/types/src/ingestion.types.ts @@ -24,7 +24,8 @@ export type IngestionProvider = | 'generic_imap' | 'pst_import' | 'eml_import' - | 'mbox_import'; + | 'mbox_import' + | 'smtp_journaling'; export type IngestionStatus = | 'active' @@ -34,7 +35,8 @@ export type IngestionStatus = | 'syncing' | 'importing' | 'auth_success' - | 'imported'; + | 'imported' + | 'partially_active'; // For sources with merged children where some are active and others are not export interface BaseIngestionCredentials { type: IngestionProvider; @@ -91,6 +93,12 @@ export interface MboxImportCredentials extends BaseIngestionCredentials { localFilePath?: string; } +export interface SmtpJournalingCredentials extends BaseIngestionCredentials { + type: 'smtp_journaling'; + /** The ID of the journaling_sources row that owns this ingestion source */ + journalingSourceId: string; +} + // Discriminated union for all possible credential types export type IngestionCredentials = | GenericImapCredentials @@ -98,7 +106,8 @@ export type IngestionCredentials = | Microsoft365Credentials | PSTImportCredentials | EMLImportCredentials - | MboxImportCredentials; + | MboxImportCredentials + | SmtpJournalingCredentials; export interface IngestionSource { id: string; @@ -112,6 +121,12 @@ export interface IngestionSource { lastSyncFinishedAt?: Date | null; lastSyncStatusMessage?: string | null; syncState?: SyncState | null; + /** When true, the raw EML file is stored without any modification (no attachment + * stripping). Required for GoBD / SEC 17a-4 compliance. Defaults to false. */ + preserveOriginalFile: boolean; + /** The ID of the root ingestion source this child is merged into. + * Null or undefined when this source is a standalone root. */ + mergedIntoId?: string | null; } /** @@ -125,6 +140,10 @@ export interface CreateIngestionSourceDto { name: string; provider: IngestionProvider; providerConfig: Record; + /** Store the unmodified raw EML for GoBD compliance. Defaults to false. */ + preserveOriginalFile?: boolean; + /** Merge this new source into an existing root source's group. */ + mergedIntoId?: string; } export interface UpdateIngestionSourceDto { @@ -136,6 +155,8 @@ export interface UpdateIngestionSourceDto { lastSyncFinishedAt?: Date; lastSyncStatusMessage?: string; syncState?: SyncState; + /** Set or clear the merge parent. Use null to unmerge. */ + mergedIntoId?: string | null; } export interface IContinuousSyncJob { diff --git a/packages/types/src/journaling.types.ts b/packages/types/src/journaling.types.ts new file mode 100644 index 0000000..bca01fd --- /dev/null +++ b/packages/types/src/journaling.types.ts @@ -0,0 +1,65 @@ +/** Status of a journaling source's SMTP listener */ +export type JournalingSourceStatus = 'active' | 'paused'; + +/** Represents a configured journaling source */ +export interface JournalingSource { + id: string; + name: string; + /** CIDR blocks or IP addresses allowed to send journal reports */ + allowedIps: string[]; + /** Whether to reject plain-text (non-TLS) connections */ + requireTls: boolean; + /** Optional SMTP AUTH username for the journal endpoint */ + smtpUsername: string | null; + status: JournalingSourceStatus; + /** The backing ingestion source ID that owns archived emails */ + ingestionSourceId: string; + /** + * The SMTP routing address the admin must configure in their MTA + * (e.g. journal-abc12345@archive.yourdomain.com). + * Computed server-side from the source ID and SMTP_JOURNALING_DOMAIN. + */ + routingAddress: string; + /** Total number of emails received via this journaling source */ + totalReceived: number; + /** Timestamp of the last email received */ + lastReceivedAt: Date | null; + createdAt: Date; + updatedAt: Date; +} + +/** DTO for creating a new journaling source */ +export interface CreateJournalingSourceDto { + name: string; + allowedIps: string[]; + requireTls?: boolean; + smtpUsername?: string; + smtpPassword?: string; +} + +/** DTO for updating an existing journaling source */ +export interface UpdateJournalingSourceDto { + name?: string; + allowedIps?: string[]; + requireTls?: boolean; + status?: JournalingSourceStatus; + smtpUsername?: string; + smtpPassword?: string; +} + +/** Job data for the journal-inbound BullMQ job */ +export interface IJournalInboundJob { + /** The journaling source ID that received the email */ + journalingSourceId: string; + /** + * Path to the temp file containing the raw email data on the local filesystem. + * Raw emails are written to disk instead of embedded in the Redis job payload + * to avoid Redis memory pressure (base64 inflates 50MB → ~67MB per job). + * The worker is responsible for deleting this file after processing. + */ + tempFilePath: string; + /** IP address of the sending MTA */ + remoteAddress: string; + /** Timestamp when the SMTP listener received the email */ + receivedAt: string; +} diff --git a/packages/types/src/license.types.ts b/packages/types/src/license.types.ts index b2f269c..04b93a0 100644 --- a/packages/types/src/license.types.ts +++ b/packages/types/src/license.types.ts @@ -6,6 +6,7 @@ export enum OpenArchiverFeature { RETENTION_POLICY = 'retention-policy', LEGAL_HOLDS = 'legal-holds', INTEGRITY_REPORT = 'integrity-report', + JOURNALING = 'journaling', SSO = 'sso', STATUS = 'status', ALL = 'all', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c0f56eb..7018014 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -334,6 +334,9 @@ importers: tailwind-variants: specifier: ^1.0.0 version: 1.0.0(tailwindcss@4.1.11) + tippy.js: + specifier: ^6.3.7 + version: 6.3.7 devDependencies: '@internationalized/date': specifier: ^3.8.2 @@ -1313,6 +1316,9 @@ packages: '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + '@popperjs/core@2.11.8': + resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} + '@rollup/plugin-commonjs@28.0.6': resolution: {integrity: sha512-XSQB1K7FUU5QP+3lOQmVCE3I0FcbbNvmNT4VJSj93iUjayaARrTQeoRdiYQoftAJBLrR9t2agwAd3ekaTgHNlw==} engines: {node: '>=16.0.0 || 14 >= 14.17'} @@ -4580,6 +4586,9 @@ packages: resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==} engines: {node: '>=12.0.0'} + tippy.js@6.3.7: + resolution: {integrity: sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==} + tlds@1.259.0: resolution: {integrity: sha512-AldGGlDP0PNgwppe2quAvuBl18UcjuNtOnDuUkqhd6ipPqrYYBt3aTxK1QTsBVknk97lS2JcafWMghjGWFtunw==} hasBin: true @@ -5996,6 +6005,8 @@ snapshots: '@polka/url@1.0.0-next.29': {} + '@popperjs/core@2.11.8': {} + '@rollup/plugin-commonjs@28.0.6(rollup@4.44.2)': dependencies: '@rollup/pluginutils': 5.2.0(rollup@4.44.2) @@ -9535,6 +9546,10 @@ snapshots: fdir: 6.4.6(picomatch@4.0.2) picomatch: 4.0.2 + tippy.js@6.3.7: + dependencies: + '@popperjs/core': 2.11.8 + tlds@1.259.0: {} to-regex-range@5.0.1: