Idendity inboxes in org

This commit is contained in:
Wayne
2025-07-24 18:46:35 +03:00
parent bef92cb7d4
commit c3bbc84b01
15 changed files with 1123 additions and 190 deletions

View File

@@ -0,0 +1 @@
ALTER TABLE "archived_emails" ADD COLUMN "user_email" text NOT NULL;

View File

@@ -0,0 +1,832 @@
{
"id": "86b6960e-1936-4543-846f-a2d24d6dc5d1",
"prevId": "2a68f80f-b233-43bd-8280-745bee76ca3e",
"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
},
"user_email": {
"name": "user_email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"message_id_header": {
"name": "message_id_header",
"type": "text",
"primaryKey": false,
"notNull": false
},
"sent_at": {
"name": "sent_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"subject": {
"name": "subject",
"type": "text",
"primaryKey": false,
"notNull": false
},
"sender_name": {
"name": "sender_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"sender_email": {
"name": "sender_email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"recipients": {
"name": "recipients",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"storage_path": {
"name": "storage_path",
"type": "text",
"primaryKey": false,
"notNull": true
},
"storage_hash_sha256": {
"name": "storage_hash_sha256",
"type": "text",
"primaryKey": false,
"notNull": true
},
"size_bytes": {
"name": "size_bytes",
"type": "bigint",
"primaryKey": false,
"notNull": true
},
"is_indexed": {
"name": "is_indexed",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"has_attachments": {
"name": "has_attachments",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"is_on_legal_hold": {
"name": "is_on_legal_hold",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"archived_at": {
"name": "archived_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"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": "text",
"primaryKey": false,
"notNull": false
},
"status": {
"name": "status",
"type": "ingestion_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'pending_auth'"
},
"last_sync_started_at": {
"name": "last_sync_started_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"last_sync_finished_at": {
"name": "last_sync_finished_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"last_sync_status_message": {
"name": "last_sync_status_message",
"type": "text",
"primaryKey": false,
"notNull": false
},
"sync_state": {
"name": "sync_state",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"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",
"importing",
"auth_success"
]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -57,6 +57,13 @@
"when": 1753190159356,
"tag": "0007_handy_archangel",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1753370737317,
"tag": "0008_eminent_the_spike",
"breakpoints": true
}
]
}

View File

@@ -7,6 +7,7 @@ export const archivedEmails = pgTable('archived_emails', {
ingestionSourceId: uuid('ingestion_source_id')
.notNull()
.references(() => ingestionSources.id, { onDelete: 'cascade' }),
userEmail: text('user_email').notNull(),
messageIdHeader: text('message_id_header'),
sentAt: timestamp('sent_at', { withTimezone: true }).notNull(),
subject: text('subject'),

View File

@@ -23,7 +23,7 @@ export const processMailboxProcessor = async (job: Job<IProcessMailboxJob, SyncS
// Pass the sync state for the entire source, the connector will handle per-user logic if necessary
for await (const email of connector.fetchEmails(userEmail, source.syncState)) {
if (email) {
await ingestionService.processEmail(email, source, storageService);
await ingestionService.processEmail(email, source, storageService, userEmail);
}
}

View File

@@ -199,7 +199,8 @@ export class IngestionService {
public async processEmail(
email: EmailObject,
source: IngestionSource,
storage: StorageService
storage: StorageService,
userEmail: string
): Promise<void> {
try {
console.log('processing email, ', email.id, email.subject);
@@ -212,6 +213,7 @@ export class IngestionService {
.insert(archivedEmails)
.values({
ingestionSourceId: source.id,
userEmail,
messageIdHeader:
(email.headers['message-id'] as string) ??
`generated-${emailHash}-${source.id}-${email.id}`,

View File

@@ -15,6 +15,7 @@
},
"dependencies": {
"@open-archiver/types": "workspace:*",
"d3-shape": "^3.2.0",
"jose": "^6.0.1",
"lucide-svelte": "^0.525.0",
"postal-mime": "^2.4.4",
@@ -27,6 +28,7 @@
"@sveltejs/kit": "^2.16.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/vite": "^4.0.0",
"@types/d3-shape": "^3.1.7",
"bits-ui": "^2.8.10",
"clsx": "^2.1.1",
"dotenv": "^17.2.0",

View File

@@ -5,72 +5,72 @@
@custom-variant dark (&:is(.dark *));
:root {
--radius: 0.625rem;
--radius: 0.65rem;
--background: oklch(1 0 0);
--foreground: oklch(0.129 0.042 264.695);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.129 0.042 264.695);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.129 0.042 264.695);
--primary: oklch(0.208 0.042 265.755);
--primary-foreground: oklch(0.984 0.003 247.858);
--secondary: oklch(0.968 0.007 247.896);
--secondary-foreground: oklch(0.208 0.042 265.755);
--muted: oklch(0.968 0.007 247.896);
--muted-foreground: oklch(0.554 0.046 257.417);
--accent: oklch(0.968 0.007 247.896);
--accent-foreground: oklch(0.208 0.042 265.755);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.705 0.213 47.604);
--primary-foreground: oklch(0.98 0.016 73.684);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.929 0.013 255.508);
--input: oklch(0.929 0.013 255.508);
--ring: oklch(0.704 0.04 256.788);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.705 0.213 47.604);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.984 0.003 247.858);
--sidebar-foreground: oklch(0.129 0.042 264.695);
--sidebar-primary: oklch(0.208 0.042 265.755);
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
--sidebar-accent: oklch(0.968 0.007 247.896);
--sidebar-accent-foreground: oklch(0.208 0.042 265.755);
--sidebar-border: oklch(0.929 0.013 255.508);
--sidebar-ring: oklch(0.704 0.04 256.788);
--chart-2: oklch(0.705 0.213 47.604);
--chart-3: oklch(0.837 0.128 66.29);
--chart-4: oklch(0.553 0.195 38.402);
--chart-5: oklch(0.47 0.157 37.304);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.705 0.213 47.604);
--sidebar-primary-foreground: oklch(0.98 0.016 73.684);
--sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.705 0.213 47.604);
}
.dark {
--background: oklch(0.129 0.042 264.695);
--foreground: oklch(0.984 0.003 247.858);
--card: oklch(0.208 0.042 265.755);
--card-foreground: oklch(0.984 0.003 247.858);
--popover: oklch(0.208 0.042 265.755);
--popover-foreground: oklch(0.984 0.003 247.858);
--primary: oklch(0.929 0.013 255.508);
--primary-foreground: oklch(0.208 0.042 265.755);
--secondary: oklch(0.279 0.041 260.031);
--secondary-foreground: oklch(0.984 0.003 247.858);
--muted: oklch(0.279 0.041 260.031);
--muted-foreground: oklch(0.704 0.04 256.788);
--accent: oklch(0.279 0.041 260.031);
--accent-foreground: oklch(0.984 0.003 247.858);
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.646 0.222 41.116);
--primary-foreground: oklch(0.98 0.016 73.684);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.551 0.027 264.364);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.208 0.042 265.755);
--sidebar-foreground: oklch(0.984 0.003 247.858);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
--sidebar-accent: oklch(0.279 0.041 260.031);
--sidebar-accent-foreground: oklch(0.984 0.003 247.858);
--ring: oklch(0.646 0.222 41.116);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.705 0.213 47.604);
--chart-3: oklch(0.837 0.128 66.29);
--chart-4: oklch(0.553 0.195 38.402);
--chart-5: oklch(0.47 0.157 37.304);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.646 0.222 41.116);
--sidebar-primary-foreground: oklch(0.98 0.016 73.684);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.551 0.027 264.364);
--sidebar-ring: oklch(0.646 0.222 41.116);
}
@theme inline {

View File

@@ -0,0 +1,51 @@
<script lang="ts">
import { Button, buttonVariants } from '$lib/components/ui/button/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import { type Snippet } from 'svelte';
let {
header,
text,
buttonText,
click
}: {
header: string;
text: string;
buttonText?: string;
click: () => void;
} = $props();
</script>
<div class="space-y-4 rounded-lg border-2 border-dashed border-gray-300 p-6 text-center">
<div>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
aria-hidden="true"
class="mx-auto size-12 text-gray-400"
>
<path
d="M9 13h6m-3-3v6m-9 1V7a2 2 0 012-2h6l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z"
stroke-width="2"
vector-effect="non-scaling-stroke"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</div>
<h3 class="mt-2 text-sm font-semibold text-gray-900">{header}</h3>
<p class="mt-1 text-sm text-gray-500">{text}</p>
<div>
<Button
variant="outline"
class="cursor-pointer"
onclick={() => {
click();
}}
>
{buttonText}
</Button>
</div>
</div>

View File

@@ -20,7 +20,7 @@
<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 flex flex-row items-center gap-2 font-bold">
<a href="/dashboard" class="flex flex-row items-center gap-2 font-bold">
<img src="/logos/logo-sq.svg" alt="OpenArchiver Logo" class="h-8 w-8" />
<span>Open Archiver</span>
</a>

View File

@@ -1,19 +1,35 @@
<script lang="ts">
import type { PageData } from './$types';
import * as Card from '$lib/components/ui/card/index.js';
import * as Table from '$lib/components/ui/table/index.js';
import * as Chart from '$lib/components/ui/chart/index.js';
import { BarChart } from 'layerchart';
import { LineChart, PieChart, AreaChart } from 'layerchart';
import { formatBytes } from '$lib/utils';
import { curveCatmullRom } from 'd3-shape';
import type { ChartConfig } from '$lib/components/ui/chart';
import EmptyState from '$lib/components/custom/EmptyState.svelte';
import { goto } from '$app/navigation';
import { Archive, CircleAlert, HardDrive } from 'lucide-svelte';
let { data }: { data: PageData } = $props();
const chartConfig = {
const transformedHistory = $derived(
data.ingestionHistory?.history.map((item) => ({
...item,
date: new Date(item.date)
})) ?? []
);
const emailIngestedChartConfig = {
count: {
label: 'Emails Ingested',
color: '#2563eb'
color: 'var(--chart-1)'
}
} satisfies Chart.ChartConfig;
} satisfies ChartConfig;
const StorageUsedChartConfig = {
storageUsed: {
label: 'Storage Used'
}
} satisfies ChartConfig;
</script>
<svelte:head>
@@ -21,134 +37,152 @@
<meta name="description" content="Overview of your email archive." />
</svelte:head>
<div class="flex-1 space-y-4 p-8 pt-6">
<div class="flex-1 space-y-4">
<div class="flex items-center justify-between space-y-2">
<h2 class="text-3xl font-bold tracking-tight">Dashboard</h2>
</div>
{#if !data.ingestionSources || data.ingestionSources?.length === 0}
<div>
<EmptyState
buttonText="Create an ingestion"
header="You don't have any ingestion source set up."
text="Add an ingestion source to start archiving your inboxes."
click={() => {
goto('/dashboard/ingestions');
}}
></EmptyState>
</div>
{:else}
<!-- show data -->
<div class="space-y-4">
{#if data.stats}
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<Card.Root>
<Card.Header class="flex flex-row items-center justify-between space-y-0 pb-2">
<Card.Title class="text-sm font-medium">Total Emails Archived</Card.Title>
<Archive class="text-muted-foreground h-4 w-4" />
</Card.Header>
<Card.Content>
<div class="text-primary text-2xl font-bold">{data.stats.totalEmailsArchived}</div>
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header class="flex flex-row items-center justify-between space-y-0 pb-2">
<Card.Title class="text-sm font-medium">Total Storage Used</Card.Title>
<HardDrive class="text-muted-foreground h-4 w-4" />
</Card.Header>
<Card.Content>
<div class="text-primary text-2xl font-bold">
{formatBytes(data.stats.totalStorageUsed)}
</div>
</Card.Content>
</Card.Root>
<Card.Root class="">
<Card.Header class="flex flex-row items-center justify-between space-y-0 pb-2">
<Card.Title class="text-sm font-medium">Failed Ingestions (Last 7 Days)</Card.Title>
<CircleAlert class=" text-muted-foreground h-4 w-4" />
</Card.Header>
<Card.Content>
<div
class=" text-2xl font-bold text-green-500"
class:text-destructive={data.stats.failedIngestionsLast7Days > 0}
>
{data.stats.failedIngestionsLast7Days}
</div>
</Card.Content>
</Card.Root>
</div>
{/if}
{#if data.stats}
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<Card.Root>
<Card.Header class="flex flex-row items-center justify-between space-y-0 pb-2">
<Card.Title class="text-sm font-medium">Total Emails Archived</Card.Title>
</Card.Header>
<Card.Content>
<div class="text-2xl font-bold">{data.stats.totalEmailsArchived}</div>
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header class="flex flex-row items-center justify-between space-y-0 pb-2">
<Card.Title class="text-sm font-medium">Total Storage Used</Card.Title>
</Card.Header>
<Card.Content>
<div class="text-2xl font-bold">{formatBytes(data.stats.totalStorageUsed)}</div>
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header class="flex flex-row items-center justify-between space-y-0 pb-2">
<Card.Title class="text-sm font-medium">Failed Ingestions (Last 7 Days)</Card.Title>
</Card.Header>
<Card.Content>
<div class="text-2xl font-bold">{data.stats.failedIngestionsLast7Days}</div>
</Card.Content>
</Card.Root>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3">
<div class=" lg:col-span-2">
<Card.Root>
<Card.Header>
<Card.Title>Ingestion History</Card.Title>
</Card.Header>
<Card.Content class=" pl-4">
{#if transformedHistory.length > 0}
<Chart.Container config={emailIngestedChartConfig} class="min-h-[300px] w-full">
<AreaChart
data={transformedHistory}
x="date"
y="count"
yDomain={[0, Math.max(...transformedHistory.map((d) => d.count)) * 1.1]}
axis
legend={false}
series={[
{
key: 'count',
...emailIngestedChartConfig.count
}
]}
cRange={[
'var(--color-chart-1)',
'var(--color-chart-2)',
'var(--color-chart-3)',
'var(--color-chart-4)',
'var(--color-chart-5)'
]}
labels={{}}
props={{
xAxis: {
format: (d) =>
new Date(d).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric'
})
},
area: { curve: curveCatmullRom }
}}
>
{#snippet tooltip()}
<Chart.Tooltip />
{/snippet}
</AreaChart>
</Chart.Container>
{:else}
<p>No ingestion history available.</p>
{/if}
</Card.Content>
</Card.Root>
</div>
<div class=" lg:col-span-1">
<Card.Root class="h-full">
<Card.Header>
<Card.Title>Storage by Ingestion Source (Bytes)</Card.Title>
</Card.Header>
<Card.Content class="h-full">
{#if data.ingestionSources && data.ingestionSources.length > 0}
<Chart.Container
config={StorageUsedChartConfig}
class="h-full min-h-[300px] w-full"
>
<PieChart
data={data.ingestionSources}
key="name"
value="storageUsed"
label="name"
legend={{}}
cRange={[
'var(--color-chart-1)',
'var(--color-chart-2)',
'var(--color-chart-3)',
'var(--color-chart-4)',
'var(--color-chart-5)'
]}
>
{#snippet tooltip()}
<Chart.Tooltip></Chart.Tooltip>
{/snippet}
</PieChart>
</Chart.Container>
{:else}
<p>No ingestion sources available.</p>
{/if}
</Card.Content>
</Card.Root>
</div>
</div>
</div>
{/if}
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
<Card.Root class="col-span-4">
<Card.Header>
<Card.Title>Ingestion History</Card.Title>
</Card.Header>
<Card.Content class="pl-2">
{#if data.ingestionHistory && data.ingestionHistory.history.length > 0}
<Chart.Container config={chartConfig} class="min-h-[200px] w-full">
<BarChart
data={data.ingestionHistory.history}
x="date"
y="count"
axis="x"
seriesLayout="group"
props={{
xAxis: {
format: (d) =>
new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
}
}}
>
{#snippet tooltip()}
<Chart.Tooltip />
{/snippet}
</BarChart>
</Chart.Container>
{:else}
<p>No ingestion history available.</p>
{/if}
</Card.Content>
</Card.Root>
<Card.Root class="col-span-3">
<Card.Header>
<Card.Title>Recent Syncs</Card.Title>
<Card.Description>Most recent sync activities.</Card.Description>
</Card.Header>
<Card.Content>
{#if data.recentSyncs && data.recentSyncs.length > 0}
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Source</Table.Head>
<Table.Head>Status</Table.Head>
<Table.Head>Processed</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each data.recentSyncs as sync}
<Table.Row>
<Table.Cell class="font-medium">{sync.sourceName}</Table.Cell>
<Table.Cell>{sync.status}</Table.Cell>
<Table.Cell>{sync.emailsProcessed}</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
{:else}
<p>No recent syncs.</p>
{/if}
</Card.Content>
</Card.Root>
</div>
<Card.Root>
<Card.Header>
<Card.Title>Ingestion Sources</Card.Title>
<Card.Description>Overview of your ingestion sources.</Card.Description>
</Card.Header>
<Card.Content>
{#if data.ingestionSources && data.ingestionSources.length > 0}
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Name</Table.Head>
<Table.Head>Provider</Table.Head>
<Table.Head>Status</Table.Head>
<Table.Head class="text-right">Storage Used</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each data.ingestionSources as source}
<Table.Row>
<Table.Cell class="font-medium">{source.name}</Table.Cell>
<Table.Cell>{source.provider}</Table.Cell>
<Table.Cell>{source.status}</Table.Cell>
<Table.Cell class="text-right">{formatBytes(source.storageUsed)}</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
{:else}
<p>No ingestion sources found.</p>
{/if}
</Card.Content>
</Card.Root>
</div>

View File

@@ -95,6 +95,7 @@
<Table.Header>
<Table.Row>
<Table.Head>Date</Table.Head>
<Table.Head>Inbox</Table.Head>
<Table.Head>Subject</Table.Head>
<Table.Head>Sender</Table.Head>
<Table.Head>Attachments</Table.Head>
@@ -106,6 +107,7 @@
{#each archivedEmails.items as email (email.id)}
<Table.Row>
<Table.Cell>{new Date(email.sentAt).toLocaleString()}</Table.Cell>
<Table.Cell>{email.userEmail}</Table.Cell>
<Table.Cell>
<div class="max-w-100 truncate">
<a href={`/dashboard/archived-emails/${email.id}`}>

View File

@@ -36,7 +36,7 @@
{#if email}
<div class="grid grid-cols-3 gap-6">
<div class="col-span-2">
<div class="col-span-3 md:col-span-2">
<Card.Root>
<Card.Header>
<Card.Title>{email.subject || 'No Subject'}</Card.Title>
@@ -79,7 +79,7 @@
</Card.Content>
</Card.Root>
</div>
<div class="col-span-1">
<div class="col-span-3 md:col-span-1">
<Card.Root>
<Card.Header>
<Card.Title>Actions</Card.Title>

View File

@@ -23,6 +23,7 @@ export interface Attachment {
export interface ArchivedEmail {
id: string;
ingestionSourceId: string;
userEmail: string;
messageIdHeader: string | null;
sentAt: Date;
subject: string | null;

File diff suppressed because one or more lines are too long