mirror of
https://github.com/LogicLabs-OU/OpenArchiver.git
synced 2026-04-06 00:31:57 +02:00
Archived email API, dark mode
This commit is contained in:
@@ -3,7 +3,8 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "dotenv -- pnpm --filter \"./packages/*\" --parallel dev",
|
||||
"build": "pnpm --filter \"./packages/*\" --parallel build"
|
||||
"build": "pnpm --filter \"./packages/*\" --parallel build",
|
||||
"start:workers": "dotenv -- pnpm --filter \"./packages/backend\" start:ingestion-worker && -- pnpm --filter \"./packages/backend\" start:indexing-worker"
|
||||
},
|
||||
"devDependencies": {
|
||||
"dotenv-cli": "8.0.0",
|
||||
|
||||
@@ -30,7 +30,10 @@
|
||||
"googleapis": "^152.0.0",
|
||||
"imapflow": "^1.0.191",
|
||||
"jose": "^6.0.11",
|
||||
"mailparser": "^3.7.4",
|
||||
"pg": "^8.16.3",
|
||||
"pino": "^9.7.0",
|
||||
"pino-pretty": "^13.0.0",
|
||||
"postgres": "^3.4.7",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"sqlite3": "^5.1.7",
|
||||
@@ -40,6 +43,7 @@
|
||||
"@bull-board/api": "^6.11.0",
|
||||
"@bull-board/express": "^6.11.0",
|
||||
"@types/express": "^5.0.3",
|
||||
"@types/mailparser": "^3.4.6",
|
||||
"@types/node": "^24.0.12",
|
||||
"bull-board": "^2.1.3",
|
||||
"drizzle-kit": "^0.31.4",
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { ArchivedEmailService } from '../../services/ArchivedEmailService';
|
||||
|
||||
export class ArchivedEmailController {
|
||||
public getArchivedEmails = async (req: Request, res: Response): Promise<Response> => {
|
||||
try {
|
||||
const { ingestionSourceId } = req.params;
|
||||
const page = parseInt(req.query.page as string, 10) || 1;
|
||||
const limit = parseInt(req.query.limit as string, 10) || 10;
|
||||
|
||||
const result = await ArchivedEmailService.getArchivedEmails(
|
||||
ingestionSourceId,
|
||||
page,
|
||||
limit
|
||||
);
|
||||
return res.status(200).json(result);
|
||||
} catch (error) {
|
||||
console.error('Get archived emails error:', error);
|
||||
return res.status(500).json({ message: 'An internal server error occurred' });
|
||||
}
|
||||
};
|
||||
|
||||
public getArchivedEmailById = async (req: Request, res: Response): Promise<Response> => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const email = await ArchivedEmailService.getArchivedEmailById(id);
|
||||
if (!email) {
|
||||
return res.status(404).json({ message: 'Archived email not found' });
|
||||
}
|
||||
return res.status(200).json(email);
|
||||
} catch (error) {
|
||||
console.error(`Get archived email by id ${req.params.id} error:`, error);
|
||||
return res.status(500).json({ message: 'An internal server error occurred' });
|
||||
}
|
||||
};
|
||||
}
|
||||
31
packages/backend/src/api/controllers/storage.controller.ts
Normal file
31
packages/backend/src/api/controllers/storage.controller.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { StorageService } from '../../services/StorageService';
|
||||
|
||||
export class StorageController {
|
||||
constructor(private storageService: StorageService) { }
|
||||
|
||||
public downloadFile = async (req: Request, res: Response): Promise<void> => {
|
||||
const filePath = req.query.path as string;
|
||||
|
||||
if (!filePath) {
|
||||
res.status(400).send('File path is required');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const fileExists = await this.storageService.exists(filePath);
|
||||
if (!fileExists) {
|
||||
res.status(404).send('File not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const fileStream = await this.storageService.get(filePath);
|
||||
const fileName = filePath.split('/').pop();
|
||||
res.setHeader('Content-Disposition', `attachment; filename=${fileName}`);
|
||||
fileStream.pipe(res);
|
||||
} catch (error) {
|
||||
console.error('Error downloading file:', error);
|
||||
res.status(500).send('Error downloading file');
|
||||
}
|
||||
};
|
||||
}
|
||||
20
packages/backend/src/api/routes/archived-email.routes.ts
Normal file
20
packages/backend/src/api/routes/archived-email.routes.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Router } from 'express';
|
||||
import { ArchivedEmailController } from '../controllers/archived-email.controller';
|
||||
import { requireAuth } from '../middleware/requireAuth';
|
||||
import { IAuthService } from '../../services/AuthService';
|
||||
|
||||
export const createArchivedEmailRouter = (
|
||||
archivedEmailController: ArchivedEmailController,
|
||||
authService: IAuthService
|
||||
): Router => {
|
||||
const router = Router();
|
||||
|
||||
// Secure all routes in this module
|
||||
router.use(requireAuth(authService));
|
||||
|
||||
router.get('/ingestion-source/:ingestionSourceId', archivedEmailController.getArchivedEmails);
|
||||
|
||||
router.get('/:id', archivedEmailController.getArchivedEmailById);
|
||||
|
||||
return router;
|
||||
};
|
||||
18
packages/backend/src/api/routes/storage.routes.ts
Normal file
18
packages/backend/src/api/routes/storage.routes.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Router } from 'express';
|
||||
import { StorageController } from '../controllers/storage.controller';
|
||||
import { requireAuth } from '../middleware/requireAuth';
|
||||
import { IAuthService } from '../../services/AuthService';
|
||||
|
||||
export const createStorageRouter = (
|
||||
storageController: StorageController,
|
||||
authService: IAuthService
|
||||
): Router => {
|
||||
const router = Router();
|
||||
|
||||
// Secure all routes in this module
|
||||
router.use(requireAuth(authService));
|
||||
|
||||
router.get('/download', storageController.downloadFile);
|
||||
|
||||
return router;
|
||||
};
|
||||
18
packages/backend/src/api/routes/test.routes.ts
Normal file
18
packages/backend/src/api/routes/test.routes.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Router } from 'express';
|
||||
import { ingestionQueue } from '../../jobs/queues';
|
||||
|
||||
const router: Router = Router();
|
||||
|
||||
router.post('/trigger-job', async (req, res) => {
|
||||
try {
|
||||
const job = await ingestionQueue.add('initial-import', {
|
||||
ingestionSourceId: 'test-source-id-test-2345'
|
||||
});
|
||||
res.status(202).json({ message: 'Test job triggered successfully', jobId: job.id });
|
||||
} catch (error) {
|
||||
console.error('Failed to trigger test job', error);
|
||||
res.status(500).json({ message: 'Failed to trigger test job' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
11
packages/backend/src/config/logger.ts
Normal file
11
packages/backend/src/config/logger.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import pino from 'pino';
|
||||
|
||||
export const logger = pino({
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
transport: {
|
||||
target: 'pino-pretty',
|
||||
options: {
|
||||
colorize: true
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -6,5 +6,7 @@ import 'dotenv/config';
|
||||
export const connection = {
|
||||
host: process.env.REDIS_HOST || 'localhost',
|
||||
port: (process.env.REDIS_PORT && parseInt(process.env.REDIS_PORT, 10)) || 6379,
|
||||
maxRetriesPerRequest: null
|
||||
password: process.env.REDIS_PASSWORD,
|
||||
maxRetriesPerRequest: null,
|
||||
tls: {} // Enable TLS for Upstash
|
||||
};
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TYPE "public"."ingestion_status" ADD VALUE 'auth_success';
|
||||
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE "archived_emails" DROP CONSTRAINT "archived_emails_custodian_id_custodians_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "archived_emails" ADD COLUMN "ingestion_source_id" uuid NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "archived_emails" ADD CONSTRAINT "archived_emails_ingestion_source_id_ingestion_sources_id_fk" FOREIGN KEY ("ingestion_source_id") REFERENCES "public"."ingestion_sources"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "archived_emails" DROP COLUMN "custodian_id";
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "archived_emails" ALTER COLUMN "message_id_header" DROP NOT NULL;
|
||||
819
packages/backend/src/database/migrations/meta/0001_snapshot.json
Normal file
819
packages/backend/src/database/migrations/meta/0001_snapshot.json
Normal file
@@ -0,0 +1,819 @@
|
||||
{
|
||||
"id": "9f4ccc8d-aafa-43de-abf6-f85034dba904",
|
||||
"prevId": "3fe238cc-60db-4ddb-8945-11db89bdee2b",
|
||||
"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()"
|
||||
},
|
||||
"custodian_id": {
|
||||
"name": "custodian_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"message_id_header": {
|
||||
"name": "message_id_header",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"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()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"archived_emails_custodian_id_custodians_id_fk": {
|
||||
"name": "archived_emails_custodian_id_custodians_id_fk",
|
||||
"tableFrom": "archived_emails",
|
||||
"tableTo": "custodians",
|
||||
"columnsFrom": [
|
||||
"custodian_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
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"attachments_content_hash_sha256_unique": {
|
||||
"name": "attachments_content_hash_sha256_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"content_hash_sha256"
|
||||
]
|
||||
}
|
||||
},
|
||||
"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
|
||||
},
|
||||
"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
|
||||
},
|
||||
"action": {
|
||||
"name": "action",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"target_type": {
|
||||
"name": "target_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"target_id": {
|
||||
"name": "target_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"details": {
|
||||
"name": "details",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"is_tamper_evident": {
|
||||
"name": "is_tamper_evident",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"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.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()"
|
||||
},
|
||||
"case_id": {
|
||||
"name": "case_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"custodian_id": {
|
||||
"name": "custodian_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"hold_criteria": {
|
||||
"name": "hold_criteria",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"reason": {
|
||||
"name": "reason",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"applied_by_identifier": {
|
||||
"name": "applied_by_identifier",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"applied_at": {
|
||||
"name": "applied_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"removed_at": {
|
||||
"name": "removed_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"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": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"legal_holds_custodian_id_custodians_id_fk": {
|
||||
"name": "legal_holds_custodian_id_custodians_id_fk",
|
||||
"tableFrom": "legal_holds",
|
||||
"tableTo": "custodians",
|
||||
"columnsFrom": [
|
||||
"custodian_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.retention_policies": {
|
||||
"name": "retention_policies",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"priority": {
|
||||
"name": "priority",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"retention_period_days": {
|
||||
"name": "retention_period_days",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"action_on_expiry": {
|
||||
"name": "action_on_expiry",
|
||||
"type": "retention_action",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"is_enabled": {
|
||||
"name": "is_enabled",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": true
|
||||
},
|
||||
"conditions": {
|
||||
"name": "conditions",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"retention_policies_name_unique": {
|
||||
"name": "retention_policies_name_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"name"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.custodians": {
|
||||
"name": "custodians",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"display_name": {
|
||||
"name": "display_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"source_type": {
|
||||
"name": "source_type",
|
||||
"type": "ingestion_provider",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"custodians_email_unique": {
|
||||
"name": "custodians_email_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"email"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.ingestion_sources": {
|
||||
"name": "ingestion_sources",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"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": "jsonb",
|
||||
"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
|
||||
},
|
||||
"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": {},
|
||||
"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"
|
||||
]
|
||||
},
|
||||
"public.ingestion_status": {
|
||||
"name": "ingestion_status",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"active",
|
||||
"paused",
|
||||
"error",
|
||||
"pending_auth",
|
||||
"syncing",
|
||||
"auth_success"
|
||||
]
|
||||
}
|
||||
},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
819
packages/backend/src/database/migrations/meta/0002_snapshot.json
Normal file
819
packages/backend/src/database/migrations/meta/0002_snapshot.json
Normal file
@@ -0,0 +1,819 @@
|
||||
{
|
||||
"id": "bb68c4a0-16d6-40c6-891d-200348601f91",
|
||||
"prevId": "9f4ccc8d-aafa-43de-abf6-f85034dba904",
|
||||
"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()"
|
||||
},
|
||||
"ingestion_source_id": {
|
||||
"name": "ingestion_source_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"message_id_header": {
|
||||
"name": "message_id_header",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"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()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"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
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"attachments_content_hash_sha256_unique": {
|
||||
"name": "attachments_content_hash_sha256_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"content_hash_sha256"
|
||||
]
|
||||
}
|
||||
},
|
||||
"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
|
||||
},
|
||||
"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
|
||||
},
|
||||
"action": {
|
||||
"name": "action",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"target_type": {
|
||||
"name": "target_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"target_id": {
|
||||
"name": "target_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"details": {
|
||||
"name": "details",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"is_tamper_evident": {
|
||||
"name": "is_tamper_evident",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"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.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()"
|
||||
},
|
||||
"case_id": {
|
||||
"name": "case_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"custodian_id": {
|
||||
"name": "custodian_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"hold_criteria": {
|
||||
"name": "hold_criteria",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"reason": {
|
||||
"name": "reason",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"applied_by_identifier": {
|
||||
"name": "applied_by_identifier",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"applied_at": {
|
||||
"name": "applied_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"removed_at": {
|
||||
"name": "removed_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"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": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"legal_holds_custodian_id_custodians_id_fk": {
|
||||
"name": "legal_holds_custodian_id_custodians_id_fk",
|
||||
"tableFrom": "legal_holds",
|
||||
"tableTo": "custodians",
|
||||
"columnsFrom": [
|
||||
"custodian_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.retention_policies": {
|
||||
"name": "retention_policies",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"priority": {
|
||||
"name": "priority",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"retention_period_days": {
|
||||
"name": "retention_period_days",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"action_on_expiry": {
|
||||
"name": "action_on_expiry",
|
||||
"type": "retention_action",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"is_enabled": {
|
||||
"name": "is_enabled",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": true
|
||||
},
|
||||
"conditions": {
|
||||
"name": "conditions",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"retention_policies_name_unique": {
|
||||
"name": "retention_policies_name_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"name"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.custodians": {
|
||||
"name": "custodians",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"display_name": {
|
||||
"name": "display_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"source_type": {
|
||||
"name": "source_type",
|
||||
"type": "ingestion_provider",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"custodians_email_unique": {
|
||||
"name": "custodians_email_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"email"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.ingestion_sources": {
|
||||
"name": "ingestion_sources",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"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": "jsonb",
|
||||
"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
|
||||
},
|
||||
"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": {},
|
||||
"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"
|
||||
]
|
||||
},
|
||||
"public.ingestion_status": {
|
||||
"name": "ingestion_status",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"active",
|
||||
"paused",
|
||||
"error",
|
||||
"pending_auth",
|
||||
"syncing",
|
||||
"auth_success"
|
||||
]
|
||||
}
|
||||
},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
819
packages/backend/src/database/migrations/meta/0003_snapshot.json
Normal file
819
packages/backend/src/database/migrations/meta/0003_snapshot.json
Normal file
@@ -0,0 +1,819 @@
|
||||
{
|
||||
"id": "0a5303e6-3d82-4687-bb45-0267a6c72130",
|
||||
"prevId": "bb68c4a0-16d6-40c6-891d-200348601f91",
|
||||
"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()"
|
||||
},
|
||||
"ingestion_source_id": {
|
||||
"name": "ingestion_source_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"message_id_header": {
|
||||
"name": "message_id_header",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"sent_at": {
|
||||
"name": "sent_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"subject": {
|
||||
"name": "subject",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"sender_name": {
|
||||
"name": "sender_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"sender_email": {
|
||||
"name": "sender_email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"recipients": {
|
||||
"name": "recipients",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"storage_path": {
|
||||
"name": "storage_path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"storage_hash_sha256": {
|
||||
"name": "storage_hash_sha256",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"size_bytes": {
|
||||
"name": "size_bytes",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"is_indexed": {
|
||||
"name": "is_indexed",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"has_attachments": {
|
||||
"name": "has_attachments",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"is_on_legal_hold": {
|
||||
"name": "is_on_legal_hold",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"archived_at": {
|
||||
"name": "archived_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"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
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"attachments_content_hash_sha256_unique": {
|
||||
"name": "attachments_content_hash_sha256_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"content_hash_sha256"
|
||||
]
|
||||
}
|
||||
},
|
||||
"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
|
||||
},
|
||||
"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
|
||||
},
|
||||
"action": {
|
||||
"name": "action",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"target_type": {
|
||||
"name": "target_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"target_id": {
|
||||
"name": "target_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"details": {
|
||||
"name": "details",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"is_tamper_evident": {
|
||||
"name": "is_tamper_evident",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"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.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()"
|
||||
},
|
||||
"case_id": {
|
||||
"name": "case_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"custodian_id": {
|
||||
"name": "custodian_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"hold_criteria": {
|
||||
"name": "hold_criteria",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"reason": {
|
||||
"name": "reason",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"applied_by_identifier": {
|
||||
"name": "applied_by_identifier",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"applied_at": {
|
||||
"name": "applied_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"removed_at": {
|
||||
"name": "removed_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"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": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"legal_holds_custodian_id_custodians_id_fk": {
|
||||
"name": "legal_holds_custodian_id_custodians_id_fk",
|
||||
"tableFrom": "legal_holds",
|
||||
"tableTo": "custodians",
|
||||
"columnsFrom": [
|
||||
"custodian_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.retention_policies": {
|
||||
"name": "retention_policies",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"priority": {
|
||||
"name": "priority",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"retention_period_days": {
|
||||
"name": "retention_period_days",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"action_on_expiry": {
|
||||
"name": "action_on_expiry",
|
||||
"type": "retention_action",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"is_enabled": {
|
||||
"name": "is_enabled",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": true
|
||||
},
|
||||
"conditions": {
|
||||
"name": "conditions",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"retention_policies_name_unique": {
|
||||
"name": "retention_policies_name_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"name"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.custodians": {
|
||||
"name": "custodians",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"display_name": {
|
||||
"name": "display_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"source_type": {
|
||||
"name": "source_type",
|
||||
"type": "ingestion_provider",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"custodians_email_unique": {
|
||||
"name": "custodians_email_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"email"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.ingestion_sources": {
|
||||
"name": "ingestion_sources",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"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": "jsonb",
|
||||
"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
|
||||
},
|
||||
"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": {},
|
||||
"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"
|
||||
]
|
||||
},
|
||||
"public.ingestion_status": {
|
||||
"name": "ingestion_status",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"active",
|
||||
"paused",
|
||||
"error",
|
||||
"pending_auth",
|
||||
"syncing",
|
||||
"auth_success"
|
||||
]
|
||||
}
|
||||
},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,27 @@
|
||||
"when": 1752225352591,
|
||||
"tag": "0000_amusing_namora",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1752326803882,
|
||||
"tag": "0001_odd_night_thrasher",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1752332648392,
|
||||
"tag": "0002_lethal_quentin_quire",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "7",
|
||||
"when": 1752332967084,
|
||||
"tag": "0003_petite_wrecker",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
import { relations } from 'drizzle-orm';
|
||||
import { boolean, jsonb, pgTable, text, timestamp, uuid, bigint } from 'drizzle-orm/pg-core';
|
||||
import { custodians } from './custodians';
|
||||
import { ingestionSources } from './ingestion-sources';
|
||||
|
||||
export const archivedEmails = pgTable('archived_emails', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
custodianId: uuid('custodian_id').notNull().references(() => custodians.id, { onDelete: 'cascade' }),
|
||||
messageIdHeader: text('message_id_header').notNull(),
|
||||
ingestionSourceId: uuid('ingestion_source_id')
|
||||
.notNull()
|
||||
.references(() => ingestionSources.id, { onDelete: 'cascade' }),
|
||||
messageIdHeader: text('message_id_header'),
|
||||
sentAt: timestamp('sent_at', { withTimezone: true }).notNull(),
|
||||
subject: text('subject'),
|
||||
senderName: text('sender_name'),
|
||||
@@ -21,8 +23,8 @@ export const archivedEmails = pgTable('archived_emails', {
|
||||
});
|
||||
|
||||
export const archivedEmailsRelations = relations(archivedEmails, ({ one }) => ({
|
||||
custodian: one(custodians, {
|
||||
fields: [archivedEmails.custodianId],
|
||||
references: [custodians.id],
|
||||
}),
|
||||
ingestionSource: one(ingestionSources, {
|
||||
fields: [archivedEmails.ingestionSourceId],
|
||||
references: [ingestionSources.id]
|
||||
})
|
||||
}));
|
||||
|
||||
@@ -2,11 +2,17 @@ import express from 'express';
|
||||
import dotenv from 'dotenv';
|
||||
import { AuthController } from './api/controllers/auth.controller';
|
||||
import { IngestionController } from './api/controllers/ingestion.controller';
|
||||
import { ArchivedEmailController } from './api/controllers/archived-email.controller';
|
||||
import { StorageController } from './api/controllers/storage.controller';
|
||||
import { requireAuth } from './api/middleware/requireAuth';
|
||||
import { createAuthRouter } from './api/routes/auth.routes';
|
||||
import { createIngestionRouter } from './api/routes/ingestion.routes';
|
||||
import { createArchivedEmailRouter } from './api/routes/archived-email.routes';
|
||||
import { createStorageRouter } from './api/routes/storage.routes';
|
||||
import testRouter from './api/routes/test.routes';
|
||||
import { AuthService } from './services/AuthService';
|
||||
import { AdminUserService } from './services/UserService';
|
||||
import { StorageService } from './services/StorageService';
|
||||
|
||||
|
||||
|
||||
@@ -31,6 +37,9 @@ const userService = new AdminUserService();
|
||||
const authService = new AuthService(userService, JWT_SECRET, JWT_EXPIRES_IN);
|
||||
const authController = new AuthController(authService);
|
||||
const ingestionController = new IngestionController();
|
||||
const archivedEmailController = new ArchivedEmailController();
|
||||
const storageService = new StorageService();
|
||||
const storageController = new StorageController(storageService);
|
||||
|
||||
// --- Express App Initialization ---
|
||||
const app = express();
|
||||
@@ -41,8 +50,13 @@ app.use(express.json()); // For parsing application/json
|
||||
// --- Routes ---
|
||||
const authRouter = createAuthRouter(authController);
|
||||
const ingestionRouter = createIngestionRouter(ingestionController, authService);
|
||||
const archivedEmailRouter = createArchivedEmailRouter(archivedEmailController, authService);
|
||||
const storageRouter = createStorageRouter(storageController, authService);
|
||||
app.use('/v1/auth', authRouter);
|
||||
app.use('/v1/ingestion-sources', ingestionRouter);
|
||||
app.use('/v1/archived-emails', archivedEmailRouter);
|
||||
app.use('/v1/storage', storageRouter);
|
||||
app.use('/v1/test', testRouter);
|
||||
|
||||
// Example of a protected route
|
||||
app.get('/v1/protected', requireAuth(authService), (req, res) => {
|
||||
|
||||
@@ -5,6 +5,10 @@ import { IInitialImportJob } from '@open-archive/types';
|
||||
const ingestionService = new IngestionService();
|
||||
|
||||
export default async (job: Job<IInitialImportJob>) => {
|
||||
console.log(`Processing initial import for ingestion source: ${job.data.ingestionSourceId}`);
|
||||
await ingestionService.performBulkImport(job.data);
|
||||
try {
|
||||
console.log(`Processing initial import for ingestion source: ${job.data.ingestionSourceId}`);
|
||||
await ingestionService.performBulkImport(job.data);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
117
packages/backend/src/services/ArchivedEmailService.ts
Normal file
117
packages/backend/src/services/ArchivedEmailService.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { count, desc, eq } from 'drizzle-orm';
|
||||
import { db } from '../database';
|
||||
import { archivedEmails, attachments, emailAttachments } from '../database/schema';
|
||||
import type { PaginatedArchivedEmails, ArchivedEmail, Recipient } from '@open-archive/types';
|
||||
import { StorageService } from './StorageService';
|
||||
import type { Readable } from 'stream';
|
||||
|
||||
interface DbRecipients {
|
||||
to: { name: string; address: string; }[];
|
||||
cc: { name: string; address: string; }[];
|
||||
bcc: { name: string; address: string; }[];
|
||||
}
|
||||
|
||||
async function streamToBuffer(stream: Readable): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
stream.on('data', (chunk) => chunks.push(chunk));
|
||||
stream.on('error', reject);
|
||||
stream.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
});
|
||||
}
|
||||
|
||||
export class ArchivedEmailService {
|
||||
private static mapRecipients(dbRecipients: unknown): Recipient[] {
|
||||
const { to = [], cc = [], bcc = [] } = dbRecipients as DbRecipients;
|
||||
|
||||
const allRecipients = [...to, ...cc, ...bcc];
|
||||
|
||||
return allRecipients.map((r) => ({
|
||||
name: r.name,
|
||||
email: r.address
|
||||
}));
|
||||
}
|
||||
|
||||
public static async getArchivedEmails(
|
||||
ingestionSourceId: string,
|
||||
page: number,
|
||||
limit: number
|
||||
): Promise<PaginatedArchivedEmails> {
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const [total] = await db
|
||||
.select({
|
||||
count: count(archivedEmails.id)
|
||||
})
|
||||
.from(archivedEmails)
|
||||
.where(eq(archivedEmails.ingestionSourceId, ingestionSourceId));
|
||||
|
||||
const items = await db
|
||||
.select()
|
||||
.from(archivedEmails)
|
||||
.where(eq(archivedEmails.ingestionSourceId, ingestionSourceId))
|
||||
.orderBy(desc(archivedEmails.sentAt))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
return {
|
||||
items: items.map((item) => ({
|
||||
...item,
|
||||
recipients: this.mapRecipients(item.recipients)
|
||||
})),
|
||||
total: total.count,
|
||||
page,
|
||||
limit
|
||||
};
|
||||
}
|
||||
|
||||
public static async getArchivedEmailById(emailId: string): Promise<ArchivedEmail | null> {
|
||||
const [email] = await db
|
||||
.select()
|
||||
.from(archivedEmails)
|
||||
.where(eq(archivedEmails.id, emailId));
|
||||
|
||||
if (!email) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const storage = new StorageService();
|
||||
const rawStream = await storage.get(email.storagePath);
|
||||
const raw = await streamToBuffer(rawStream as Readable);
|
||||
|
||||
const mappedEmail = {
|
||||
...email,
|
||||
recipients: this.mapRecipients(email.recipients),
|
||||
raw
|
||||
};
|
||||
|
||||
if (email.hasAttachments) {
|
||||
const emailAttachmentsResult = await db
|
||||
.select({
|
||||
id: attachments.id,
|
||||
filename: attachments.filename,
|
||||
mimeType: attachments.mimeType,
|
||||
sizeBytes: attachments.sizeBytes,
|
||||
storagePath: attachments.storagePath
|
||||
})
|
||||
.from(emailAttachments)
|
||||
.innerJoin(attachments, eq(emailAttachments.attachmentId, attachments.id))
|
||||
.where(eq(emailAttachments.emailId, emailId));
|
||||
|
||||
// const attachmentsWithRaw = await Promise.all(
|
||||
// emailAttachmentsResult.map(async (attachment) => {
|
||||
// const rawStream = await storage.get(attachment.storagePath);
|
||||
// const raw = await streamToBuffer(rawStream as Readable);
|
||||
// return { ...attachment, raw };
|
||||
// })
|
||||
// );
|
||||
|
||||
return {
|
||||
...mappedEmail,
|
||||
attachments: emailAttachmentsResult
|
||||
};
|
||||
}
|
||||
|
||||
return mappedEmail;
|
||||
}
|
||||
}
|
||||
@@ -2,19 +2,13 @@ import type {
|
||||
IngestionSource,
|
||||
GoogleWorkspaceCredentials,
|
||||
Microsoft365Credentials,
|
||||
GenericImapCredentials
|
||||
GenericImapCredentials,
|
||||
EmailObject
|
||||
} from '@open-archive/types';
|
||||
import { GoogleConnector } from './ingestion-connectors/GoogleConnector';
|
||||
import { MicrosoftConnector } from './ingestion-connectors/MicrosoftConnector';
|
||||
import { ImapConnector } from './ingestion-connectors/ImapConnector';
|
||||
|
||||
// This is a placeholder for a real email object structure
|
||||
export interface EmailObject {
|
||||
id: string;
|
||||
headers: Record<string, any> | any[];
|
||||
body: string;
|
||||
}
|
||||
|
||||
// Define a common interface for all connectors
|
||||
export interface IEmailConnector {
|
||||
testConnection(): Promise<boolean>;
|
||||
|
||||
@@ -11,7 +11,10 @@ import { CryptoService } from './CryptoService';
|
||||
import { EmailProviderFactory } from './EmailProviderFactory';
|
||||
import { ingestionQueue, indexingQueue } from '../jobs/queues';
|
||||
import { StorageService } from './StorageService';
|
||||
import type { IInitialImportJob } from '@open-archive/types';
|
||||
import type { IInitialImportJob, EmailObject } from '@open-archive/types';
|
||||
import { archivedEmails, attachments as attachmentsSchema, emailAttachments } from '../database/schema';
|
||||
import { createHash } from 'crypto';
|
||||
import { logger } from '../config/logger';
|
||||
|
||||
|
||||
export class IngestionService {
|
||||
@@ -68,6 +71,9 @@ export class IngestionService {
|
||||
const { providerConfig, ...rest } = dto;
|
||||
const valuesToUpdate: Partial<typeof ingestionSources.$inferInsert> = { ...rest };
|
||||
|
||||
// Get the original source to compare the status later
|
||||
const originalSource = await this.findById(id);
|
||||
|
||||
if (providerConfig) {
|
||||
// Encrypt the new credentials before updating
|
||||
valuesToUpdate.credentials = CryptoService.encryptObject(providerConfig);
|
||||
@@ -82,7 +88,18 @@ export class IngestionService {
|
||||
if (!updatedSource) {
|
||||
throw new Error('Ingestion source not found');
|
||||
}
|
||||
return this.decryptSource(updatedSource);
|
||||
|
||||
const decryptedSource = this.decryptSource(updatedSource);
|
||||
|
||||
// If the status has changed to auth_success, trigger the initial import
|
||||
if (
|
||||
originalSource.status !== 'auth_success' &&
|
||||
decryptedSource.status === 'auth_success'
|
||||
) {
|
||||
await this.triggerInitialImport(decryptedSource.id);
|
||||
}
|
||||
|
||||
return decryptedSource;
|
||||
}
|
||||
|
||||
public static async delete(id: string): Promise<IngestionSource> {
|
||||
@@ -105,9 +122,9 @@ export class IngestionService {
|
||||
}
|
||||
|
||||
public async performBulkImport(job: IInitialImportJob): Promise<void> {
|
||||
console.log('performing bulk import');
|
||||
const { ingestionSourceId } = job;
|
||||
const source = await IngestionService.findById(ingestionSourceId);
|
||||
|
||||
if (!source) {
|
||||
throw new Error(`Ingestion source ${ingestionSourceId} not found.`);
|
||||
}
|
||||
@@ -123,12 +140,7 @@ export class IngestionService {
|
||||
|
||||
try {
|
||||
for await (const email of connector.fetchEmails()) {
|
||||
const filePath = `${source.id}/${email.id}.eml`;
|
||||
await storage.put(filePath, Buffer.from(email.body, 'utf-8'));
|
||||
await indexingQueue.add('index-email', {
|
||||
filePath,
|
||||
ingestionSourceId: source.id
|
||||
});
|
||||
await this.processEmail(email, source, storage);
|
||||
}
|
||||
|
||||
await IngestionService.update(ingestionSourceId, {
|
||||
@@ -147,4 +159,83 @@ export class IngestionService {
|
||||
throw error; // Re-throw to allow BullMQ to handle the job failure
|
||||
}
|
||||
}
|
||||
|
||||
private async processEmail(
|
||||
email: EmailObject,
|
||||
source: IngestionSource,
|
||||
storage: StorageService
|
||||
): Promise<void> {
|
||||
try {
|
||||
console.log('processing email, ', email.id);
|
||||
const emlBuffer = email.eml ?? Buffer.from(email.body, 'utf-8');
|
||||
const emailHash = createHash('sha256').update(emlBuffer).digest('hex');
|
||||
const emailPath = `email-archive/${source.name.replaceAll(' ', '-')}-${source.id}/emails/${email.id}.eml`;
|
||||
await storage.put(emailPath, emlBuffer);
|
||||
|
||||
const [archivedEmail] = await db
|
||||
.insert(archivedEmails)
|
||||
.values({
|
||||
ingestionSourceId: source.id,
|
||||
messageIdHeader:
|
||||
(email.headers['message-id'] as string) ??
|
||||
`generated-${emailHash}-${source.id}-${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: emlBuffer.length,
|
||||
hasAttachments: email.attachments.length > 0
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (email.attachments.length > 0) {
|
||||
for (const attachment of email.attachments) {
|
||||
const attachmentBuffer = attachment.content;
|
||||
const attachmentHash = createHash('sha256').update(attachmentBuffer).digest('hex');
|
||||
const attachmentPath = `email-archive/${source.name.replaceAll(' ', '-')}-${source.id}/attachments/${attachment.filename}`;
|
||||
await storage.put(attachmentPath, attachmentBuffer);
|
||||
|
||||
const [newAttachment] = await db
|
||||
.insert(attachmentsSchema)
|
||||
.values({
|
||||
filename: attachment.filename,
|
||||
mimeType: attachment.contentType,
|
||||
sizeBytes: attachment.size,
|
||||
contentHashSha256: attachmentHash,
|
||||
storagePath: attachmentPath
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: attachmentsSchema.contentHashSha256,
|
||||
set: { filename: attachment.filename }
|
||||
})
|
||||
.returning();
|
||||
|
||||
await db.insert(emailAttachments).values({
|
||||
emailId: archivedEmail.id,
|
||||
attachmentId: newAttachment.id
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Uncomment when indexing feature is done
|
||||
// await indexingQueue.add('index-email', {
|
||||
// filePath: emailPath,
|
||||
// ingestionSourceId: source.id
|
||||
// });
|
||||
} catch (error) {
|
||||
logger.error({
|
||||
message: `Failed to process email ${email.id} for source ${source.id}`,
|
||||
error,
|
||||
emailId: email.id,
|
||||
ingestionSourceId: source.id
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { GoogleWorkspaceCredentials } from '@open-archive/types';
|
||||
import type { IEmailConnector, EmailObject } from '../EmailProviderFactory';
|
||||
import type { GoogleWorkspaceCredentials, EmailObject, EmailAddress } from '@open-archive/types';
|
||||
import type { IEmailConnector } from '../EmailProviderFactory';
|
||||
import { google } from 'googleapis';
|
||||
import { simpleParser, ParsedMail, Attachment, AddressObject } from 'mailparser';
|
||||
import { OAuth2Client } from 'google-auth-library';
|
||||
import type { gmail_v1 } from 'googleapis';
|
||||
|
||||
@@ -44,13 +45,43 @@ export class GoogleConnector implements IEmailConnector {
|
||||
const msg = await gmail.users.messages.get({
|
||||
userId: 'me',
|
||||
id: message.id,
|
||||
format: 'raw',
|
||||
format: 'raw'
|
||||
});
|
||||
if (msg.data.raw) {
|
||||
const emlBuffer = Buffer.from(msg.data.raw, 'base64');
|
||||
const parsedEmail: ParsedMail = await simpleParser(emlBuffer);
|
||||
const attachments = parsedEmail.attachments.map((attachment: Attachment) => ({
|
||||
filename: attachment.filename || 'untitled',
|
||||
contentType: attachment.contentType,
|
||||
size: attachment.size,
|
||||
content: 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 || '' }))
|
||||
);
|
||||
};
|
||||
|
||||
yield {
|
||||
id: msg.data.id!,
|
||||
headers: msg.data.payload?.headers || [],
|
||||
body: Buffer.from(msg.data.raw, 'base64').toString('utf-8'),
|
||||
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 as any,
|
||||
attachments,
|
||||
receivedAt: parsedEmail.date || new Date(),
|
||||
eml: emlBuffer
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { GenericImapCredentials } from '@open-archive/types';
|
||||
import type { IEmailConnector, EmailObject } from '../EmailProviderFactory';
|
||||
import type { GenericImapCredentials, EmailObject, EmailAddress } from '@open-archive/types';
|
||||
import type { IEmailConnector } from '../EmailProviderFactory';
|
||||
import { ImapFlow } from 'imapflow';
|
||||
import { simpleParser, ParsedMail, Attachment, AddressObject } from 'mailparser';
|
||||
|
||||
export class ImapConnector implements IEmailConnector {
|
||||
private client: ImapFlow;
|
||||
@@ -36,12 +37,35 @@ export class ImapConnector implements IEmailConnector {
|
||||
|
||||
const searchCriteria = since ? { since } : { all: true };
|
||||
|
||||
for await (const msg of this.client.fetch(searchCriteria, { envelope: true, source: true })) {
|
||||
for await (const msg of this.client.fetch(searchCriteria, { envelope: true, source: true, bodyStructure: true })) {
|
||||
if (msg.envelope && msg.source) {
|
||||
const parsedEmail: ParsedMail = await simpleParser(msg.source);
|
||||
const attachments = parsedEmail.attachments.map((attachment: Attachment) => ({
|
||||
filename: attachment.filename || 'untitled',
|
||||
contentType: attachment.contentType,
|
||||
size: attachment.size,
|
||||
content: 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 || '' })));
|
||||
};
|
||||
|
||||
yield {
|
||||
id: msg.uid.toString(),
|
||||
headers: msg.envelope,
|
||||
body: msg.source.toString(),
|
||||
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 as any,
|
||||
attachments,
|
||||
receivedAt: parsedEmail.date || new Date(),
|
||||
eml: msg.source
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Microsoft365Credentials } from '@open-archive/types';
|
||||
import type { IEmailConnector, EmailObject } from '../EmailProviderFactory';
|
||||
import type { Microsoft365Credentials, EmailObject, EmailAddress } from '@open-archive/types';
|
||||
import type { IEmailConnector } from '../EmailProviderFactory';
|
||||
import { ConfidentialClientApplication } from '@azure/msal-node';
|
||||
import { simpleParser, ParsedMail, Attachment, AddressObject } from 'mailparser';
|
||||
import axios from 'axios';
|
||||
|
||||
const GRAPH_API_ENDPOINT = 'https://graph.microsoft.com/v1.0';
|
||||
@@ -54,16 +55,42 @@ export class MicrosoftConnector implements IEmailConnector {
|
||||
const messages = res.data.value;
|
||||
|
||||
for (const message of messages) {
|
||||
// The raw MIME content is not directly available in the list view.
|
||||
// A second request is needed to get the full content.
|
||||
const rawContentRes = await axios.get(
|
||||
`${GRAPH_API_ENDPOINT}/users/me/messages/${message.id}/$value`,
|
||||
{ headers }
|
||||
);
|
||||
const emlBuffer = Buffer.from(rawContentRes.data, 'utf-8');
|
||||
const parsedEmail: ParsedMail = await simpleParser(emlBuffer);
|
||||
const attachments = parsedEmail.attachments.map((attachment: Attachment) => ({
|
||||
filename: attachment.filename || 'untitled',
|
||||
contentType: attachment.contentType,
|
||||
size: attachment.size,
|
||||
content: 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 || '' }))
|
||||
);
|
||||
};
|
||||
|
||||
yield {
|
||||
id: message.id,
|
||||
headers: message, // The list response contains most headers
|
||||
body: rawContentRes.data,
|
||||
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 as any,
|
||||
attachments,
|
||||
receivedAt: parsedEmail.date || new Date(),
|
||||
eml: emlBuffer
|
||||
};
|
||||
}
|
||||
nextLink = res.data['@odata.nextLink'];
|
||||
|
||||
@@ -16,7 +16,9 @@
|
||||
"dependencies": {
|
||||
"@open-archive/types": "workspace:*",
|
||||
"jose": "^6.0.1",
|
||||
"lucide-svelte": "^0.525.0"
|
||||
"lucide-svelte": "^0.525.0",
|
||||
"postal-mime": "^2.4.4",
|
||||
"svelte-persisted-store": "^0.12.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@internationalized/date": "^3.8.2",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<script lang="ts">
|
||||
import PostalMime, { type Email } from 'postal-mime';
|
||||
import type { Buffer } from 'buffer';
|
||||
|
||||
let { raw }: { raw: Buffer | { type: 'Buffer'; data: number[] } | undefined } = $props();
|
||||
|
||||
let parsedEmail: Email | null = $state(null);
|
||||
let isLoading = $state(true);
|
||||
|
||||
// By adding a <base> tag, all relative and absolute links in the HTML document
|
||||
// will open in a new tab by default.
|
||||
let emailHtml = $derived(() => {
|
||||
if (parsedEmail?.html) {
|
||||
return `<base target="_blank" />${parsedEmail.html}`;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
async function parseEmail() {
|
||||
if (raw) {
|
||||
try {
|
||||
let buffer: Uint8Array;
|
||||
if ('type' in raw && raw.type === 'Buffer') {
|
||||
buffer = new Uint8Array(raw.data);
|
||||
} else {
|
||||
buffer = new Uint8Array(raw as Buffer);
|
||||
}
|
||||
const parsed = await new PostalMime().parse(buffer);
|
||||
parsedEmail = parsed;
|
||||
} catch (error) {
|
||||
console.error('Failed to parse email:', error);
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
} else {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
parseEmail();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="mt-2 rounded-md border bg-white p-4">
|
||||
{#if isLoading}
|
||||
<p>Loading email preview...</p>
|
||||
{:else if emailHtml}
|
||||
<iframe title="Email Preview" srcdoc={emailHtml()} class="h-[600px] w-full border-none"
|
||||
></iframe>
|
||||
{:else if raw}
|
||||
<p>Could not render email preview.</p>
|
||||
{:else}
|
||||
<p class="text-gray-500">Raw .eml file not available for this email.</p>
|
||||
{/if}
|
||||
</div>
|
||||
11
packages/frontend/src/lib/components/custom/Footer.svelte
Normal file
11
packages/frontend/src/lib/components/custom/Footer.svelte
Normal file
@@ -0,0 +1,11 @@
|
||||
<footer class="border-t py-6 md:py-0">
|
||||
<div
|
||||
class="container mx-auto flex flex-col items-center justify-center gap-4 md:h-24 md:flex-row"
|
||||
>
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<p class="text-muted-foreground text-balance text-center text-sm leading-loose">
|
||||
© {new Date().getFullYear()} OpenArchive. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -23,7 +23,7 @@
|
||||
let formData = $state({
|
||||
name: source?.name ?? '',
|
||||
provider: source?.provider ?? 'google_workspace',
|
||||
providerConfig: source?.providerConfig ?? {}
|
||||
providerConfig: source?.credentials ?? {}
|
||||
});
|
||||
|
||||
const triggerContent = $derived(
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
import { theme } from '$lib/stores/theme.store';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import { Sun, Moon, Laptop } from 'lucide-svelte';
|
||||
</script>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Sun
|
||||
class="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0"
|
||||
/>
|
||||
<Moon
|
||||
class="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100"
|
||||
/>
|
||||
<span class="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item onclick={() => ($theme = 'light')}>
|
||||
<Sun class="mr-2 h-4 w-4" />
|
||||
<span>Light</span>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={() => ($theme = 'dark')}>
|
||||
<Moon class="mr-2 h-4 w-4" />
|
||||
<span>Dark</span>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={() => ($theme = 'system')}>
|
||||
<Laptop class="mr-2 h-4 w-4" />
|
||||
<span>System</span>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
5
packages/frontend/src/lib/stores/theme.store.ts
Normal file
5
packages/frontend/src/lib/stores/theme.store.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { persisted } from 'svelte-persisted-store';
|
||||
|
||||
type Theme = 'light' | 'dark' | 'system';
|
||||
|
||||
export const theme = persisted<Theme>('theme', 'system');
|
||||
@@ -1,12 +1,29 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import { authStore } from '$lib/stores/auth.store';
|
||||
import { theme } from '$lib/stores/theme.store';
|
||||
import { browser } from '$app/environment';
|
||||
import Footer from '$lib/components/custom/Footer.svelte';
|
||||
|
||||
let { data, children } = $props();
|
||||
|
||||
$effect(() => {
|
||||
authStore.syncWithServer(data.user, data.accessToken);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (browser) {
|
||||
const isDark =
|
||||
$theme === 'dark' ||
|
||||
($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
document.documentElement.classList.toggle('dark', isDark);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
<div class="flex min-h-screen flex-col">
|
||||
<main class="flex-1">
|
||||
{@render children()}
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
|
||||
@@ -4,9 +4,11 @@
|
||||
import { authStore } from '$lib/stores/auth.store';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import ThemeSwitcher from '$lib/components/custom/ThemeSwitcher.svelte';
|
||||
const navItems = [
|
||||
{ href: '/dashboard', label: 'Dashboard' },
|
||||
{ href: '/dashboard/ingestions', label: 'Ingestions' }
|
||||
{ href: '/dashboard/ingestions', label: 'Ingestions' },
|
||||
{ href: '/dashboard/archived-emails', label: 'Archived emails' }
|
||||
];
|
||||
let { children } = $props();
|
||||
function handleLogout() {
|
||||
@@ -17,7 +19,10 @@
|
||||
|
||||
<header class="bg-background sticky top-0 z-40 border-b">
|
||||
<div class="container mx-auto flex h-16 flex-row items-center justify-between">
|
||||
<a href="/dashboard" class="text-primary text-lg font-semibold"> OpenArchive </a>
|
||||
<a href="/dashboard" class="text-primary flex flex-row items-center gap-2 font-bold">
|
||||
<img src="/logos/logo-sq.svg" alt="OpenArchive Logo" class="h-8 w-8" />
|
||||
<span>OpenArchive</span>
|
||||
</a>
|
||||
<NavigationMenu.Root>
|
||||
<NavigationMenu.List class="flex items-center space-x-4">
|
||||
{#each navItems as item}
|
||||
@@ -31,7 +36,10 @@
|
||||
{/each}
|
||||
</NavigationMenu.List>
|
||||
</NavigationMenu.Root>
|
||||
<Button onclick={handleLogout} variant="outline">Logout</Button>
|
||||
<div class="flex items-center gap-4">
|
||||
<ThemeSwitcher />
|
||||
<Button onclick={handleLogout} variant="outline">Logout</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { api } from '$lib/server/api';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import type { IngestionSource, PaginatedArchivedEmails } from '@open-archive/types';
|
||||
|
||||
export const load: PageServerLoad = async (event) => {
|
||||
try {
|
||||
const { url } = event;
|
||||
const ingestionSourceId = url.searchParams.get('ingestionSourceId');
|
||||
const page = url.searchParams.get('page') || '1';
|
||||
const limit = url.searchParams.get('limit') || '10';
|
||||
|
||||
const sourcesResponse = await api('/ingestion-sources', event);
|
||||
if (!sourcesResponse.ok) {
|
||||
throw new Error(`Failed to fetch ingestion sources: ${sourcesResponse.statusText}`);
|
||||
}
|
||||
const ingestionSources: IngestionSource[] = await sourcesResponse.json();
|
||||
|
||||
let archivedEmails: PaginatedArchivedEmails = {
|
||||
items: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
limit: 10
|
||||
};
|
||||
|
||||
const selectedIngestionSourceId = ingestionSourceId || ingestionSources[0]?.id;
|
||||
|
||||
if (selectedIngestionSourceId) {
|
||||
const emailsResponse = await api(
|
||||
`/archived-emails/ingestion-source/${selectedIngestionSourceId}?page=${page}&limit=${limit}`,
|
||||
event
|
||||
);
|
||||
if (!emailsResponse.ok) {
|
||||
throw new Error(`Failed to fetch archived emails: ${emailsResponse.statusText}`);
|
||||
}
|
||||
archivedEmails = await emailsResponse.json();
|
||||
}
|
||||
|
||||
return {
|
||||
ingestionSources,
|
||||
archivedEmails,
|
||||
selectedIngestionSourceId
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to load archived emails page:', error);
|
||||
return {
|
||||
ingestionSources: [],
|
||||
archivedEmails: {
|
||||
items: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
limit: 10
|
||||
},
|
||||
error: 'Failed to load data'
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,170 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
let ingestionSources = $derived(data.ingestionSources);
|
||||
let archivedEmails = $derived(data.archivedEmails);
|
||||
let selectedIngestionSourceId = $derived(data.selectedIngestionSourceId);
|
||||
|
||||
const handleSourceChange = (value: string | undefined) => {
|
||||
if (value) {
|
||||
goto(`/dashboard/archived-emails?ingestionSourceId=${value}`);
|
||||
}
|
||||
};
|
||||
|
||||
const getPaginationItems = (currentPage: number, totalPages: number, siblingCount = 1) => {
|
||||
const totalPageNumbers = siblingCount + 5;
|
||||
|
||||
if (totalPages <= totalPageNumbers) {
|
||||
return Array.from({ length: totalPages }, (_, i) => i + 1);
|
||||
}
|
||||
|
||||
const leftSiblingIndex = Math.max(currentPage - siblingCount, 1);
|
||||
const rightSiblingIndex = Math.min(currentPage + siblingCount, totalPages);
|
||||
|
||||
const shouldShowLeftDots = leftSiblingIndex > 2;
|
||||
const shouldShowRightDots = rightSiblingIndex < totalPages - 2;
|
||||
|
||||
const firstPageIndex = 1;
|
||||
const lastPageIndex = totalPages;
|
||||
|
||||
if (!shouldShowLeftDots && shouldShowRightDots) {
|
||||
let leftItemCount = 3 + 2 * siblingCount;
|
||||
let leftRange = Array.from({ length: leftItemCount }, (_, i) => i + 1);
|
||||
return [...leftRange, '...', totalPages];
|
||||
}
|
||||
|
||||
if (shouldShowLeftDots && !shouldShowRightDots) {
|
||||
let rightItemCount = 3 + 2 * siblingCount;
|
||||
let rightRange = Array.from(
|
||||
{ length: rightItemCount },
|
||||
(_, i) => totalPages - rightItemCount + i + 1
|
||||
);
|
||||
return [firstPageIndex, '...', ...rightRange];
|
||||
}
|
||||
|
||||
if (shouldShowLeftDots && shouldShowRightDots) {
|
||||
let middleRange = Array.from(
|
||||
{ length: rightSiblingIndex - leftSiblingIndex + 1 },
|
||||
(_, i) => leftSiblingIndex + i
|
||||
);
|
||||
return [firstPageIndex, '...', ...middleRange, '...', lastPageIndex];
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
let paginationItems = $derived(
|
||||
getPaginationItems(archivedEmails.page, Math.ceil(archivedEmails.total / archivedEmails.limit))
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold">Archived Emails</h1>
|
||||
{#if ingestionSources.length > 0}
|
||||
<div class="w-[250px]">
|
||||
<Select.Root
|
||||
type="single"
|
||||
onValueChange={handleSourceChange}
|
||||
value={selectedIngestionSourceId}
|
||||
>
|
||||
<Select.Trigger class="w-full">
|
||||
<span
|
||||
>{selectedIngestionSourceId
|
||||
? ingestionSources.find((s) => s.id === selectedIngestionSourceId)?.name
|
||||
: 'Select an ingestion source'}</span
|
||||
>
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each ingestionSources as source}
|
||||
<Select.Item value={source.id}>{source.name}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="rounded-md border">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head>Date</Table.Head>
|
||||
<Table.Head>Subject</Table.Head>
|
||||
<Table.Head>Sender</Table.Head>
|
||||
<Table.Head>Attachments</Table.Head>
|
||||
<Table.Head class="text-right">Actions</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#if archivedEmails.items.length > 0}
|
||||
{#each archivedEmails.items as email (email.id)}
|
||||
<Table.Row>
|
||||
<Table.Cell>{new Date(email.sentAt).toLocaleString()}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<a href={`/dashboard/archived-emails/${email.id}`}>
|
||||
{email.subject}
|
||||
</a>
|
||||
</Table.Cell>
|
||||
<Table.Cell>{email.senderEmail}</Table.Cell>
|
||||
<Table.Cell>{email.hasAttachments ? 'Yes' : 'No'}</Table.Cell>
|
||||
<Table.Cell class="text-right">
|
||||
<a href={`/dashboard/archived-emails/${email.id}`}>
|
||||
<Button variant="outline">View</Button>
|
||||
</a>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
{:else}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={5} class="text-center">No archived emails found.</Table.Cell>
|
||||
</Table.Row>
|
||||
{/if}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
|
||||
{#if archivedEmails.total > archivedEmails.limit}
|
||||
<div class="mt-8 flex flex-row items-center justify-center space-x-2">
|
||||
<a
|
||||
href={`/dashboard/archived-emails?ingestionSourceId=${selectedIngestionSourceId}&page=${
|
||||
archivedEmails.page - 1
|
||||
}&limit=${archivedEmails.limit}`}
|
||||
class={archivedEmails.page === 1 ? 'pointer-events-none' : ''}
|
||||
>
|
||||
<Button variant="outline" disabled={archivedEmails.page === 1}>Prev</Button>
|
||||
</a>
|
||||
|
||||
{#each paginationItems as item}
|
||||
{#if typeof item === 'number'}
|
||||
<a
|
||||
href={`/dashboard/archived-emails?ingestionSourceId=${selectedIngestionSourceId}&page=${item}&limit=${archivedEmails.limit}`}
|
||||
>
|
||||
<Button variant={item === archivedEmails.page ? 'default' : 'outline'}>{item}</Button>
|
||||
</a>
|
||||
{:else}
|
||||
<span class="px-4 py-2">...</span>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<a
|
||||
href={`/dashboard/archived-emails?ingestionSourceId=${selectedIngestionSourceId}&page=${
|
||||
archivedEmails.page + 1
|
||||
}&limit=${archivedEmails.limit}`}
|
||||
class={archivedEmails.page === Math.ceil(archivedEmails.total / archivedEmails.limit)
|
||||
? 'pointer-events-none'
|
||||
: ''}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={archivedEmails.page === Math.ceil(archivedEmails.total / archivedEmails.limit)}
|
||||
>Next</Button
|
||||
>
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { api } from '$lib/server/api';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import type { ArchivedEmail } from '@open-archive/types';
|
||||
|
||||
export const load: PageServerLoad = async (event) => {
|
||||
try {
|
||||
const { id } = event.params;
|
||||
const response = await api(`/archived-emails/${id}`, event);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch archived email: ${response.statusText}`);
|
||||
}
|
||||
const email: ArchivedEmail = await response.json();
|
||||
return {
|
||||
email
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to load archived email:', error);
|
||||
return {
|
||||
email: null,
|
||||
error: 'Failed to load email'
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,98 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import EmailPreview from '$lib/components/custom/EmailPreview.svelte';
|
||||
import { api } from '$lib/api.client';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
const { email } = data;
|
||||
|
||||
async function download(path: string, filename: string) {
|
||||
if (!browser) return;
|
||||
|
||||
try {
|
||||
const response = await api(`/storage/download?path=${encodeURIComponent(path)}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
a.remove();
|
||||
} catch (error) {
|
||||
console.error('Download failed:', error);
|
||||
// Optionally, show an error message to the user
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if email}
|
||||
<div class="grid grid-cols-3 gap-6">
|
||||
<div class="col-span-2">
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>{email.subject || 'No Subject'}</Card.Title>
|
||||
<Card.Description>
|
||||
From: {email.senderName || email.senderEmail} | Sent: {new Date(
|
||||
email.sentAt
|
||||
).toLocaleString()}
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<h3 class="font-semibold">Recipients</h3>
|
||||
<p>To: {email.recipients.map((r) => r.email).join(', ')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold">Email Preview</h3>
|
||||
<EmailPreview raw={email.raw} />
|
||||
</div>
|
||||
{#if email.attachments && email.attachments.length > 0}
|
||||
<div>
|
||||
<h3 class="font-semibold">Attachments</h3>
|
||||
<ul class="mt-2 space-y-2">
|
||||
{#each email.attachments as attachment}
|
||||
<li class="flex items-center justify-between rounded-md border p-2">
|
||||
<span>{attachment.filename} ({attachment.sizeBytes} bytes)</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => download(attachment.storagePath, attachment.filename)}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
<div class="col-span-1">
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Actions</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<Button onclick={() => download(email.storagePath, `${email.subject || 'email'}.eml`)}
|
||||
>Download Email (.eml)</Button
|
||||
>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<p>Email not found.</p>
|
||||
{/if}
|
||||
BIN
packages/frontend/static/favicon.png
Normal file
BIN
packages/frontend/static/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.8 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB |
19
packages/frontend/static/logos/logo-sq.svg
Normal file
19
packages/frontend/static/logos/logo-sq.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 164 KiB |
51
packages/types/src/archived-emails.types.ts
Normal file
51
packages/types/src/archived-emails.types.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Represents a single recipient of an email.
|
||||
*/
|
||||
export interface Recipient {
|
||||
name?: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a single attachment of an email.
|
||||
*/
|
||||
export interface Attachment {
|
||||
id: string;
|
||||
filename: string;
|
||||
mimeType: string | null;
|
||||
sizeBytes: number;
|
||||
storagePath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a single archived email.
|
||||
*/
|
||||
export interface ArchivedEmail {
|
||||
id: string;
|
||||
ingestionSourceId: string;
|
||||
messageIdHeader: string | null;
|
||||
sentAt: Date;
|
||||
subject: string | null;
|
||||
senderName: string | null;
|
||||
senderEmail: string;
|
||||
recipients: Recipient[];
|
||||
storagePath: string;
|
||||
storageHashSha256: string;
|
||||
sizeBytes: number;
|
||||
isIndexed: boolean;
|
||||
hasAttachments: boolean;
|
||||
isOnLegalHold: boolean;
|
||||
archivedAt: Date;
|
||||
attachments?: Attachment[];
|
||||
raw?: Buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a paginated list of archived emails.
|
||||
*/
|
||||
export interface PaginatedArchivedEmails {
|
||||
items: ArchivedEmail[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
48
packages/types/src/email.types.ts
Normal file
48
packages/types/src/email.types.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Represents a single email address, including an optional name and the email address itself.
|
||||
*/
|
||||
export interface EmailAddress {
|
||||
name: string;
|
||||
address: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines the structure for an email attachment, including its filename, content type, size, and the raw content as a buffer.
|
||||
*/
|
||||
export interface EmailAttachment {
|
||||
filename: string;
|
||||
contentType: string;
|
||||
size: number;
|
||||
content: Buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes the universal structure for a raw email object, designed to be compatible with various ingestion sources like IMAP and Google Workspace.
|
||||
* This type serves as a standardized representation of an email before it is processed and stored in the database.
|
||||
*/
|
||||
export interface EmailObject {
|
||||
/** A unique identifier for the email, typically assigned by the source provider. */
|
||||
id: string;
|
||||
/** An array of `EmailAddress` objects representing the sender(s). */
|
||||
from: EmailAddress[];
|
||||
/** An array of `EmailAddress` objects representing the primary recipient(s). */
|
||||
to: EmailAddress[];
|
||||
/** An optional array of `EmailAddress` objects for carbon copy (CC) recipients. */
|
||||
cc?: EmailAddress[];
|
||||
/** An optional array of `EmailAddress` objects for blind carbon copy (BCC) recipients. */
|
||||
bcc?: EmailAddress[];
|
||||
/** The subject line of the email. */
|
||||
subject: string;
|
||||
/** The plain text body of the email. */
|
||||
body: string;
|
||||
/** The HTML version of the email body, if available. */
|
||||
html: string;
|
||||
/** A record of all email headers, where keys are header names and values can be a string or an array of strings. */
|
||||
headers: Record<string, string | string[]>;
|
||||
/** An array of `EmailAttachment` objects found in the email. */
|
||||
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;
|
||||
}
|
||||
@@ -2,3 +2,5 @@ export * from './auth.types';
|
||||
export * from './user.types';
|
||||
export * from './ingestion.types';
|
||||
export * from './storage.types';
|
||||
export * from './email.types';
|
||||
export * from './archived-emails.types';
|
||||
|
||||
@@ -14,7 +14,6 @@ export interface GenericImapCredentials {
|
||||
port: number;
|
||||
secure: boolean;
|
||||
username: string;
|
||||
// Password will be encrypted and stored securely
|
||||
password?: string;
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user