Archived email API, dark mode

This commit is contained in:
Wayne
2025-07-14 00:11:01 +03:00
parent f4d48a4e5a
commit a305bb5006
46 changed files with 3581 additions and 54 deletions

View File

@@ -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",

View File

@@ -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",

View File

@@ -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' });
}
};
}

View 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');
}
};
}

View 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;
};

View 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;
};

View 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;

View 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
}
}
});

View File

@@ -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
};

View File

@@ -0,0 +1 @@
ALTER TYPE "public"."ingestion_status" ADD VALUE 'auth_success';

View File

@@ -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";

View File

@@ -0,0 +1 @@
ALTER TABLE "archived_emails" ALTER COLUMN "message_id_header" DROP NOT NULL;

View 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": {}
}
}

View 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": {}
}
}

View 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": {}
}
}

View File

@@ -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
}
]
}

View File

@@ -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]
})
}));

View File

@@ -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) => {

View File

@@ -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);
}
};

View 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;
}
}

View File

@@ -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>;

View File

@@ -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
});
}
}
}

View File

@@ -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
};
}
}

View File

@@ -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
};
}
}

View File

@@ -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'];

View File

@@ -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",

View File

@@ -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>

View File

@@ -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>

View 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>

View File

@@ -23,7 +23,7 @@
let formData = $state({
name: source?.name ?? '',
provider: source?.provider ?? 'google_workspace',
providerConfig: source?.providerConfig ?? {}
providerConfig: source?.credentials ?? {}
});
const triggerContent = $derived(

View File

@@ -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>

View File

@@ -0,0 +1,5 @@
import { persisted } from 'svelte-persisted-store';
type Theme = 'light' | 'dark' | 'system';
export const theme = persisted<Theme>('theme', 'system');

View File

@@ -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>

View File

@@ -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>

View File

@@ -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'
};
}
};

View File

@@ -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}

View File

@@ -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'
};
}
};

View File

@@ -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}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

View File

@@ -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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 164 KiB

View 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;
}

View 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;
}

View File

@@ -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';

View File

@@ -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