From 4d405f096f3039cac08040392319b26e9cc904b8 Mon Sep 17 00:00:00 2001 From: wayneshn Date: Mon, 30 Mar 2026 20:51:13 +0200 Subject: [PATCH] feat(ingestion): add unmerge ingestion source functionality Introduces the ability to detach a child ingestion source from its merge group, making it a standalone root source. Changes include: - Add `unmerge` controller method with auth and error handling - Add POST `/v1/ingestion-sources/{id}/unmerge` route with OpenAPI docs - Implement `IngestionService.unmerge` backend logic - Add unmerge UI action and handler in the frontend ingestion view - Fix bulk delete to also remove children of deleted root sources - Update docs with new API operation and merging sources user guide --- docs/api/ingestion.md | 4 + docs/user-guides/email-providers/index.md | 1 + .../email-providers/merging-sources.md | 105 + package.json | 2 +- .../api/controllers/ingestion.controller.ts | 25 + .../src/api/routes/ingestion.routes.ts | 38 + .../migrations/0032_exotic_the_twelve.sql | 3 + .../migrations/0033_adorable_lockheed.sql | 1 + .../migrations/meta/0032_snapshot.json | 1869 ++++++++++++++++ .../migrations/meta/0033_snapshot.json | 1882 +++++++++++++++++ .../database/migrations/meta/_journal.json | 14 + .../src/database/schema/ingestion-sources.ts | 64 +- .../sync-cycle-finished.processor.ts | 2 +- .../backend/src/locales/bg/translation.json | 10 +- .../backend/src/locales/de/translation.json | 14 + .../backend/src/locales/el/translation.json | 17 +- .../backend/src/locales/es/translation.json | 17 +- .../backend/src/locales/et/translation.json | 17 +- .../backend/src/locales/fr/translation.json | 17 +- .../backend/src/locales/it/translation.json | 19 +- .../backend/src/locales/ja/translation.json | 17 +- .../backend/src/locales/nl/translation.json | 17 +- .../backend/src/locales/pt/translation.json | 17 +- .../src/services/ArchivedEmailService.ts | 23 +- .../backend/src/services/IngestionService.ts | 193 +- .../backend/src/services/SearchService.ts | 24 +- .../custom/IngestionSourceForm.svelte | 151 +- .../frontend/src/lib/translations/de.json | 531 ++++- .../frontend/src/lib/translations/en.json | 23 +- .../frontend/src/lib/translations/es.json | 604 +++++- .../frontend/src/lib/translations/fr.json | 604 +++++- .../dashboard/admin/license/+page.svelte | 4 +- .../archived-emails/[id]/+page.svelte | 2 +- .../dashboard/ingestions/+page.server.ts | 4 +- .../routes/dashboard/ingestions/+page.svelte | 358 +++- packages/types/src/ingestion.types.ts | 10 +- 36 files changed, 6342 insertions(+), 361 deletions(-) create mode 100644 docs/user-guides/email-providers/merging-sources.md create mode 100644 packages/backend/src/database/migrations/0032_exotic_the_twelve.sql create mode 100644 packages/backend/src/database/migrations/0033_adorable_lockheed.sql create mode 100644 packages/backend/src/database/migrations/meta/0032_snapshot.json create mode 100644 packages/backend/src/database/migrations/meta/0033_snapshot.json diff --git a/docs/api/ingestion.md b/docs/api/ingestion.md index fb0baa7..4423a1c 100644 --- a/docs/api/ingestion.md +++ b/docs/api/ingestion.md @@ -37,3 +37,7 @@ Manage ingestion sources — the configured connections to email providers (Goog ## Force Sync + +## Unmerge an Ingestion Source + + diff --git a/docs/user-guides/email-providers/index.md b/docs/user-guides/email-providers/index.md index 7fa816c..1e7c607 100644 --- a/docs/user-guides/email-providers/index.md +++ b/docs/user-guides/email-providers/index.md @@ -10,3 +10,4 @@ Choose your provider from the list below to get started: - [EML Import](./eml.md) - [PST Import](./pst.md) - [Mbox Import](./mbox.md) +- [Merging Ingestion Sources](./merging-sources.md) diff --git a/docs/user-guides/email-providers/merging-sources.md b/docs/user-guides/email-providers/merging-sources.md new file mode 100644 index 0000000..4303363 --- /dev/null +++ b/docs/user-guides/email-providers/merging-sources.md @@ -0,0 +1,105 @@ +# Merging Ingestion Sources + +Merged ingestion groups let you combine multiple ingestion sources so that their emails appear unified in browsing, search, and thread views. This is useful when you want to pair a historical archive (for example, a PST or Mbox import) with a live connection, or when migrating between providers. + +## Concepts + +| Term | Definition | +| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Root source** | An ingestion source where no merge parent is set. Shown as the primary row in the Ingestions table. All emails in the group are physically owned by the root. | +| **Child source** | An ingestion source merged into a root. Acts as a fetch assistant — it connects to the provider and retrieves emails, but all data is stored under the root source. | +| **Group** | A root source and all its children. All emails from every member are stored under and owned by the root. | + +The hierarchy is **flat** — only one level of nesting is supported. If you merge a source into a child, the system automatically redirects the relationship to the root. + +## Root Ownership — How Storage and Data Work + +This is the key design principle of merged sources: + +> **Child sources are assistants. They fetch emails from their provider but never own any stored data. Every email ingested by a child is written to the root source's storage folder and assigned the root source's ID in the database.** + +In practical terms: + +- The storage path for every email belongs to the root: `openarchiver/{root-name}-{root-id}/emails/...` +- Every `archived_emails` database row created by a child ingestion will have `ingestionSourceId` set to the **root's ID**, not the child's. +- Attachments are also stored under the root's folder and scoped to the root's ID. +- The root's **Preserve Original File** (GoBD compliance) setting is inherited by all children in the group. A child's own `preserveOriginalFile` setting is ignored during ingestion — only the root's setting applies. + +This means browsing the root source's emails will show all emails from the entire group, including those fetched by child sources, without any extra configuration. + +## When to Use Merged Sources + +- **Historical + live**: Import a PST archive and merge it into an active IMAP or Google Workspace connection so historical and current emails appear in one unified mailbox. +- **Provider migration**: Add a new Microsoft 365 connector and merge it with your existing Google Workspace connector during a cutover period. +- **Backfill**: Import an Mbox export and merge it with a live connection to cover a gap in the archive. + +## How to Merge a New Source Into an Existing One + +Merging can only be configured **at creation time**. + +1. Navigate to the **Ingestions** page. +2. Click **Create New** to open the ingestion source form. +3. Fill in the provider details as usual. +4. Expand the **Advanced Options** section at the bottom of the form. This section is only visible when at least one ingestion source already exists. +5. Check **Merge into existing ingestion** and select the target root source from the dropdown. +6. Click **Submit**. + +The new source will run its initial import normally. Once complete, its emails will appear alongside those of the root source — all stored under the root. + +## How Emails Appear When Merged + +When you browse archived emails for a root source, you see all emails in the group because they are all physically owned by the root. There is nothing to aggregate — the data is already unified at the storage and database level. + +The same applies to search: filtering by a root source ID returns all emails in the group. + +Threads also span the merge group. If a reply arrived via a different source than the original message, it still appears in the correct thread. + +## How Syncing Works + +Each source syncs **independently**. The scheduler picks up all sources with status `active` or `error`, regardless of whether they are merged. + +- File-based imports (PST, EML, Mbox) finish with status `imported` and are never re-synced automatically. +- Live sources (IMAP, Google Workspace, Microsoft 365) continue their normal sync cycle. + +When you trigger **Force Sync** on a root source, the system also queues a sync for all non-file-based children that are currently `active` or `error`. + +## Deduplication Across the Group + +When ingesting emails, duplicate detection covers the **entire merge group**. If the same email (matched by its RFC `Message-ID` header or provider-specific ID) already exists anywhere in the group, it is skipped and not stored again. + +## Preserve Original File (GoBD Compliance) and Merged Sources + +The **Preserve Original File** setting on the root source governs the entire group. When this setting is enabled on the root: + +- All emails ingested by child sources are also stored unmodified (raw EML, no attachment stripping). +- The child's own `preserveOriginalFile` setting has no effect — the root's setting is always used. + +This ensures consistent compliance behaviour across the group. If you require GoBD or SEC 17a-4 compliance for an entire merged group, enable **Preserve Original File** on the root source before adding any children. + +## Editing Sources in a Group + +Each source in a group can be edited independently. Expand the group row in the Ingestions table by clicking the chevron, then use the **⋮** actions menu on the specific source (root or child) you want to edit. + +## Unmerging a Child Source + +To detach a child from its group and make it standalone: + +1. Expand the group row by clicking the chevron next to the root source name. +2. Open the **⋮** actions menu on the child source. +3. Click **Unmerge**. + +The child becomes an independent root source. No email data is moved or deleted. + +> **Note:** Because all emails fetched by the child were stored under the root source's ID, unmerging the child does not transfer those emails. Historical emails ingested while the source was a child remain owned by the root. Only new emails ingested after unmerging will be stored under the (now standalone) child. + +## Deleting Sources in a Group + +- **Deleting a root source** also deletes all its children: their configuration, and all emails, attachments, storage files, and search index entries owned by the root are all removed. Because all group emails are stored under the root, this effectively removes the entire group's archive. +- **Deleting a child source** removes only the child's configuration and sync state. Emails already ingested by the child are stored under the root and are **not** deleted. + +A warning is shown in the delete confirmation dialog when a root source has children. + +## Known Limitations + +- **Merging existing standalone sources is not supported.** You can only merge a source into a group at creation time. To merge two existing sources, you must delete one and recreate it with the merge target selected. +- **Historical data from a child source before unmerging remains with the root.** If you unmerge a child, emails it previously ingested stay owned by the root and are not migrated to the child. diff --git a/package.json b/package.json index 2ceda0d..fd589f6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-archiver", - "version": "0.5.0", + "version": "0.5.1", "private": true, "license": "SEE LICENSE IN LICENSE file", "scripts": { diff --git a/packages/backend/src/api/controllers/ingestion.controller.ts b/packages/backend/src/api/controllers/ingestion.controller.ts index f0d05d3..03edebc 100644 --- a/packages/backend/src/api/controllers/ingestion.controller.ts +++ b/packages/backend/src/api/controllers/ingestion.controller.ts @@ -177,6 +177,31 @@ export class IngestionController { } }; + public unmerge = async (req: Request, res: Response): Promise => { + try { + const { id } = req.params; + const userId = req.user?.sub; + if (!userId) { + return res.status(401).json({ message: req.t('errors.unauthorized') }); + } + const actor = await this.userService.findById(userId); + if (!actor) { + return res.status(401).json({ message: req.t('errors.unauthorized') }); + } + const updatedSource = await IngestionService.unmerge(id, actor, req.ip || 'unknown'); + const safeSource = this.toSafeIngestionSource(updatedSource); + return res.status(200).json(safeSource); + } catch (error) { + logger.error({ err: error }, `Unmerge ingestion source ${req.params.id} error`); + if (error instanceof Error && error.message === 'Ingestion source not found') { + return res.status(404).json({ message: req.t('ingestion.notFound') }); + } else if (error instanceof Error) { + return res.status(400).json({ message: error.message }); + } + return res.status(500).json({ message: req.t('errors.internalServerError') }); + } + }; + public triggerForceSync = async (req: Request, res: Response): Promise => { try { const { id } = req.params; diff --git a/packages/backend/src/api/routes/ingestion.routes.ts b/packages/backend/src/api/routes/ingestion.routes.ts index 7d3efe2..fa7d78f 100644 --- a/packages/backend/src/api/routes/ingestion.routes.ts +++ b/packages/backend/src/api/routes/ingestion.routes.ts @@ -291,5 +291,43 @@ export const createIngestionRouter = ( ingestionController.triggerForceSync ); + /** + * @openapi + * /v1/ingestion-sources/{id}/unmerge: + * post: + * summary: Unmerge a child ingestion source + * description: Detaches a child source from its merge group, making it a standalone root source. Requires `update:ingestion` permission. + * operationId: unmergeIngestionSource + * tags: + * - Ingestion + * security: + * - bearerAuth: [] + * - apiKeyAuth: [] + * parameters: + * - name: id + * in: path + * required: true + * schema: + * type: string + * responses: + * '200': + * description: Source unmerged. Returns the updated source. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/SafeIngestionSource' + * '400': + * description: Source is not merged into another source. + * '401': + * $ref: '#/components/responses/Unauthorized' + * '404': + * $ref: '#/components/responses/NotFound' + */ + router.post( + '/:id/unmerge', + requirePermission('update', 'ingestion'), + ingestionController.unmerge + ); + return router; }; diff --git a/packages/backend/src/database/migrations/0032_exotic_the_twelve.sql b/packages/backend/src/database/migrations/0032_exotic_the_twelve.sql new file mode 100644 index 0000000..481cc29 --- /dev/null +++ b/packages/backend/src/database/migrations/0032_exotic_the_twelve.sql @@ -0,0 +1,3 @@ +ALTER TABLE "archived_emails" ADD COLUMN "is_journaled" boolean DEFAULT false;--> statement-breakpoint +ALTER TABLE "ingestion_sources" ADD COLUMN "merged_into_id" uuid;--> statement-breakpoint +CREATE INDEX "idx_merged_into" ON "ingestion_sources" USING btree ("merged_into_id"); \ No newline at end of file diff --git a/packages/backend/src/database/migrations/0033_adorable_lockheed.sql b/packages/backend/src/database/migrations/0033_adorable_lockheed.sql new file mode 100644 index 0000000..6b7c90a --- /dev/null +++ b/packages/backend/src/database/migrations/0033_adorable_lockheed.sql @@ -0,0 +1 @@ +ALTER TABLE "ingestion_sources" ADD CONSTRAINT "ingestion_sources_merged_into_id_ingestion_sources_id_fk" FOREIGN KEY ("merged_into_id") REFERENCES "public"."ingestion_sources"("id") ON DELETE set null ON UPDATE no action; \ No newline at end of file diff --git a/packages/backend/src/database/migrations/meta/0032_snapshot.json b/packages/backend/src/database/migrations/meta/0032_snapshot.json new file mode 100644 index 0000000..97ed90c --- /dev/null +++ b/packages/backend/src/database/migrations/meta/0032_snapshot.json @@ -0,0 +1,1869 @@ +{ + "id": "9fab872e-0b67-41d9-be9e-c0494f845207", + "prevId": "a1e1d446-db1b-4316-961b-82dcd0e1423d", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.archived_emails": { + "name": "archived_emails", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ingestion_source_id": { + "name": "ingestion_source_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_email": { + "name": "user_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_id_header": { + "name": "message_id_header", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_message_id": { + "name": "provider_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sender_name": { + "name": "sender_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sender_email": { + "name": "sender_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipients": { + "name": "recipients", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "storage_path": { + "name": "storage_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_hash_sha256": { + "name": "storage_hash_sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "is_indexed": { + "name": "is_indexed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "has_attachments": { + "name": "has_attachments", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_on_legal_hold": { + "name": "is_on_legal_hold", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_journaled": { + "name": "is_journaled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "thread_id_idx": { + "name": "thread_id_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "provider_msg_source_idx": { + "name": "provider_msg_source_idx", + "columns": [ + { + "expression": "provider_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ingestion_source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "archived_emails_ingestion_source_id_ingestion_sources_id_fk": { + "name": "archived_emails_ingestion_source_id_ingestion_sources_id_fk", + "tableFrom": "archived_emails", + "tableTo": "ingestion_sources", + "columnsFrom": [ + "ingestion_source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.attachments": { + "name": "attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "content_hash_sha256": { + "name": "content_hash_sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_path": { + "name": "storage_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ingestion_source_id": { + "name": "ingestion_source_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "source_hash_idx": { + "name": "source_hash_idx", + "columns": [ + { + "expression": "ingestion_source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "content_hash_sha256", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "attachments_ingestion_source_id_ingestion_sources_id_fk": { + "name": "attachments_ingestion_source_id_ingestion_sources_id_fk", + "tableFrom": "attachments", + "tableTo": "ingestion_sources", + "columnsFrom": [ + "ingestion_source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_attachments": { + "name": "email_attachments", + "schema": "", + "columns": { + "email_id": { + "name": "email_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "attachment_id": { + "name": "attachment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "email_attachments_email_id_archived_emails_id_fk": { + "name": "email_attachments_email_id_archived_emails_id_fk", + "tableFrom": "email_attachments", + "tableTo": "archived_emails", + "columnsFrom": [ + "email_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_attachments_attachment_id_attachments_id_fk": { + "name": "email_attachments_attachment_id_attachments_id_fk", + "tableFrom": "email_attachments", + "tableTo": "attachments", + "columnsFrom": [ + "attachment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "email_attachments_email_id_attachment_id_pk": { + "name": "email_attachments_email_id_attachment_id_pk", + "columns": [ + "email_id", + "attachment_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "previous_hash": { + "name": "previous_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "actor_identifier": { + "name": "actor_identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_ip": { + "name": "actor_ip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action_type": { + "name": "action_type", + "type": "audit_log_action", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "audit_log_target_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "current_hash": { + "name": "current_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ediscovery_cases": { + "name": "ediscovery_cases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "created_by_identifier": { + "name": "created_by_identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ediscovery_cases_name_unique": { + "name": "ediscovery_cases_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_legal_holds": { + "name": "email_legal_holds", + "schema": "", + "columns": { + "email_id": { + "name": "email_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "legal_hold_id": { + "name": "legal_hold_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "applied_at": { + "name": "applied_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "applied_by_user_id": { + "name": "applied_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "email_legal_holds_email_id_archived_emails_id_fk": { + "name": "email_legal_holds_email_id_archived_emails_id_fk", + "tableFrom": "email_legal_holds", + "tableTo": "archived_emails", + "columnsFrom": [ + "email_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_legal_holds_legal_hold_id_legal_holds_id_fk": { + "name": "email_legal_holds_legal_hold_id_legal_holds_id_fk", + "tableFrom": "email_legal_holds", + "tableTo": "legal_holds", + "columnsFrom": [ + "legal_hold_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_legal_holds_applied_by_user_id_users_id_fk": { + "name": "email_legal_holds_applied_by_user_id_users_id_fk", + "tableFrom": "email_legal_holds", + "tableTo": "users", + "columnsFrom": [ + "applied_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "email_legal_holds_email_id_legal_hold_id_pk": { + "name": "email_legal_holds_email_id_legal_hold_id_pk", + "columns": [ + "email_id", + "legal_hold_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_retention_labels": { + "name": "email_retention_labels", + "schema": "", + "columns": { + "email_id": { + "name": "email_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "label_id": { + "name": "label_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "applied_at": { + "name": "applied_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "applied_by_user_id": { + "name": "applied_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "email_retention_labels_email_id_archived_emails_id_fk": { + "name": "email_retention_labels_email_id_archived_emails_id_fk", + "tableFrom": "email_retention_labels", + "tableTo": "archived_emails", + "columnsFrom": [ + "email_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_retention_labels_label_id_retention_labels_id_fk": { + "name": "email_retention_labels_label_id_retention_labels_id_fk", + "tableFrom": "email_retention_labels", + "tableTo": "retention_labels", + "columnsFrom": [ + "label_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_retention_labels_applied_by_user_id_users_id_fk": { + "name": "email_retention_labels_applied_by_user_id_users_id_fk", + "tableFrom": "email_retention_labels", + "tableTo": "users", + "columnsFrom": [ + "applied_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "email_retention_labels_email_id_label_id_pk": { + "name": "email_retention_labels_email_id_label_id_pk", + "columns": [ + "email_id", + "label_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.export_jobs": { + "name": "export_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "case_id": { + "name": "case_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "query": { + "name": "query", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_identifier": { + "name": "created_by_identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "export_jobs_case_id_ediscovery_cases_id_fk": { + "name": "export_jobs_case_id_ediscovery_cases_id_fk", + "tableFrom": "export_jobs", + "tableTo": "ediscovery_cases", + "columnsFrom": [ + "case_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.legal_holds": { + "name": "legal_holds", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "case_id": { + "name": "case_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "legal_holds_case_id_ediscovery_cases_id_fk": { + "name": "legal_holds_case_id_ediscovery_cases_id_fk", + "tableFrom": "legal_holds", + "tableTo": "ediscovery_cases", + "columnsFrom": [ + "case_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.retention_events": { + "name": "retention_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "event_name": { + "name": "event_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "event_timestamp": { + "name": "event_timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "target_criteria": { + "name": "target_criteria", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.retention_labels": { + "name": "retention_labels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "retention_period_days": { + "name": "retention_period_days", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_disabled": { + "name": "is_disabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.retention_policies": { + "name": "retention_policies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "retention_period_days": { + "name": "retention_period_days", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "action_on_expiry": { + "name": "action_on_expiry", + "type": "retention_action", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "conditions": { + "name": "conditions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ingestion_scope": { + "name": "ingestion_scope", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "retention_policies_name_unique": { + "name": "retention_policies_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custodians": { + "name": "custodians", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "ingestion_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "custodians_email_unique": { + "name": "custodians_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ingestion_sources": { + "name": "ingestion_sources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "ingestion_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "credentials": { + "name": "credentials", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "ingestion_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending_auth'" + }, + "last_sync_started_at": { + "name": "last_sync_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_sync_finished_at": { + "name": "last_sync_finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_sync_status_message": { + "name": "last_sync_status_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_state": { + "name": "sync_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "preserve_original_file": { + "name": "preserve_original_file", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "merged_into_id": { + "name": "merged_into_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_merged_into": { + "name": "idx_merged_into", + "columns": [ + { + "expression": "merged_into_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ingestion_sources_user_id_users_id_fk": { + "name": "ingestion_sources_user_id_users_id_fk", + "tableFrom": "ingestion_sources", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.roles": { + "name": "roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "policies": { + "name": "policies", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "roles_name_unique": { + "name": "roles_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + }, + "roles_slug_unique": { + "name": "roles_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_roles": { + "name": "user_roles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_roles_role_id_roles_id_fk": { + "name": "user_roles_role_id_roles_id_fk", + "tableFrom": "user_roles", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_roles_user_id_role_id_pk": { + "name": "user_roles_user_id_role_id_pk", + "columns": [ + "user_id", + "role_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'local'" + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_settings": { + "name": "system_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "api_keys_user_id_users_id_fk": { + "name": "api_keys_user_id_users_id_fk", + "tableFrom": "api_keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_sessions": { + "name": "sync_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "ingestion_source_id": { + "name": "ingestion_source_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "is_initial_import": { + "name": "is_initial_import", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "total_mailboxes": { + "name": "total_mailboxes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "completed_mailboxes": { + "name": "completed_mailboxes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "failed_mailboxes": { + "name": "failed_mailboxes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_messages": { + "name": "error_messages", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sync_sessions_ingestion_source_id_ingestion_sources_id_fk": { + "name": "sync_sessions_ingestion_source_id_ingestion_sources_id_fk", + "tableFrom": "sync_sessions", + "tableTo": "ingestion_sources", + "columnsFrom": [ + "ingestion_source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.journaling_sources": { + "name": "journaling_sources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allowed_ips": { + "name": "allowed_ips", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "require_tls": { + "name": "require_tls", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "smtp_username": { + "name": "smtp_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "smtp_password_hash": { + "name": "smtp_password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "journaling_source_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "ingestion_source_id": { + "name": "ingestion_source_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "routing_address": { + "name": "routing_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_received": { + "name": "total_received", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_received_at": { + "name": "last_received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "journaling_sources_ingestion_source_id_ingestion_sources_id_fk": { + "name": "journaling_sources_ingestion_source_id_ingestion_sources_id_fk", + "tableFrom": "journaling_sources", + "tableTo": "ingestion_sources", + "columnsFrom": [ + "ingestion_source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.retention_action": { + "name": "retention_action", + "schema": "public", + "values": [ + "delete_permanently", + "notify_admin" + ] + }, + "public.ingestion_provider": { + "name": "ingestion_provider", + "schema": "public", + "values": [ + "google_workspace", + "microsoft_365", + "generic_imap", + "pst_import", + "eml_import", + "mbox_import", + "smtp_journaling" + ] + }, + "public.ingestion_status": { + "name": "ingestion_status", + "schema": "public", + "values": [ + "active", + "paused", + "error", + "pending_auth", + "syncing", + "importing", + "auth_success", + "imported" + ] + }, + "public.audit_log_action": { + "name": "audit_log_action", + "schema": "public", + "values": [ + "CREATE", + "READ", + "UPDATE", + "DELETE", + "LOGIN", + "LOGOUT", + "SETUP", + "IMPORT", + "PAUSE", + "SYNC", + "UPLOAD", + "SEARCH", + "DOWNLOAD", + "GENERATE" + ] + }, + "public.audit_log_target_type": { + "name": "audit_log_target_type", + "schema": "public", + "values": [ + "ApiKey", + "ArchivedEmail", + "Dashboard", + "IngestionSource", + "JournalingSource", + "RetentionPolicy", + "RetentionLabel", + "LegalHold", + "Role", + "SystemEvent", + "SystemSettings", + "User", + "File" + ] + }, + "public.journaling_source_status": { + "name": "journaling_source_status", + "schema": "public", + "values": [ + "active", + "paused" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/backend/src/database/migrations/meta/0033_snapshot.json b/packages/backend/src/database/migrations/meta/0033_snapshot.json new file mode 100644 index 0000000..da60cf0 --- /dev/null +++ b/packages/backend/src/database/migrations/meta/0033_snapshot.json @@ -0,0 +1,1882 @@ +{ + "id": "3715c0a0-8472-483d-b5ff-69329de9f952", + "prevId": "9fab872e-0b67-41d9-be9e-c0494f845207", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.archived_emails": { + "name": "archived_emails", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ingestion_source_id": { + "name": "ingestion_source_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_email": { + "name": "user_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_id_header": { + "name": "message_id_header", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_message_id": { + "name": "provider_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sender_name": { + "name": "sender_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sender_email": { + "name": "sender_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipients": { + "name": "recipients", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "storage_path": { + "name": "storage_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_hash_sha256": { + "name": "storage_hash_sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "is_indexed": { + "name": "is_indexed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "has_attachments": { + "name": "has_attachments", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_on_legal_hold": { + "name": "is_on_legal_hold", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_journaled": { + "name": "is_journaled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "thread_id_idx": { + "name": "thread_id_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "provider_msg_source_idx": { + "name": "provider_msg_source_idx", + "columns": [ + { + "expression": "provider_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ingestion_source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "archived_emails_ingestion_source_id_ingestion_sources_id_fk": { + "name": "archived_emails_ingestion_source_id_ingestion_sources_id_fk", + "tableFrom": "archived_emails", + "tableTo": "ingestion_sources", + "columnsFrom": [ + "ingestion_source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.attachments": { + "name": "attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "content_hash_sha256": { + "name": "content_hash_sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_path": { + "name": "storage_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ingestion_source_id": { + "name": "ingestion_source_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "source_hash_idx": { + "name": "source_hash_idx", + "columns": [ + { + "expression": "ingestion_source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "content_hash_sha256", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "attachments_ingestion_source_id_ingestion_sources_id_fk": { + "name": "attachments_ingestion_source_id_ingestion_sources_id_fk", + "tableFrom": "attachments", + "tableTo": "ingestion_sources", + "columnsFrom": [ + "ingestion_source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_attachments": { + "name": "email_attachments", + "schema": "", + "columns": { + "email_id": { + "name": "email_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "attachment_id": { + "name": "attachment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "email_attachments_email_id_archived_emails_id_fk": { + "name": "email_attachments_email_id_archived_emails_id_fk", + "tableFrom": "email_attachments", + "tableTo": "archived_emails", + "columnsFrom": [ + "email_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_attachments_attachment_id_attachments_id_fk": { + "name": "email_attachments_attachment_id_attachments_id_fk", + "tableFrom": "email_attachments", + "tableTo": "attachments", + "columnsFrom": [ + "attachment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "email_attachments_email_id_attachment_id_pk": { + "name": "email_attachments_email_id_attachment_id_pk", + "columns": [ + "email_id", + "attachment_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "previous_hash": { + "name": "previous_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "actor_identifier": { + "name": "actor_identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_ip": { + "name": "actor_ip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action_type": { + "name": "action_type", + "type": "audit_log_action", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "audit_log_target_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "current_hash": { + "name": "current_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ediscovery_cases": { + "name": "ediscovery_cases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "created_by_identifier": { + "name": "created_by_identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ediscovery_cases_name_unique": { + "name": "ediscovery_cases_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_legal_holds": { + "name": "email_legal_holds", + "schema": "", + "columns": { + "email_id": { + "name": "email_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "legal_hold_id": { + "name": "legal_hold_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "applied_at": { + "name": "applied_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "applied_by_user_id": { + "name": "applied_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "email_legal_holds_email_id_archived_emails_id_fk": { + "name": "email_legal_holds_email_id_archived_emails_id_fk", + "tableFrom": "email_legal_holds", + "tableTo": "archived_emails", + "columnsFrom": [ + "email_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_legal_holds_legal_hold_id_legal_holds_id_fk": { + "name": "email_legal_holds_legal_hold_id_legal_holds_id_fk", + "tableFrom": "email_legal_holds", + "tableTo": "legal_holds", + "columnsFrom": [ + "legal_hold_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_legal_holds_applied_by_user_id_users_id_fk": { + "name": "email_legal_holds_applied_by_user_id_users_id_fk", + "tableFrom": "email_legal_holds", + "tableTo": "users", + "columnsFrom": [ + "applied_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "email_legal_holds_email_id_legal_hold_id_pk": { + "name": "email_legal_holds_email_id_legal_hold_id_pk", + "columns": [ + "email_id", + "legal_hold_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_retention_labels": { + "name": "email_retention_labels", + "schema": "", + "columns": { + "email_id": { + "name": "email_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "label_id": { + "name": "label_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "applied_at": { + "name": "applied_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "applied_by_user_id": { + "name": "applied_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "email_retention_labels_email_id_archived_emails_id_fk": { + "name": "email_retention_labels_email_id_archived_emails_id_fk", + "tableFrom": "email_retention_labels", + "tableTo": "archived_emails", + "columnsFrom": [ + "email_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_retention_labels_label_id_retention_labels_id_fk": { + "name": "email_retention_labels_label_id_retention_labels_id_fk", + "tableFrom": "email_retention_labels", + "tableTo": "retention_labels", + "columnsFrom": [ + "label_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_retention_labels_applied_by_user_id_users_id_fk": { + "name": "email_retention_labels_applied_by_user_id_users_id_fk", + "tableFrom": "email_retention_labels", + "tableTo": "users", + "columnsFrom": [ + "applied_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "email_retention_labels_email_id_label_id_pk": { + "name": "email_retention_labels_email_id_label_id_pk", + "columns": [ + "email_id", + "label_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.export_jobs": { + "name": "export_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "case_id": { + "name": "case_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "query": { + "name": "query", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_identifier": { + "name": "created_by_identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "export_jobs_case_id_ediscovery_cases_id_fk": { + "name": "export_jobs_case_id_ediscovery_cases_id_fk", + "tableFrom": "export_jobs", + "tableTo": "ediscovery_cases", + "columnsFrom": [ + "case_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.legal_holds": { + "name": "legal_holds", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "case_id": { + "name": "case_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "legal_holds_case_id_ediscovery_cases_id_fk": { + "name": "legal_holds_case_id_ediscovery_cases_id_fk", + "tableFrom": "legal_holds", + "tableTo": "ediscovery_cases", + "columnsFrom": [ + "case_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.retention_events": { + "name": "retention_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "event_name": { + "name": "event_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "event_timestamp": { + "name": "event_timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "target_criteria": { + "name": "target_criteria", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.retention_labels": { + "name": "retention_labels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "retention_period_days": { + "name": "retention_period_days", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_disabled": { + "name": "is_disabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.retention_policies": { + "name": "retention_policies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "retention_period_days": { + "name": "retention_period_days", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "action_on_expiry": { + "name": "action_on_expiry", + "type": "retention_action", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "conditions": { + "name": "conditions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ingestion_scope": { + "name": "ingestion_scope", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "retention_policies_name_unique": { + "name": "retention_policies_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custodians": { + "name": "custodians", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "ingestion_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "custodians_email_unique": { + "name": "custodians_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ingestion_sources": { + "name": "ingestion_sources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "ingestion_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "credentials": { + "name": "credentials", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "ingestion_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending_auth'" + }, + "last_sync_started_at": { + "name": "last_sync_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_sync_finished_at": { + "name": "last_sync_finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_sync_status_message": { + "name": "last_sync_status_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_state": { + "name": "sync_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "preserve_original_file": { + "name": "preserve_original_file", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "merged_into_id": { + "name": "merged_into_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_merged_into": { + "name": "idx_merged_into", + "columns": [ + { + "expression": "merged_into_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ingestion_sources_user_id_users_id_fk": { + "name": "ingestion_sources_user_id_users_id_fk", + "tableFrom": "ingestion_sources", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ingestion_sources_merged_into_id_ingestion_sources_id_fk": { + "name": "ingestion_sources_merged_into_id_ingestion_sources_id_fk", + "tableFrom": "ingestion_sources", + "tableTo": "ingestion_sources", + "columnsFrom": [ + "merged_into_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.roles": { + "name": "roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "policies": { + "name": "policies", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "roles_name_unique": { + "name": "roles_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + }, + "roles_slug_unique": { + "name": "roles_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_roles": { + "name": "user_roles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_roles_role_id_roles_id_fk": { + "name": "user_roles_role_id_roles_id_fk", + "tableFrom": "user_roles", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_roles_user_id_role_id_pk": { + "name": "user_roles_user_id_role_id_pk", + "columns": [ + "user_id", + "role_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'local'" + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_settings": { + "name": "system_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "api_keys_user_id_users_id_fk": { + "name": "api_keys_user_id_users_id_fk", + "tableFrom": "api_keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_sessions": { + "name": "sync_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "ingestion_source_id": { + "name": "ingestion_source_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "is_initial_import": { + "name": "is_initial_import", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "total_mailboxes": { + "name": "total_mailboxes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "completed_mailboxes": { + "name": "completed_mailboxes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "failed_mailboxes": { + "name": "failed_mailboxes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_messages": { + "name": "error_messages", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sync_sessions_ingestion_source_id_ingestion_sources_id_fk": { + "name": "sync_sessions_ingestion_source_id_ingestion_sources_id_fk", + "tableFrom": "sync_sessions", + "tableTo": "ingestion_sources", + "columnsFrom": [ + "ingestion_source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.journaling_sources": { + "name": "journaling_sources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allowed_ips": { + "name": "allowed_ips", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "require_tls": { + "name": "require_tls", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "smtp_username": { + "name": "smtp_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "smtp_password_hash": { + "name": "smtp_password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "journaling_source_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "ingestion_source_id": { + "name": "ingestion_source_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "routing_address": { + "name": "routing_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_received": { + "name": "total_received", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_received_at": { + "name": "last_received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "journaling_sources_ingestion_source_id_ingestion_sources_id_fk": { + "name": "journaling_sources_ingestion_source_id_ingestion_sources_id_fk", + "tableFrom": "journaling_sources", + "tableTo": "ingestion_sources", + "columnsFrom": [ + "ingestion_source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.retention_action": { + "name": "retention_action", + "schema": "public", + "values": [ + "delete_permanently", + "notify_admin" + ] + }, + "public.ingestion_provider": { + "name": "ingestion_provider", + "schema": "public", + "values": [ + "google_workspace", + "microsoft_365", + "generic_imap", + "pst_import", + "eml_import", + "mbox_import", + "smtp_journaling" + ] + }, + "public.ingestion_status": { + "name": "ingestion_status", + "schema": "public", + "values": [ + "active", + "paused", + "error", + "pending_auth", + "syncing", + "importing", + "auth_success", + "imported" + ] + }, + "public.audit_log_action": { + "name": "audit_log_action", + "schema": "public", + "values": [ + "CREATE", + "READ", + "UPDATE", + "DELETE", + "LOGIN", + "LOGOUT", + "SETUP", + "IMPORT", + "PAUSE", + "SYNC", + "UPLOAD", + "SEARCH", + "DOWNLOAD", + "GENERATE" + ] + }, + "public.audit_log_target_type": { + "name": "audit_log_target_type", + "schema": "public", + "values": [ + "ApiKey", + "ArchivedEmail", + "Dashboard", + "IngestionSource", + "JournalingSource", + "RetentionPolicy", + "RetentionLabel", + "LegalHold", + "Role", + "SystemEvent", + "SystemSettings", + "User", + "File" + ] + }, + "public.journaling_source_status": { + "name": "journaling_source_status", + "schema": "public", + "values": [ + "active", + "paused" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/backend/src/database/migrations/meta/_journal.json b/packages/backend/src/database/migrations/meta/_journal.json index 56797af..3878c85 100644 --- a/packages/backend/src/database/migrations/meta/_journal.json +++ b/packages/backend/src/database/migrations/meta/_journal.json @@ -225,6 +225,20 @@ "when": 1774623960683, "tag": "0031_bouncy_boomerang", "breakpoints": true + }, + { + "idx": 32, + "version": "7", + "when": 1774709286830, + "tag": "0032_exotic_the_twelve", + "breakpoints": true + }, + { + "idx": 33, + "version": "7", + "when": 1774719684064, + "tag": "0033_adorable_lockheed", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/backend/src/database/schema/ingestion-sources.ts b/packages/backend/src/database/schema/ingestion-sources.ts index f065809..6cda0c8 100644 --- a/packages/backend/src/database/schema/ingestion-sources.ts +++ b/packages/backend/src/database/schema/ingestion-sources.ts @@ -1,4 +1,14 @@ -import { boolean, jsonb, pgEnum, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core'; +import { + boolean, + index, + jsonb, + pgEnum, + pgTable, + text, + timestamp, + uuid, + type AnyPgColumn, +} from 'drizzle-orm/pg-core'; import { users } from './users'; import { relations } from 'drizzle-orm'; @@ -21,27 +31,47 @@ export const ingestionStatusEnum = pgEnum('ingestion_status', [ 'importing', 'auth_success', 'imported', + 'partially_active', ]); -export const ingestionSources = pgTable('ingestion_sources', { - id: uuid('id').primaryKey().defaultRandom(), - userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }), - name: text('name').notNull(), - provider: ingestionProviderEnum('provider').notNull(), - credentials: text('credentials'), - status: ingestionStatusEnum('status').notNull().default('pending_auth'), - lastSyncStartedAt: timestamp('last_sync_started_at', { withTimezone: true }), - lastSyncFinishedAt: timestamp('last_sync_finished_at', { withTimezone: true }), - lastSyncStatusMessage: text('last_sync_status_message'), - syncState: jsonb('sync_state'), - preserveOriginalFile: boolean('preserve_original_file').notNull().default(false), - createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), - updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), -}); +export const ingestionSources = pgTable( + 'ingestion_sources', + { + id: uuid('id').primaryKey().defaultRandom(), + userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }), + name: text('name').notNull(), + provider: ingestionProviderEnum('provider').notNull(), + credentials: text('credentials'), + status: ingestionStatusEnum('status').notNull().default('pending_auth'), + lastSyncStartedAt: timestamp('last_sync_started_at', { withTimezone: true }), + lastSyncFinishedAt: timestamp('last_sync_finished_at', { withTimezone: true }), + lastSyncStatusMessage: text('last_sync_status_message'), + syncState: jsonb('sync_state'), + preserveOriginalFile: boolean('preserve_original_file').notNull().default(false), + /** Self-referencing FK for merge groups. When set, this source is a child + * whose emails are logically grouped with the root source. Flat hierarchy only. */ + mergedIntoId: uuid('merged_into_id').references((): AnyPgColumn => ingestionSources.id, { + onDelete: 'set null', + }), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [index('idx_merged_into').on(table.mergedIntoId)] +); -export const ingestionSourcesRelations = relations(ingestionSources, ({ one }) => ({ +export const ingestionSourcesRelations = relations(ingestionSources, ({ one, many }) => ({ user: one(users, { fields: [ingestionSources.userId], references: [users.id], }), + /** The root source this child is merged into (null if this is a root). */ + mergedInto: one(ingestionSources, { + fields: [ingestionSources.mergedIntoId], + references: [ingestionSources.id], + relationName: 'mergedChildren', + }), + /** Child sources that are merged into this root. */ + children: many(ingestionSources, { + relationName: 'mergedChildren', + }), })); diff --git a/packages/backend/src/jobs/processors/sync-cycle-finished.processor.ts b/packages/backend/src/jobs/processors/sync-cycle-finished.processor.ts index 1774c95..1fdd89a 100644 --- a/packages/backend/src/jobs/processors/sync-cycle-finished.processor.ts +++ b/packages/backend/src/jobs/processors/sync-cycle-finished.processor.ts @@ -57,7 +57,7 @@ export default async (job: Job) => { // syncState was already merged incrementally by each process-mailbox job via // SyncSessionService.recordMailboxResult() — no deepmerge needed here. await IngestionService.update(ingestionSourceId, { - status, + status: source.status === 'paused' ? 'paused' : status, // Don't override paused status lastSyncFinishedAt: new Date(), lastSyncStatusMessage: message, }); diff --git a/packages/backend/src/locales/bg/translation.json b/packages/backend/src/locales/bg/translation.json index bf33ebe..d9e6067 100644 --- a/packages/backend/src/locales/bg/translation.json +++ b/packages/backend/src/locales/bg/translation.json @@ -14,7 +14,8 @@ "demoMode": "Тази операция не е разрешена в демо режим", "unauthorized": "Неоторизирано", "unknown": "Възникна неизвестна грешка", - "noPermissionToAction": "Нямате разрешение да извършите текущото действие." + "noPermissionToAction": "Нямате разрешение да извършите текущото действие.", + "deletion_disabled": "Изтриването е деактивирано за тази инстанция." }, "user": { "notFound": "Потребителят не е открит", @@ -65,5 +66,12 @@ }, "api": { "requestBodyInvalid": "Невалидно съдържание на заявката." + }, + "upload": { + "invalid_request": "Заявката за качване е невалидна или неправилно формирана.", + "stream_error": "При получаването на файла възникна грешка. Моля, опитайте отново.", + "parse_error": "Данните на качения файл не можаха да бъдат обработени.", + "storage_error": "Каченият файл не можа да бъде запазен. Моля, опитайте отново.", + "connection_error": "Връзката беше прекъсната по време на качването." } } diff --git a/packages/backend/src/locales/de/translation.json b/packages/backend/src/locales/de/translation.json index 4a861ec..5bbf6b9 100644 --- a/packages/backend/src/locales/de/translation.json +++ b/packages/backend/src/locales/de/translation.json @@ -59,5 +59,19 @@ "invalidFilePath": "Ungültiger Dateipfad", "fileNotFound": "Datei nicht gefunden", "downloadError": "Fehler beim Herunterladen der Datei" + }, + "apiKeys": { + "generateSuccess": "API-Schlüssel erfolgreich generiert.", + "deleteSuccess": "API-Schlüssel erfolgreich gelöscht." + }, + "api": { + "requestBodyInvalid": "Ungültiger Anfrage-Body." + }, + "upload": { + "invalid_request": "Die Upload-Anfrage ist ungültig oder fehlerhaft.", + "stream_error": "Beim Empfangen der Datei ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.", + "parse_error": "Die hochgeladenen Dateidaten konnten nicht verarbeitet werden.", + "storage_error": "Die hochgeladene Datei konnte nicht gespeichert werden. Bitte versuchen Sie es erneut.", + "connection_error": "Die Verbindung wurde während des Uploads unterbrochen." } } diff --git a/packages/backend/src/locales/el/translation.json b/packages/backend/src/locales/el/translation.json index 7656b73..a653431 100644 --- a/packages/backend/src/locales/el/translation.json +++ b/packages/backend/src/locales/el/translation.json @@ -14,7 +14,8 @@ "demoMode": "Αυτή η λειτουργία δεν επιτρέπεται σε λειτουργία επίδειξης.", "unauthorized": "Μη εξουσιοδοτημένο", "unknown": "Παρουσιάστηκε ένα άγνωστο σφάλμα", - "noPermissionToAction": "Δεν έχετε την άδεια να εκτελέσετε την τρέχουσα ενέργεια." + "noPermissionToAction": "Δεν έχετε την άδεια να εκτελέσετε την τρέχουσα ενέργεια.", + "deletion_disabled": "Η διαγραφή είναι απενεργοποιημένη για αυτήν την εγκατάσταση." }, "user": { "notFound": "Ο χρήστης δεν βρέθηκε", @@ -58,5 +59,19 @@ "invalidFilePath": "Μη έγκυρη διαδρομή αρχείου", "fileNotFound": "Το αρχείο δεν βρέθηκε", "downloadError": "Σφάλμα κατά τη λήψη του αρχείου" + }, + "apiKeys": { + "generateSuccess": "Το κλειδί API δημιουργήθηκε με επιτυχία.", + "deleteSuccess": "Το κλειδί API διαγράφηκε με επιτυχία." + }, + "api": { + "requestBodyInvalid": "Μη έγκυρο σώμα αιτήματος." + }, + "upload": { + "invalid_request": "Το αίτημα αποστολής είναι μη έγκυρο ή κακοσχηματισμένο.", + "stream_error": "Παρουσιάστηκε σφάλμα κατά τη λήψη του αρχείου. Παρακαλώ δοκιμάστε ξανά.", + "parse_error": "Αποτυχία επεξεργασίας των δεδομένων του αναρτημένου αρχείου.", + "storage_error": "Αποτυχία αποθήκευσης του αναρτημένου αρχείου. Παρακαλώ δοκιμάστε ξανά.", + "connection_error": "Η σύνδεση χάθηκε κατά τη διάρκεια της αποστολής." } } diff --git a/packages/backend/src/locales/es/translation.json b/packages/backend/src/locales/es/translation.json index d35be87..6c41ccd 100644 --- a/packages/backend/src/locales/es/translation.json +++ b/packages/backend/src/locales/es/translation.json @@ -14,7 +14,8 @@ "demoMode": "Esta operación no está permitida en modo de demostración.", "unauthorized": "No autorizado", "unknown": "Ocurrió un error desconocido", - "noPermissionToAction": "No tienes permiso para realizar la acción actual." + "noPermissionToAction": "No tienes permiso para realizar la acción actual.", + "deletion_disabled": "La eliminación está deshabilitada para esta instancia." }, "user": { "notFound": "Usuario no encontrado", @@ -58,5 +59,19 @@ "invalidFilePath": "Ruta de archivo no válida", "fileNotFound": "Archivo no encontrado", "downloadError": "Error al descargar el archivo" + }, + "apiKeys": { + "generateSuccess": "Clave de API generada correctamente.", + "deleteSuccess": "Clave de API eliminada correctamente." + }, + "api": { + "requestBodyInvalid": "El cuerpo de la solicitud no es válido." + }, + "upload": { + "invalid_request": "La solicitud de carga no es válida o está malformada.", + "stream_error": "Se produjo un error al recibir el archivo. Por favor, inténtalo de nuevo.", + "parse_error": "No se pudieron procesar los datos del archivo cargado.", + "storage_error": "No se pudo guardar el archivo cargado. Por favor, inténtalo de nuevo.", + "connection_error": "La conexión se perdió durante la carga." } } diff --git a/packages/backend/src/locales/et/translation.json b/packages/backend/src/locales/et/translation.json index ee545f1..c4f4738 100644 --- a/packages/backend/src/locales/et/translation.json +++ b/packages/backend/src/locales/et/translation.json @@ -14,7 +14,8 @@ "demoMode": "See toiming pole demorežiimis lubatud.", "unauthorized": "Volitamata", "unknown": "Ilmnes tundmatu viga", - "noPermissionToAction": "Teil pole praeguse toimingu tegemiseks luba." + "noPermissionToAction": "Teil pole praeguse toimingu tegemiseks luba.", + "deletion_disabled": "Kustutamine on selle eksemplari jaoks keelatud." }, "user": { "notFound": "Kasutajat ei leitud", @@ -58,5 +59,19 @@ "invalidFilePath": "Kehtetu faili tee", "fileNotFound": "Faili ei leitud", "downloadError": "Faili allalaadimisel ilmnes viga" + }, + "apiKeys": { + "generateSuccess": "API-võti genereeriti edukalt.", + "deleteSuccess": "API-võti kustutati edukalt." + }, + "api": { + "requestBodyInvalid": "Vigane päringu sisu." + }, + "upload": { + "invalid_request": "Üleslaadimispäring on vigane või valesti vormindatud.", + "stream_error": "Faili vastuvõtmisel ilmnes viga. Palun proovige uuesti.", + "parse_error": "Üleslaaditud faili andmeid ei õnnestunud töödelda.", + "storage_error": "Üleslaaditud faili salvestamine ebaõnnestus. Palun proovige uuesti.", + "connection_error": "Ühendus katkes üleslaadimise ajal." } } diff --git a/packages/backend/src/locales/fr/translation.json b/packages/backend/src/locales/fr/translation.json index 6984a2e..c38dc35 100644 --- a/packages/backend/src/locales/fr/translation.json +++ b/packages/backend/src/locales/fr/translation.json @@ -14,7 +14,8 @@ "demoMode": "Cette opération n'est pas autorisée en mode démo.", "unauthorized": "Non autorisé", "unknown": "Une erreur inconnue s'est produite", - "noPermissionToAction": "Vous n'avez pas la permission d'effectuer l'action en cours." + "noPermissionToAction": "Vous n'avez pas la permission d'effectuer l'action en cours.", + "deletion_disabled": "La suppression est désactivée pour cette instance." }, "user": { "notFound": "Utilisateur non trouvé", @@ -58,5 +59,19 @@ "invalidFilePath": "Chemin de fichier invalide", "fileNotFound": "Fichier non trouvé", "downloadError": "Erreur lors du téléchargement du fichier" + }, + "apiKeys": { + "generateSuccess": "Clé API générée avec succès.", + "deleteSuccess": "Clé API supprimée avec succès." + }, + "api": { + "requestBodyInvalid": "Corps de la requête invalide." + }, + "upload": { + "invalid_request": "La demande d'envoi est invalide ou malformée.", + "stream_error": "Une erreur s'est produite lors de la réception du fichier. Veuillez réessayer.", + "parse_error": "Impossible de traiter les données du fichier envoyé.", + "storage_error": "Impossible d'enregistrer le fichier envoyé. Veuillez réessayer.", + "connection_error": "La connexion a été perdue pendant l'envoi." } } diff --git a/packages/backend/src/locales/it/translation.json b/packages/backend/src/locales/it/translation.json index 1ce882e..d6834a8 100644 --- a/packages/backend/src/locales/it/translation.json +++ b/packages/backend/src/locales/it/translation.json @@ -5,7 +5,7 @@ "alreadyCompleted": "La configurazione è già stata completata." }, "login": { - "emailAndPasswordRequired": "Email and password are required", + "emailAndPasswordRequired": "Email e password sono obbligatorie", "invalidCredentials": "Credenziali non valide" } }, @@ -14,7 +14,8 @@ "demoMode": "Questa operazione non è consentita in modalità demo.", "unauthorized": "Non autorizzato", "unknown": "Si è verificato un errore sconosciuto", - "noPermissionToAction": "Non hai il permesso di eseguire l'azione corrente." + "noPermissionToAction": "Non hai il permesso di eseguire l'azione corrente.", + "deletion_disabled": "L'eliminazione è disabilitata per questa istanza." }, "user": { "notFound": "Utente non trovato", @@ -58,5 +59,19 @@ "invalidFilePath": "Percorso del file non valido", "fileNotFound": "File non trovato", "downloadError": "Errore durante il download del file" + }, + "apiKeys": { + "generateSuccess": "Chiave API generata con successo.", + "deleteSuccess": "Chiave API eliminata con successo." + }, + "api": { + "requestBodyInvalid": "Corpo della richiesta non valido." + }, + "upload": { + "invalid_request": "La richiesta di caricamento non è valida o è malformata.", + "stream_error": "Si è verificato un errore durante la ricezione del file. Si prega di riprovare.", + "parse_error": "Impossibile elaborare i dati del file caricato.", + "storage_error": "Impossibile salvare il file caricato. Si prega di riprovare.", + "connection_error": "La connessione è stata persa durante il caricamento." } } diff --git a/packages/backend/src/locales/ja/translation.json b/packages/backend/src/locales/ja/translation.json index fc67aa3..87c3a9c 100644 --- a/packages/backend/src/locales/ja/translation.json +++ b/packages/backend/src/locales/ja/translation.json @@ -14,7 +14,8 @@ "demoMode": "この操作はデモモードでは許可されていません。", "unauthorized": "不正なアクセス", "unknown": "不明なエラーが発生しました", - "noPermissionToAction": "現在の操作を実行する権限がありません。" + "noPermissionToAction": "現在の操作を実行する権限がありません。", + "deletion_disabled": "このインスタンスでは削除が無効になっています。" }, "user": { "notFound": "ユーザーが見つかりません", @@ -58,5 +59,19 @@ "invalidFilePath": "無効なファイルパス", "fileNotFound": "ファイルが見つかりません", "downloadError": "ファイルのダウンロード中にエラーが発生しました" + }, + "apiKeys": { + "generateSuccess": "APIキーが正常に生成されました。", + "deleteSuccess": "APIキーが正常に削除されました。" + }, + "api": { + "requestBodyInvalid": "リクエストボディが無効です。" + }, + "upload": { + "invalid_request": "アップロードリクエストが無効または不正な形式です。", + "stream_error": "ファイルの受信中にエラーが発生しました。もう一度お試しください。", + "parse_error": "アップロードされたファイルのデータを処理できませんでした。", + "storage_error": "アップロードされたファイルを保存できませんでした。もう一度お試しください。", + "connection_error": "アップロード中に接続が切断されました。" } } diff --git a/packages/backend/src/locales/nl/translation.json b/packages/backend/src/locales/nl/translation.json index 72197ad..b7cb015 100644 --- a/packages/backend/src/locales/nl/translation.json +++ b/packages/backend/src/locales/nl/translation.json @@ -14,7 +14,8 @@ "demoMode": "Deze bewerking is niet toegestaan in de demomodus.", "unauthorized": "Ongeautoriseerd", "unknown": "Er is een onbekende fout opgetreden", - "noPermissionToAction": "U heeft geen toestemming om de huidige actie uit te voeren." + "noPermissionToAction": "U heeft geen toestemming om de huidige actie uit te voeren.", + "deletion_disabled": "Verwijderen is uitgeschakeld voor deze instantie." }, "user": { "notFound": "Gebruiker niet gevonden", @@ -58,5 +59,19 @@ "invalidFilePath": "Ongeldig bestandspad", "fileNotFound": "Bestand niet gevonden", "downloadError": "Fout bij het downloaden van het bestand" + }, + "apiKeys": { + "generateSuccess": "API-sleutel succesvol gegenereerd.", + "deleteSuccess": "API-sleutel succesvol verwijderd." + }, + "api": { + "requestBodyInvalid": "Ongeldige inhoud van het verzoek." + }, + "upload": { + "invalid_request": "Het uploadverzoek is ongeldig of onjuist geformatteerd.", + "stream_error": "Er is een fout opgetreden bij het ontvangen van het bestand. Probeer het opnieuw.", + "parse_error": "De gegevens van het geüploade bestand konden niet worden verwerkt.", + "storage_error": "Het geüploade bestand kon niet worden opgeslagen. Probeer het opnieuw.", + "connection_error": "De verbinding is verbroken tijdens het uploaden." } } diff --git a/packages/backend/src/locales/pt/translation.json b/packages/backend/src/locales/pt/translation.json index bf2c253..c586167 100644 --- a/packages/backend/src/locales/pt/translation.json +++ b/packages/backend/src/locales/pt/translation.json @@ -14,7 +14,8 @@ "demoMode": "Esta operação não é permitida no modo de demonstração.", "unauthorized": "Não autorizado", "unknown": "Ocorreu um erro desconhecido", - "noPermissionToAction": "Você não tem permissão para executar a ação atual." + "noPermissionToAction": "Você não tem permissão para executar a ação atual.", + "deletion_disabled": "A exclusão está desabilitada para esta instância." }, "user": { "notFound": "Usuário não encontrado", @@ -58,5 +59,19 @@ "invalidFilePath": "Caminho de arquivo inválido", "fileNotFound": "Arquivo não encontrado", "downloadError": "Erro ao baixar o arquivo" + }, + "apiKeys": { + "generateSuccess": "Chave de API gerada com sucesso.", + "deleteSuccess": "Chave de API excluída com sucesso." + }, + "api": { + "requestBodyInvalid": "Corpo da requisição inválido." + }, + "upload": { + "invalid_request": "A requisição de envio é inválida ou malformada.", + "stream_error": "Ocorreu um erro ao receber o arquivo. Por favor, tente novamente.", + "parse_error": "Não foi possível processar os dados do arquivo enviado.", + "storage_error": "Não foi possível salvar o arquivo enviado. Por favor, tente novamente.", + "connection_error": "A conexão foi perdida durante o envio." } } diff --git a/packages/backend/src/services/ArchivedEmailService.ts b/packages/backend/src/services/ArchivedEmailService.ts index 8d1af61..8341fe4 100644 --- a/packages/backend/src/services/ArchivedEmailService.ts +++ b/packages/backend/src/services/ArchivedEmailService.ts @@ -1,4 +1,4 @@ -import { count, desc, eq, asc, and } from 'drizzle-orm'; +import { count, desc, eq, asc, and, inArray } from 'drizzle-orm'; import { db } from '../database'; import { archivedEmails, @@ -16,6 +16,7 @@ import type { } from '@open-archiver/types'; import { StorageService } from './StorageService'; import { SearchService } from './SearchService'; +import { IngestionService } from './IngestionService'; import type { Readable } from 'stream'; import { AuditService } from './AuditService'; import { User } from '@open-archiver/types'; @@ -59,7 +60,14 @@ export class ArchivedEmailService { ): Promise { const offset = (page - 1) * limit; const { drizzleFilter } = await FilterBuilder.create(userId, 'archive', 'read'); - const where = and(eq(archivedEmails.ingestionSourceId, ingestionSourceId), drizzleFilter); + + // Expand to the full merge group so emails from children appear when browsing a root source + const groupIds = await IngestionService.findGroupSourceIds(ingestionSourceId); + const sourceFilter = + groupIds.length === 1 + ? eq(archivedEmails.ingestionSourceId, groupIds[0]) + : inArray(archivedEmails.ingestionSourceId, groupIds); + const where = and(sourceFilter, drizzleFilter); const countQuery = db .select({ @@ -137,12 +145,15 @@ export class ArchivedEmailService { let threadEmails: ThreadEmail[] = []; + // Expand thread query to the full merge group so threads can span across merged sources if (email.threadId) { + const groupIds = await IngestionService.findGroupSourceIds(email.ingestionSourceId); + const sourceFilter = + groupIds.length === 1 + ? eq(archivedEmails.ingestionSourceId, groupIds[0]) + : inArray(archivedEmails.ingestionSourceId, groupIds); threadEmails = await db.query.archivedEmails.findMany({ - where: and( - eq(archivedEmails.threadId, email.threadId), - eq(archivedEmails.ingestionSourceId, email.ingestionSourceId) - ), + where: and(eq(archivedEmails.threadId, email.threadId), sourceFilter), orderBy: [asc(archivedEmails.sentAt)], columns: { id: true, diff --git a/packages/backend/src/services/IngestionService.ts b/packages/backend/src/services/IngestionService.ts index ec0d2d8..b836cb0 100644 --- a/packages/backend/src/services/IngestionService.ts +++ b/packages/backend/src/services/IngestionService.ts @@ -8,7 +8,7 @@ import type { IngestionProvider, PendingEmail, } from '@open-archiver/types'; -import { and, desc, eq, or } from 'drizzle-orm'; +import { and, desc, eq, inArray, or } from 'drizzle-orm'; import { CryptoService } from './CryptoService'; import { EmailProviderFactory } from './EmailProviderFactory'; import { ingestionQueue } from '../jobs/queues'; @@ -61,14 +61,22 @@ export class IngestionService { actor: User, actorIp: string ): Promise { - const { providerConfig, ...rest } = dto; + const { providerConfig, mergedIntoId, ...rest } = dto; const encryptedCredentials = CryptoService.encryptObject(providerConfig); + // Resolve merge target: if mergedIntoId points to a child, follow to the root. + let resolvedMergedIntoId: string | undefined; + if (mergedIntoId) { + const target = await this.findById(mergedIntoId); + resolvedMergedIntoId = target.mergedIntoId ?? target.id; + } + const valuesToInsert = { userId, ...rest, status: 'pending_auth' as const, credentials: encryptedCredentials, + mergedIntoId: resolvedMergedIntoId ?? null, }; const [newSource] = await db.insert(ingestionSources).values(valuesToInsert).returning(); @@ -207,6 +215,60 @@ export class IngestionService { return decryptedSource; } + /** + * Returns all ingestionSourceId values in a merge group given any member's ID. + * If the source is standalone (no parent, no children), returns just its own ID. + */ + public static async findGroupSourceIds(sourceId: string): Promise { + const source = await this.findById(sourceId); + const rootId = source.mergedIntoId ?? source.id; + + const children = await db + .select({ id: ingestionSources.id }) + .from(ingestionSources) + .where(eq(ingestionSources.mergedIntoId, rootId)); + + return [rootId, ...children.map((c) => c.id)]; + } + + /** + * Detaches a child source from its merge group, making it standalone. + */ + public static async unmerge( + id: string, + actor: User, + actorIp: string + ): Promise { + const source = await this.findById(id); + if (!source.mergedIntoId) { + throw new Error('Source is not merged into another source.'); + } + + const [updated] = await db + .update(ingestionSources) + .set({ mergedIntoId: null }) + .where(eq(ingestionSources.id, id)) + .returning(); + + await this.auditService.createAuditLog({ + actorIdentifier: actor.id, + actionType: 'UPDATE', + targetType: 'IngestionSource', + targetId: id, + actorIp, + details: { + action: 'unmerge', + previousParentId: source.mergedIntoId, + }, + }); + + const decrypted = this.decryptSource(updated); + if (!decrypted) { + throw new Error('Failed to decrypt unmerged source.'); + } + return decrypted; + } + public static async delete( id: string, actor: User, @@ -221,6 +283,18 @@ export class IngestionService { throw new Error('Ingestion source not found'); } + // If this is a root source with children, delete all children first + if (!source.mergedIntoId) { + const children = await db + .select({ id: ingestionSources.id }) + .from(ingestionSources) + .where(eq(ingestionSources.mergedIntoId, id)); + + for (const child of children) { + await this.delete(child.id, actor, actorIp, force); + } + } + // Delete all emails and attachments from storage const storage = new StorageService(); const emailPath = `${config.storage.openArchiverFolderName}/${source.name.replaceAll(' ', '-')}-${source.id}/`; @@ -327,6 +401,32 @@ export class IngestionService { }); await ingestionQueue.add('continuous-sync', { ingestionSourceId: source.id }); + + // If this is a root source, also trigger sync for all non-file-based active/error children + if (!source.mergedIntoId) { + const fileBasedProviders = this.returnFileBasedIngestions(); + const children = await db + .select({ + id: ingestionSources.id, + provider: ingestionSources.provider, + status: ingestionSources.status, + }) + .from(ingestionSources) + .where(eq(ingestionSources.mergedIntoId, id)); + + for (const child of children) { + if ( + !fileBasedProviders.includes(child.provider) && + (child.status === 'active' || child.status === 'error') + ) { + logger.info( + { childId: child.id, parentId: id }, + 'Cascading force sync to child source.' + ); + await ingestionQueue.add('continuous-sync', { ingestionSourceId: child.id }); + } + } + } } public static async performBulkImport( @@ -396,14 +496,23 @@ export class IngestionService { * Pre-fetch duplicate check to avoid unnecessary API calls during ingestion. * Checks both providerMessageId (for Google/Microsoft API IDs) and * messageIdHeader (for IMAP/PST/EML/Mbox RFC Message-IDs and pre-migration rows). + * + * The check is scoped to the full merge group so that emails already archived + * by a sibling source are not re-downloaded and stored again. */ public static async doesEmailExist( messageId: string, ingestionSourceId: string ): Promise { + const groupIds = await this.findGroupSourceIds(ingestionSourceId); + const sourceFilter = + groupIds.length === 1 + ? eq(archivedEmails.ingestionSourceId, groupIds[0]) + : inArray(archivedEmails.ingestionSourceId, groupIds); + const existingEmail = await db.query.archivedEmails.findFirst({ where: and( - eq(archivedEmails.ingestionSourceId, ingestionSourceId), + sourceFilter, or( eq(archivedEmails.providerMessageId, messageId), eq(archivedEmails.messageIdHeader, messageId) @@ -424,6 +533,13 @@ export class IngestionService { // Read the raw bytes from the temp file written by the connector const rawEmlBuffer = await readFile(email.tempFilePath); + // If this source is a child in a merge group, redirect all storage and DB + // ownership to the root source. Child sources are "assistants" — they fetch + // emails on behalf of the root but never own any stored content. + const effectiveSource = source.mergedIntoId + ? await IngestionService.findById(source.mergedIntoId) + : source; + // Generate a unique message ID for the email. If the email already has a message-id header, use that. // Otherwise, generate a new one based on the email's hash, source ID, and email ID. const messageIdHeader = email.headers.get('message-id'); @@ -438,12 +554,17 @@ export class IngestionService { .update(rawEmlBuffer) .digest('hex')}-${source.id}-${email.id}`; } - // Check if an email with the same message ID has already been imported for the current ingestion source. This is to prevent duplicate imports when an email is present in multiple mailboxes (e.g., "Inbox" and "All Mail"). + // Check if an email with the same message ID has already been imported + // within the merge group. This prevents duplicate imports when the same + // email exists in multiple mailboxes or across merged ingestion sources. + const groupIds = await IngestionService.findGroupSourceIds(source.id); + const groupSourceFilter = + groupIds.length === 1 + ? eq(archivedEmails.ingestionSourceId, groupIds[0]) + : inArray(archivedEmails.ingestionSourceId, groupIds); + const existingEmail = await db.query.archivedEmails.findFirst({ - where: and( - eq(archivedEmails.messageIdHeader, messageId), - eq(archivedEmails.ingestionSourceId, source.id) - ), + where: and(eq(archivedEmails.messageIdHeader, messageId), groupSourceFilter), }); if (existingEmail) { @@ -455,26 +576,29 @@ export class IngestionService { } const sanitizedPath = email.path ? email.path : ''; - const emailPath = `${config.storage.openArchiverFolderName}/${source.name.replaceAll(' ', '-')}-${source.id}/emails/${sanitizedPath}${email.id}.eml`; + // Use effectiveSource (root) for storage path and DB ownership. + // Child sources are assistants; all content physically belongs to the root. + const emailPath = `${config.storage.openArchiverFolderName}/${effectiveSource.name.replaceAll(' ', '-')}-${effectiveSource.id}/emails/${sanitizedPath}${email.id}.eml`; // GoBD / Preserve Original File mode: store the unmodified raw EML as-is. // No attachment stripping, no attachment table records — the full MIME body // including attachments is preserved in the single .eml file. - if (source.preserveOriginalFile) { + // Use the root (effectiveSource) compliance mode as authoritative. + if (effectiveSource.preserveOriginalFile) { const emailHash = createHash('sha256').update(rawEmlBuffer).digest('hex'); - // Message-level deduplication by file hash + // Message-level deduplication by file hash, scoped to the effective (root) source const hashDuplicate = await db.query.archivedEmails.findFirst({ where: and( eq(archivedEmails.storageHashSha256, emailHash), - eq(archivedEmails.ingestionSourceId, source.id) + eq(archivedEmails.ingestionSourceId, effectiveSource.id) ), columns: { id: true }, }); if (hashDuplicate) { logger.info( - { emailHash, ingestionSourceId: source.id }, + { emailHash, ingestionSourceId: effectiveSource.id }, 'Skipping duplicate email (hash-level dedup, preserve original mode)' ); return null; @@ -486,7 +610,8 @@ export class IngestionService { const [archivedEmail] = await db .insert(archivedEmails) .values({ - ingestionSourceId: source.id, + // Always assign to root (effectiveSource) + ingestionSourceId: effectiveSource.id, userEmail, threadId: email.threadId, messageIdHeader: messageId, @@ -523,7 +648,8 @@ export class IngestionService { const [archivedEmail] = await db .insert(archivedEmails) .values({ - ingestionSourceId: source.id, + // Always assign to root (effectiveSource) + ingestionSourceId: effectiveSource.id, userEmail, threadId: email.threadId, messageIdHeader: messageId, @@ -553,54 +679,45 @@ export class IngestionService { .update(attachmentBuffer) .digest('hex'); - // Check if an attachment with the same hash already exists for this source + // Check if an attachment with the same hash already exists for the root source const existingAttachment = await db.query.attachments.findFirst({ where: and( eq(attachmentsSchema.contentHashSha256, attachmentHash), - eq(attachmentsSchema.ingestionSourceId, source.id) + eq(attachmentsSchema.ingestionSourceId, effectiveSource.id) ), }); - let storagePath: string; + let attachmentId: string; if (existingAttachment) { - // If it exists, reuse the storage path and don't save the file again - storagePath = existingAttachment.storagePath; + attachmentId = existingAttachment.id; logger.info( { attachmentHash, - ingestionSourceId: source.id, - reusedPath: storagePath, + ingestionSourceId: effectiveSource.id, + reusedPath: existingAttachment.storagePath, }, 'Reusing existing attachment file for deduplication.' ); } else { - // If it's a new attachment, create a unique path and save it + // New attachment: store under the root source's folder const uniqueId = randomUUID().slice(0, 7); - storagePath = `${config.storage.openArchiverFolderName}/${source.name.replaceAll(' ', '-')}-${source.id}/attachments/${uniqueId}-${attachment.filename}`; - await storage.put(storagePath, attachmentBuffer); - } - - let attachmentRecord = existingAttachment; - - if (!attachmentRecord) { - // If it's a new attachment, create a unique path and save it - const uniqueId = randomUUID().slice(0, 5); - const storagePath = `${config.storage.openArchiverFolderName}/${source.name.replaceAll(' ', '-')}-${source.id}/attachments/${uniqueId}-${attachment.filename}`; + const storagePath = `${config.storage.openArchiverFolderName}/${effectiveSource.name.replaceAll(' ', '-')}-${effectiveSource.id}/attachments/${uniqueId}-${attachment.filename}`; await storage.put(storagePath, attachmentBuffer); - // Insert a new attachment record - [attachmentRecord] = await db + const [newRecord] = await db .insert(attachmentsSchema) .values({ filename: attachment.filename, mimeType: attachment.contentType, sizeBytes: attachment.size, contentHashSha256: attachmentHash, - storagePath: storagePath, - ingestionSourceId: source.id, + storagePath, + // Always assign attachment ownership to root (effectiveSource) + ingestionSourceId: effectiveSource.id, }) .returning(); + attachmentId = newRecord.id; } // Link the attachment record (either new or existing) to the email @@ -608,7 +725,7 @@ export class IngestionService { .insert(emailAttachments) .values({ emailId: archivedEmail.id, - attachmentId: attachmentRecord.id, + attachmentId, }) .onConflictDoNothing(); } diff --git a/packages/backend/src/services/SearchService.ts b/packages/backend/src/services/SearchService.ts index 3d784be..c8baf59 100644 --- a/packages/backend/src/services/SearchService.ts +++ b/packages/backend/src/services/SearchService.ts @@ -9,6 +9,7 @@ import type { } from '@open-archiver/types'; import { FilterBuilder } from './FilterBuilder'; import { AuditService } from './AuditService'; +import { IngestionService } from './IngestionService'; export class SearchService { private client: MeiliSearch; @@ -75,13 +76,24 @@ export class SearchService { }; if (filters) { - const filterStrings = Object.entries(filters).map(([key, value]) => { - if (typeof value === 'string') { - return `${key} = '${value}'`; + const filterParts: string[] = []; + for (const [key, value] of Object.entries(filters)) { + // Expand ingestionSourceId to the full merge group + if (key === 'ingestionSourceId' && typeof value === 'string') { + const groupIds = await IngestionService.findGroupSourceIds(value); + if (groupIds.length === 1) { + filterParts.push(`ingestionSourceId = '${groupIds[0]}'`); + } else { + const inList = groupIds.map((id) => `'${id}'`).join(', '); + filterParts.push(`ingestionSourceId IN [${inList}]`); + } + } else if (typeof value === 'string') { + filterParts.push(`${key} = '${value}'`); + } else { + filterParts.push(`${key} = ${value}`); } - return `${key} = ${value}`; - }); - searchParams.filter = filterStrings.join(' AND '); + } + searchParams.filter = filterParts.join(' AND '); } // Create a filter based on the user's permissions. diff --git a/packages/frontend/src/lib/components/custom/IngestionSourceForm.svelte b/packages/frontend/src/lib/components/custom/IngestionSourceForm.svelte index 14e96aa..bf7e46f 100644 --- a/packages/frontend/src/lib/components/custom/IngestionSourceForm.svelte +++ b/packages/frontend/src/lib/components/custom/IngestionSourceForm.svelte @@ -1,5 +1,5 @@
@@ -443,26 +458,98 @@ {/if} -
-
- - - - -
- + +
+ + + {#if showAdvanced} +
+
+
+ + + + +
+ +
+ + + {#if !source && mergeableRootSources.length > 0} +
+
+ + + + +
+ +
+ + {#if mergeEnabled} +
+
+
+ + + {mergeTriggerContent} + + + {#each mergeableRootSources as rootSource} + + {rootSource.name} ({rootSource.provider + .split('_') + .join(' ')}) + + {/each} + + +
+
+ {/if} + {/if} +
+ {/if}
diff --git a/packages/frontend/src/lib/translations/de.json b/packages/frontend/src/lib/translations/de.json index 768da27..bc21d50 100644 --- a/packages/frontend/src/lib/translations/de.json +++ b/packages/frontend/src/lib/translations/de.json @@ -35,12 +35,28 @@ "cancel": "Abbrechen", "not_found": "E-Mail nicht gefunden.", "integrity_report": "Integritätsbericht", + "download_integrity_report_pdf": "Integritätsbericht herunterladen (PDF)", + "downloading_integrity_report": "Wird erstellt...", + "integrity_report_download_error": "Der Integritätsbericht konnte nicht erstellt werden.", "email_eml": "E-Mail (.eml)", "valid": "Gültig", "invalid": "Ungültig", "integrity_check_failed_title": "Integritätsprüfung fehlgeschlagen", "integrity_check_failed_message": "Die Integrität der E-Mail und ihrer Anhänge konnte nicht überprüft werden.", - "integrity_report_description": "Dieser Bericht überprüft, ob der Inhalt Ihrer archivierten E-Mails nicht verändert wurde." + "integrity_report_description": "Dieser Bericht überprüft, ob der Inhalt Ihrer archivierten E-Mails nicht verändert wurde.", + "retention_policy": "Aufbewahrungsrichtlinie", + "retention_policy_description": "Zeigt, welche Aufbewahrungsrichtlinie für diese E-Mail gilt und wann die Löschung geplant ist.", + "retention_no_policy": "Keine Richtlinie anwendbar – diese E-Mail wird nicht automatisch gelöscht.", + "retention_period": "Aufbewahrungsfrist", + "retention_action": "Aktion bei Ablauf", + "retention_matching_policies": "Zutreffende Richtlinien", + "retention_delete_permanently": "Endgültige Löschung", + "retention_scheduled_deletion": "Geplante Löschung", + "retention_policy_overridden_by_label": "Diese Richtlinie wird durch das Aufbewahrungslabel überschrieben: ", + "embedded_attachments": "Eingebettete Anhänge", + "embedded": "Eingebettet", + "embedded_attachment_title": "Eingebetteter Anhang", + "embedded_attachment_description": "Dieser Anhang ist in die ursprüngliche E-Mail-Datei eingebettet und kann nicht separat heruntergeladen werden. Um diesen Anhang zu erhalten, laden Sie die vollständige E-Mail-Datei (.eml) herunter." }, "ingestions": { "title": "Erfassungsquellen", @@ -71,7 +87,19 @@ "confirm": "Bestätigen", "cancel": "Abbrechen", "bulk_delete_confirmation_title": "Möchten Sie wirklich {{count}} ausgewählte Erfassungen löschen?", - "bulk_delete_confirmation_description": "Dadurch werden alle archivierten E-Mails, Anhänge, Indizierungen und Dateien, die mit diesen Erfassungen verknüpft sind, gelöscht. Wenn Sie nur die Synchronisierung neuer E-Mails beenden möchten, können Sie stattdessen die Erfassungen anhalten." + "bulk_delete_confirmation_description": "Dadurch werden alle archivierten E-Mails, Anhänge, Indizierungen und Dateien, die mit diesen Erfassungen verknüpft sind, gelöscht. Wenn Sie nur die Synchronisierung neuer E-Mails beenden möchten, können Sie stattdessen die Erfassungen anhalten.", + "merged_sources": "zusammengeführte Quellen", + "unmerge": "Trennen", + "unmerge_success": "Die Quelle wurde von ihrer Gruppe getrennt.", + "unmerge_confirmation_title": "Diese Quelle trennen?", + "unmerge_confirmation_description": "Dadurch wird die untergeordnete Quelle von der Gruppe getrennt und als eigenständige Erfassung eingerichtet. Bitte beachten Sie Folgendes:", + "unmerge_warning_emails": "Bereits von dieser Quelle erfasste E-Mails sind unter der Hauptquelle gespeichert. Sie verbleiben dort und werden nicht verschoben.", + "unmerge_warning_future": "Nur neue E-Mails, die nach der Trennung erfasst werden, werden unter dieser Quelle gespeichert.", + "unmerge_confirm": "Trennen", + "unmerging": "Wird getrennt", + "delete_root_warning": "Diese Erfassung hat {{count}} zusammengeführte Quelle(n). Beim Löschen werden auch alle zusammengeführten Quellen und deren Daten entfernt.", + "expand": "Aufklappen", + "collapse": "Zuklappen" }, "search": { "title": "Suche", @@ -118,6 +146,23 @@ "confirm": "Bestätigen", "cancel": "Abbrechen" }, + "account": { + "title": "Kontoeinstellungen", + "description": "Verwalten Sie Ihr Profil und Ihre Sicherheitseinstellungen.", + "personal_info": "Persönliche Informationen", + "personal_info_desc": "Aktualisieren Sie Ihre persönlichen Daten.", + "security": "Sicherheit", + "security_desc": "Verwalten Sie Ihr Passwort und Ihre Sicherheitseinstellungen.", + "edit_profile": "Profil bearbeiten", + "change_password": "Passwort ändern", + "edit_profile_desc": "Nehmen Sie hier Änderungen an Ihrem Profil vor.", + "change_password_desc": "Ändern Sie Ihr Passwort. Sie müssen Ihr aktuelles Passwort eingeben.", + "current_password": "Aktuelles Passwort", + "new_password": "Neues Passwort", + "confirm_new_password": "Neues Passwort bestätigen", + "operation_successful": "Vorgang erfolgreich", + "passwords_do_not_match": "Passwörter stimmen nicht überein" + }, "system_settings": { "title": "Systemeinstellungen", "system_settings": "Systemeinstellungen", @@ -154,6 +199,76 @@ "confirm": "Bestätigen", "cancel": "Abbrechen" }, + "components": { + "charts": { + "emails_ingested": "E-Mails aufgenommen", + "storage_used": "Speicher verwendet", + "emails": "E-Mails" + }, + "common": { + "submitting": "Übermittlung...", + "submit": "Übermitteln", + "save": "Speichern" + }, + "email_preview": { + "loading": "E-Mail-Vorschau wird geladen...", + "render_error": "E-Mail-Vorschau konnte nicht gerendert werden.", + "not_available": "Rohe .eml-Datei für diese E-Mail nicht verfügbar." + }, + "footer": { + "all_rights_reserved": "Alle Rechte vorbehalten.", + "new_version_available": "Neue Version verfügbar" + }, + "ingestion_source_form": { + "provider_generic_imap": "Generisches IMAP", + "provider_google_workspace": "Google Workspace", + "provider_microsoft_365": "Microsoft 365", + "provider_pst_import": "PST-Import", + "provider_eml_import": "EML-Import", + "provider_mbox_import": "Mbox-Import", + "select_provider": "Wählen Sie einen Anbieter", + "import_method": "Importmethode", + "upload_file": "Datei hochladen", + "local_path": "Lokaler Pfad (empfohlen für große Dateien)", + "local_file_path": "Lokaler Dateipfad", + "service_account_key": "Dienstkontoschlüssel (JSON)", + "service_account_key_placeholder": "Fügen Sie den JSON-Inhalt Ihres Dienstkontoschlüssels ein", + "impersonated_admin_email": "Impersonierte Admin-E-Mail", + "client_id": "Anwendungs-(Client-)ID", + "client_secret": "Client-Geheimniswert", + "client_secret_placeholder": "Geben Sie den Geheimniswert ein, nicht die Geheimnis-ID", + "tenant_id": "Verzeichnis-(Mandanten-)ID", + "host": "Host", + "port": "Port", + "username": "Benutzername", + "use_tls": "TLS verwenden", + "allow_insecure_cert": "Unsicheres Zertifikat zulassen", + "pst_file": "PST-Datei", + "eml_file": "EML-Datei", + "mbox_file": "Mbox-Datei", + "heads_up": "Achtung!", + "org_wide_warning": "Bitte beachten Sie, dass dies ein organisationsweiter Vorgang ist. Diese Art von Erfassungen importiert und indiziert alle E-Mail-Postfächer in Ihrer Organisation. Wenn Sie nur bestimmte E-Mail-Postfächer importieren möchten, verwenden Sie den IMAP-Connector.", + "upload_failed": "Hochladen fehlgeschlagen, bitte versuchen Sie es erneut", + "upload_network_error": "Der Server konnte den Upload nicht verarbeiten. Die Datei überschreitet möglicherweise das konfigurierte Upload-Größenlimit (BODY_SIZE_LIMIT). Verwenden Sie für sehr große Dateien stattdessen die Option \"Lokaler Pfad\".", + "merge_into": "In bestehende Erfassung zusammenführen", + "merge_into_description": "E-Mails aus dieser Quelle werden mit der ausgewählten Erfassung gruppiert. Beide Quellen synchronisieren unabhängig, aber E-Mails werden gemeinsam angezeigt.", + "merge_into_tooltip": "Beim Zusammenführen wird diese neue Quelle zu einer untergeordneten Quelle der ausgewählten Haupterfassung. Alle von dieser Quelle abgerufenen E-Mails werden physisch unter der Haupterfassung gespeichert – nicht unter dieser.

Die Einstellung Originaldatei beibehalten (GoBD-Konformität) der Haupterfassung gilt für die gesamte Gruppe. Die Einstellung in diesem Formular wird ignoriert, wenn die Zusammenführung aktiviert ist.

Beide Quellen synchronisieren unabhängig nach ihrem eigenen Zeitplan.", + "merge_into_select": "Erfassung zum Zusammenführen auswählen", + "advanced_options": "Erweiterte Optionen", + "preserve_original_file": "Originaldatei beibehalten", + "preserve_original_file_tooltip": "Wenn aktiviert: Speichert die exakte, unveränderte E-Mail-Datei wie vom Server empfangen. Keine Anhänge werden entfernt. Erforderlich für GoBD (Deutschland) und SEC 17a-4 Konformität.

Wenn deaktiviert: Entfernt Nicht-Inline-Anhänge und speichert sie separat mit Deduplizierung, um Speicherplatz zu sparen." + }, + "role_form": { + "policies_json": "Richtlinien (JSON)", + "invalid_json": "Ungültiges JSON-Format für Richtlinien." + }, + "theme_switcher": { + "toggle_theme": "Thema umschalten" + }, + "user_form": { + "select_role": "Wählen Sie eine Rolle aus" + } + }, "setup": { "title": "Einrichtung", "description": "Richten Sie das anfängliche Administratorkonto für Open Archiver ein.", @@ -175,61 +290,52 @@ "system": "System", "users": "Benutzer", "roles": "Rollen", - "logout": "Abmelden" + "api_keys": "API-Schlüssel", + "account": "Konto", + "logout": "Abmelden", + "admin": "Admin" }, - "components": { - "charts": { - "emails_ingested": "E-Mails aufgenommen", - "storage_used": "Speicher verwendet", - "emails": "E-Mails" - }, - "common": { - "submitting": "Übermittlung...", - "submit": "Übermitteln", - "save": "Speichern" - }, - "email_preview": { - "loading": "E-Mail-Vorschau wird geladen...", - "render_error": "E-Mail-Vorschau konnte nicht gerendert werden.", - "not_available": "Rohe .eml-Datei für diese E-Mail nicht verfügbar." - }, - "footer": { - "all_rights_reserved": "Alle Rechte vorbehalten." - }, - "ingestion_source_form": { - "provider_generic_imap": "Generisches IMAP", - "provider_google_workspace": "Google Workspace", - "provider_microsoft_365": "Microsoft 365", - "provider_pst_import": "PST-Import", - "provider_eml_import": "EML-Import", - "select_provider": "Wählen Sie einen Anbieter", - "service_account_key": "Dienstkontoschlüssel (JSON)", - "service_account_key_placeholder": "Fügen Sie den JSON-Inhalt Ihres Dienstkontoschlüssels ein", - "impersonated_admin_email": "Impersonierte Admin-E-Mail", - "client_id": "Anwendungs-(Client-)ID", - "client_secret": "Client-Geheimniswert", - "client_secret_placeholder": "Geben Sie den Geheimniswert ein, nicht die Geheimnis-ID", - "tenant_id": "Verzeichnis-(Mandanten-)ID", - "host": "Host", - "port": "Port", - "username": "Benutzername", - "use_tls": "TLS verwenden", - "pst_file": "PST-Datei", - "eml_file": "EML-Datei", - "heads_up": "Achtung!", - "org_wide_warning": "Bitte beachten Sie, dass dies ein organisationsweiter Vorgang ist. Diese Art von Erfassungen importiert und indiziert alle E-Mail-Postfächer in Ihrer Organisation. Wenn Sie nur bestimmte E-Mail-Postfächer importieren möchten, verwenden Sie den IMAP-Connector.", - "upload_failed": "Hochladen fehlgeschlagen, bitte versuchen Sie es erneut" - }, - "role_form": { - "policies_json": "Richtlinien (JSON)", - "invalid_json": "Ungültiges JSON-Format für Richtlinien." - }, - "theme_switcher": { - "toggle_theme": "Thema umschalten" - }, - "user_form": { - "select_role": "Wählen Sie eine Rolle aus" - } + "api_keys_page": { + "title": "API-Schlüssel", + "header": "API-Schlüssel", + "generate_new_key": "Neuen Schlüssel erstellen", + "name": "Name", + "key": "Schlüssel", + "expires_at": "Läuft ab am", + "created_at": "Erstellt am", + "actions": "Aktionen", + "delete": "Löschen", + "no_keys_found": "Keine API-Schlüssel gefunden.", + "generate_modal_title": "Neuen API-Schlüssel erstellen", + "generate_modal_description": "Bitte geben Sie einen Namen und eine Gültigkeitsdauer für Ihren neuen API-Schlüssel an.", + "expires_in": "Gültig für", + "select_expiration": "Gültigkeitsdauer auswählen", + "30_days": "30 Tage", + "60_days": "60 Tage", + "6_months": "6 Monate", + "12_months": "12 Monate", + "24_months": "24 Monate", + "generate": "Erstellen", + "new_api_key": "Neuer API-Schlüssel", + "failed_to_delete": "API-Schlüssel konnte nicht gelöscht werden", + "api_key_deleted": "API-Schlüssel gelöscht", + "generated_title": "API-Schlüssel erstellt", + "generated_message": "Ihr API-Schlüssel wurde erstellt. Bitte kopieren und speichern Sie ihn an einem sicheren Ort. Dieser Schlüssel wird nur einmal angezeigt." + }, + "archived_emails_page": { + "title": "Archivierte E-Mails", + "header": "Archivierte E-Mails", + "select_ingestion_source": "Wählen Sie eine Erfassungsquelle aus", + "date": "Datum", + "subject": "Betreff", + "sender": "Absender", + "inbox": "Posteingang", + "path": "Pfad", + "actions": "Aktionen", + "view": "Ansehen", + "no_emails_found": "Keine archivierten E-Mails gefunden.", + "prev": "Zurück", + "next": "Weiter" }, "dashboard_page": { "title": "Dashboard", @@ -249,20 +355,229 @@ "top_10_senders": "Top 10 Absender", "no_indexed_insights": "Keine indizierten Einblicke verfügbar." }, - "archived_emails_page": { - "title": "Archivierte E-Mails", - "header": "Archivierte E-Mails", - "select_ingestion_source": "Wählen Sie eine Erfassungsquelle aus", - "date": "Datum", - "subject": "Betreff", - "sender": "Absender", - "inbox": "Posteingang", - "path": "Pfad", + "retention_policies": { + "title": "Aufbewahrungsrichtlinien", + "header": "Aufbewahrungsrichtlinien", + "meta_description": "Verwalten Sie Aufbewahrungsrichtlinien zur Automatisierung des E-Mail-Lebenszyklus und der Compliance.", + "create_new": "Neue Richtlinie erstellen", + "no_policies_found": "Keine Aufbewahrungsrichtlinien gefunden.", + "name": "Name", + "description": "Beschreibung", + "priority": "Priorität", + "retention_period": "Aufbewahrungsfrist", + "retention_period_days": "Aufbewahrungsfrist (Tage)", + "action_on_expiry": "Aktion bei Ablauf", + "delete_permanently": "Endgültig löschen", + "status": "Status", + "active": "Aktiv", + "inactive": "Inaktiv", + "conditions": "Bedingungen", + "conditions_description": "Definieren Sie Regeln zum Abgleich von E-Mails. Wenn keine Bedingungen festgelegt sind, gilt die Richtlinie für alle E-Mails.", + "logical_operator": "Logischer Operator", + "and": "UND", + "or": "ODER", + "add_rule": "Regel hinzufügen", + "remove_rule": "Regel entfernen", + "field": "Feld", + "field_sender": "Absender", + "field_recipient": "Empfänger", + "field_subject": "Betreff", + "field_attachment_type": "Anhangstyp", + "operator": "Operator", + "operator_equals": "Gleich", + "operator_not_equals": "Ungleich", + "operator_contains": "Enthält", + "operator_not_contains": "Enthält nicht", + "operator_starts_with": "Beginnt mit", + "operator_ends_with": "Endet mit", + "operator_domain_match": "Domain-Abgleich", + "operator_regex_match": "Regex-Abgleich", + "value": "Wert", + "value_placeholder": "z. B. benutzer@beispiel.com", + "edit": "Bearbeiten", + "delete": "Löschen", + "create": "Erstellen", + "save": "Änderungen speichern", + "cancel": "Abbrechen", + "create_description": "Erstellen Sie eine neue Aufbewahrungsrichtlinie zur Verwaltung des Lebenszyklus archivierter E-Mails.", + "edit_description": "Aktualisieren Sie die Einstellungen dieser Aufbewahrungsrichtlinie.", + "delete_confirmation_title": "Diese Aufbewahrungsrichtlinie löschen?", + "delete_confirmation_description": "Diese Aktion kann nicht rückgängig gemacht werden. E-Mails, die von dieser Richtlinie betroffen sind, unterliegen nicht mehr der automatischen Löschung.", + "deleting": "Löschen", + "confirm": "Bestätigen", + "days": "Tage", + "no_conditions": "Alle E-Mails (kein Filter)", + "rules": "Regeln", + "simulator_title": "Richtlinien-Simulator", + "simulator_description": "Testen Sie die Metadaten einer E-Mail gegen alle aktiven Richtlinien, um zu sehen, welche Aufbewahrungsfrist gelten würde.", + "simulator_sender": "Absender-E-Mail", + "simulator_sender_placeholder": "z. B. max@finanzen.firma.de", + "simulator_recipients": "Empfänger", + "simulator_recipients_placeholder": "Kommagetrennt, z. B. anna@firma.de, peter@firma.de", + "simulator_subject": "Betreff", + "simulator_subject_placeholder": "z. B. Q4-Steuerbericht", + "simulator_attachment_types": "Anhangstypen", + "simulator_attachment_types_placeholder": "Kommagetrennt, z. B. .pdf, .xlsx", + "simulator_run": "Simulation starten", + "simulator_running": "Wird ausgeführt...", + "simulator_result_title": "Simulationsergebnis", + "simulator_no_match": "Keine aktive Richtlinie hat auf diese E-Mail zugetroffen. Sie unterliegt keiner automatischen Löschung.", + "simulator_matched": "Zugetroffen – Aufbewahrungsfrist von {{days}} Tagen gilt.", + "simulator_matching_policies": "Zutreffende Richtlinien-IDs", + "simulator_no_result": "Starten Sie eine Simulation, um zu sehen, welche Richtlinien auf eine bestimmte E-Mail zutreffen.", + "simulator_ingestion_source": "Für Erfassungsquelle simulieren", + "simulator_ingestion_source_description": "Wählen Sie eine Erfassungsquelle aus, um eingeschränkte Richtlinien zu testen. Lassen Sie das Feld leer, um alle Richtlinien unabhängig vom Geltungsbereich zu prüfen.", + "simulator_ingestion_all": "Alle Quellen (Geltungsbereich ignorieren)", + "ingestion_scope": "Erfassungsbereich", + "ingestion_scope_description": "Beschränken Sie diese Richtlinie auf bestimmte Erfassungsquellen. Lassen Sie alle deaktiviert, um sie auf alle Quellen anzuwenden.", + "ingestion_scope_all": "Alle Erfassungsquellen", + "ingestion_scope_selected": "{{count}} Quelle(n) ausgewählt – diese Richtlinie gilt nur für E-Mails aus diesen Quellen.", + "create_success": "Aufbewahrungsrichtlinie erfolgreich erstellt.", + "update_success": "Aufbewahrungsrichtlinie erfolgreich aktualisiert.", + "delete_success": "Aufbewahrungsrichtlinie erfolgreich gelöscht.", + "delete_error": "Aufbewahrungsrichtlinie konnte nicht gelöscht werden." + }, + "retention_labels": { + "title": "Aufbewahrungslabels", + "header": "Aufbewahrungslabels", + "meta_description": "Verwalten Sie Aufbewahrungslabels für individuelle Compliance-Überschreibungen bei einzelnen archivierten E-Mails.", + "create_new": "Label erstellen", + "no_labels_found": "Keine Aufbewahrungslabels gefunden.", + "name": "Name", + "description": "Beschreibung", + "retention_period": "Aufbewahrungsfrist", + "retention_period_days": "Aufbewahrungsfrist (Tage)", + "applied_count": "Zugewiesene E-Mails", + "status": "Status", + "enabled": "Aktiviert", + "disabled": "Deaktiviert", + "created_at": "Erstellt am", "actions": "Aktionen", - "view": "Ansehen", - "no_emails_found": "Keine archivierten E-Mails gefunden.", - "prev": "Zurück", - "next": "Weiter" + "create": "Erstellen", + "edit": "Bearbeiten", + "delete": "Löschen", + "disable": "Deaktivieren", + "save": "Änderungen speichern", + "cancel": "Abbrechen", + "days": "Tage", + "create_description": "Erstellen Sie ein neues Aufbewahrungslabel. Sobald es E-Mails zugewiesen wurde, kann die Aufbewahrungsfrist des Labels nicht mehr geändert werden.", + "edit_description": "Aktualisieren Sie die Details dieses Aufbewahrungslabels.", + "delete_confirmation_title": "Dieses Aufbewahrungslabel löschen?", + "delete_confirmation_description": "Diese Aktion entfernt das Label endgültig. Es kann nicht mehr neuen E-Mails zugewiesen werden.", + "disable_confirmation_title": "Dieses Aufbewahrungslabel deaktivieren?", + "disable_confirmation_description": "Dieses Label ist derzeit archivierten E-Mails zugewiesen und kann nicht gelöscht werden. Es wird deaktiviert, sodass es nicht mehr neuen E-Mails zugewiesen werden kann. Bestehende markierte E-Mails behalten dieses Label, obwohl es nicht mehr wirksam ist.", + "force_delete_confirmation_title": "Dieses deaktivierte Label endgültig löschen?", + "force_delete_confirmation_description": "Dieses Label ist deaktiviert, hat aber noch E-Mail-Zuordnungen. Durch das Löschen werden alle Zuordnungen entfernt und das Label endgültig gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.", + "deleting": "Wird verarbeitet", + "confirm": "Bestätigen", + "create_success": "Aufbewahrungslabel erfolgreich erstellt.", + "update_success": "Aufbewahrungslabel erfolgreich aktualisiert.", + "delete_success": "Aufbewahrungslabel erfolgreich gelöscht.", + "disable_success": "Aufbewahrungslabel erfolgreich deaktiviert.", + "delete_error": "Aufbewahrungslabel konnte nicht gelöscht werden.", + "create_error": "Aufbewahrungslabel konnte nicht erstellt werden.", + "update_error": "Aufbewahrungslabel konnte nicht aktualisiert werden.", + "retention_period_locked": "Die Aufbewahrungsfrist kann nicht geändert werden, solange das Label E-Mails zugewiesen ist.", + "name_placeholder": "z. B. Steuerunterlagen – 10 Jahre", + "description_placeholder": "z. B. Wird steuerrelevanten Dokumenten zugewiesen, die eine verlängerte Aufbewahrung erfordern." + }, + "archive_labels": { + "section_title": "Aufbewahrungslabel", + "section_description": "Überschreiben Sie den Aufbewahrungszeitplan dieser E-Mail mit einem bestimmten Label.", + "current_label": "Aktuelles Label", + "no_label": "Kein Label zugewiesen", + "select_label": "Label auswählen", + "select_label_placeholder": "Aufbewahrungslabel auswählen...", + "apply": "Label zuweisen", + "applying": "Wird zugewiesen...", + "remove": "Label entfernen", + "removing": "Wird entfernt...", + "apply_success": "Aufbewahrungslabel erfolgreich zugewiesen.", + "remove_success": "Aufbewahrungslabel erfolgreich entfernt.", + "apply_error": "Aufbewahrungslabel konnte nicht zugewiesen werden.", + "remove_error": "Aufbewahrungslabel konnte nicht entfernt werden.", + "label_overrides_policy": "Dieses Label überschreibt die allgemeinen Aufbewahrungsrichtlinien für diese E-Mail.", + "no_labels_available": "Keine Aufbewahrungslabels verfügbar. Erstellen Sie Labels in den Compliance-Einstellungen.", + "label_inactive": "Inaktiv", + "label_inactive_note": "Dieses Label wurde deaktiviert. Es bietet keine Aufbewahrungsüberschreibung oder ein geplantes Löschdatum mehr für diese E-Mail. Sie können es entfernen und bei Bedarf ein aktives Label zuweisen." + }, + "legal_holds": { + "title": "Aufbewahrungspflichten", + "header": "Aufbewahrungspflichten", + "meta_description": "Verwalten Sie Aufbewahrungspflichten, um E-Mails vor automatischer Löschung während Rechtsstreitigkeiten oder behördlicher Untersuchungen zu schützen.", + "header_description": "Aufbewahrungspflichten setzen die automatische Löschung bestimmter Datensätze aus, die für Rechtsstreitigkeiten oder behördliche Untersuchungen relevant sind.", + "create_new": "Aufbewahrungspflicht erstellen", + "no_holds_found": "Keine Aufbewahrungspflichten gefunden.", + "name": "Name", + "reason": "Begründung / Beschreibung", + "no_reason": "Keine Begründung angegeben", + "email_count": "Geschützte E-Mails", + "status": "Status", + "active": "Aktiv", + "inactive": "Inaktiv", + "created_at": "Erstellt am", + "actions": "Aktionen", + "create": "Erstellen", + "edit": "Bearbeiten", + "delete": "Löschen", + "activate": "Aktivieren", + "deactivate": "Deaktivieren", + "bulk_apply": "Massenanwendung über Suche", + "release_all": "Alle E-Mails freigeben", + "save": "Änderungen speichern", + "cancel": "Abbrechen", + "confirm": "Löschen bestätigen", + "name_placeholder": "z. B. Rechtsstreit Projekt Titan – 2026", + "reason_placeholder": "z. B. Anhängiger Rechtsstreit bezüglich Projekt Titan. Alle Kommunikationen müssen aufbewahrt werden.", + "create_description": "Erstellen Sie eine neue Aufbewahrungspflicht, um die automatische Löschung relevanter E-Mails zu verhindern.", + "edit_description": "Aktualisieren Sie den Namen oder die Beschreibung dieser Aufbewahrungspflicht.", + "delete_confirmation_title": "Diese Aufbewahrungspflicht endgültig löschen?", + "delete_confirmation_description": "Dadurch wird die Aufbewahrungspflicht endgültig gelöscht und alle E-Mail-Zuordnungen entfernt. Zuvor geschützte E-Mails unterliegen bei der nächsten Ausführung des Lebenszyklus-Workers den normalen Aufbewahrungsregeln.", + "bulk_apply_title": "Aufbewahrungspflicht über Suche massenanwenden", + "bulk_apply_description": "Suchen Sie nach E-Mails mithilfe von Volltext- und Metadatenfiltern. Alle übereinstimmenden E-Mails werden unter diese Aufbewahrungspflicht gestellt. Die exakte Suchanfrage wird als Nachweis des Umfangs im Audit-Protokoll gespeichert.", + "bulk_query": "Suchbegriffe", + "bulk_query_placeholder": "z. B. Projekt Titan vertraulich", + "bulk_query_hint": "Durchsucht E-Mail-Text, Betreff und Anhangsinhalte über den Volltextindex.", + "bulk_from": "Von (Absender-E-Mail)", + "bulk_date_start": "Datum von", + "bulk_date_end": "Datum bis", + "bulk_apply_warning": "Diese Aktion wird auf ALLE E-Mails angewendet, die Ihrer Suche im gesamten Archiv entsprechen. Die Suchanfrage wird dauerhaft im Audit-Protokoll gespeichert.", + "bulk_apply_confirm": "Aufbewahrungspflicht auf übereinstimmende E-Mails anwenden", + "release_all_title": "Alle E-Mails aus dieser Aufbewahrungspflicht freigeben?", + "release_all_description": "Alle E-Mails verlieren ihren Schutz durch die Aufbewahrungspflicht. Sie werden bei der nächsten Ausführung des Lebenszyklus-Workers gegen die standardmäßigen Aufbewahrungsrichtlinien geprüft und können endgültig gelöscht werden.", + "release_all_confirm": "Alle E-Mails freigeben", + "create_success": "Aufbewahrungspflicht erfolgreich erstellt.", + "update_success": "Aufbewahrungspflicht erfolgreich aktualisiert.", + "delete_success": "Aufbewahrungspflicht erfolgreich gelöscht.", + "activated_success": "Aufbewahrungspflicht aktiviert. Geschützte E-Mails sind nun vor Löschung geschützt.", + "deactivated_success": "Aufbewahrungspflicht deaktiviert. E-Mails sind nicht mehr durch diese Aufbewahrungspflicht geschützt.", + "bulk_apply_success": "Aufbewahrungspflicht erfolgreich angewendet.", + "release_all_success": "Alle E-Mails aus der Aufbewahrungspflicht freigegeben.", + "create_error": "Aufbewahrungspflicht konnte nicht erstellt werden.", + "update_error": "Aufbewahrungspflicht konnte nicht aktualisiert werden.", + "delete_error": "Aufbewahrungspflicht konnte nicht gelöscht werden.", + "bulk_apply_error": "Massenanwendung fehlgeschlagen.", + "release_all_error": "E-Mails konnten nicht aus der Aufbewahrungspflicht freigegeben werden." + }, + "archive_legal_holds": { + "section_title": "Aufbewahrungspflichten", + "section_description": "Setzen Sie die automatische Löschung dieser E-Mail aus, indem Sie sie unter eine Aufbewahrungspflicht stellen.", + "no_holds": "Keine Aufbewahrungspflichten für diese E-Mail angewendet.", + "hold_name": "Name der Aufbewahrungspflicht", + "hold_status": "Status", + "applied_at": "Angewendet am", + "apply_hold": "Aufbewahrungspflicht anwenden", + "apply_hold_placeholder": "Aufbewahrungspflicht auswählen...", + "apply": "Aufbewahrungspflicht anwenden", + "applying": "Wird angewendet...", + "remove": "Entfernen", + "removing": "Wird entfernt...", + "apply_success": "Aufbewahrungspflicht auf diese E-Mail angewendet.", + "remove_success": "Aufbewahrungspflicht von dieser E-Mail entfernt.", + "apply_error": "Aufbewahrungspflicht konnte nicht angewendet werden.", + "remove_error": "Aufbewahrungspflicht konnte nicht entfernt werden.", + "immune_notice": "Diese E-Mail ist durch eine aktive Aufbewahrungspflicht geschützt und kann nicht gelöscht werden.", + "no_active_holds": "Keine aktiven Aufbewahrungspflichten verfügbar. Erstellen Sie Aufbewahrungspflichten in den Compliance-Einstellungen." }, "audit_log": { "title": "Audit-Protokoll", @@ -280,6 +595,12 @@ "no_logs_found": "Keine Audit-Protokolle gefunden.", "prev": "Zurück", "next": "Weiter", + "log_entry_details": "Protokolleintrag-Details", + "viewing_details_for": "Vollständige Details für Protokolleintrag #", + "actor_id": "Akteur-ID", + "previous_hash": "Vorheriger Hash", + "current_hash": "Aktueller Hash", + "close": "Schließen", "verification_successful_title": "Überprüfung erfolgreich", "verification_successful_message": "Integrität des Audit-Protokolls erfolgreich überprüft.", "verification_failed_title": "Überprüfung fehlgeschlagen", @@ -301,6 +622,7 @@ "id": "ID", "name": "Name", "state": "Status", + "created_at": "Erstellt am", "processed_at": "Verarbeitet am", "finished_at": "Beendet am", @@ -310,23 +632,83 @@ "next": "Weiter", "ingestion_source": "Ingestion-Quelle" }, + "journaling": { + "title": "SMTP-Journaling", + "header": "SMTP-Journaling-Quellen", + "meta_description": "Konfigurieren Sie Echtzeit-SMTP-Journaling-Endpunkte für lückenlose E-Mail-Archivierung von Unternehmens-MTAs.", + "header_description": "Empfangen Sie eine Echtzeitkopie jeder E-Mail direkt von Ihrem Mailserver über SMTP-Journaling und gewährleisten Sie so keinerlei Datenverlust.", + "create_new": "Quelle erstellen", + "no_sources_found": "Keine Journaling-Quellen konfiguriert.", + "name": "Name", + "allowed_ips": "Erlaubte IPs / CIDR", + "require_tls": "TLS erforderlich", + "smtp_username": "SMTP-Benutzername", + "smtp_password": "SMTP-Passwort", + "status": "Status", + "active": "Aktiv", + "paused": "Pausiert", + "total_received": "Empfangene E-Mails", + "last_received_at": "Zuletzt empfangen", + "created_at": "Erstellt am", + "actions": "Aktionen", + "create": "Erstellen", + "edit": "Bearbeiten", + "delete": "Löschen", + "pause": "Pausieren", + "activate": "Aktivieren", + "save": "Änderungen speichern", + "cancel": "Abbrechen", + "confirm": "Löschen bestätigen", + "name_placeholder": "z. B. MS365-Journal-Empfänger", + "allowed_ips_placeholder": "z. B. 40.107.0.0/16, 52.100.0.0/14", + "allowed_ips_hint": "Kommagetrennte IP-Adressen oder CIDR-Blöcke Ihrer Mailserver, die Journalberichte senden dürfen.", + "smtp_username_placeholder": "z. B. journal-tenant-123", + "smtp_password_placeholder": "Geben Sie ein starkes Passwort für die SMTP-Authentifizierung ein", + "smtp_auth_hint": "Optional. Falls konfiguriert, muss sich der MTA beim Verbinden mit diesen Anmeldedaten authentifizieren.", + "create_description": "Konfigurieren Sie einen neuen SMTP-Journaling-Endpunkt. Ihr MTA sendet Journalberichte an diesen Endpunkt zur Echtzeitarchivierung.", + "edit_description": "Aktualisieren Sie die Konfiguration dieser Journaling-Quelle.", + "delete_confirmation_title": "Diese Journaling-Quelle löschen?", + "delete_confirmation_description": "Dadurch werden der Journaling-Endpunkt und alle zugehörigen archivierten E-Mails endgültig gelöscht. Ihr MTA kann keine Journalberichte mehr an diesen Endpunkt senden.", + "deleting": "Löschen", + "smtp_connection_info": "SMTP-Verbindungsinformationen", + "smtp_host": "Host", + "smtp_port": "Port", + "routing_address": "Routing-Adresse", + "routing_address_hint": "Konfigurieren Sie diese Adresse als Journal-Empfänger in Ihrem MTA (Exchange, MS365, Postfix).", + "regenerate_address": "Adresse neu generieren", + "regenerate_address_warning": "Dadurch wird die aktuelle Adresse ungültig. Sie müssen Ihre MTA-Konfiguration mit der neuen Adresse aktualisieren.", + "regenerate_address_confirm": "Möchten Sie die Routing-Adresse wirklich neu generieren? Die aktuelle Adresse funktioniert sofort nicht mehr und Sie müssen Ihre MTA-Konfiguration aktualisieren.", + "regenerate_address_success": "Routing-Adresse erfolgreich neu generiert. Aktualisieren Sie Ihre MTA-Konfiguration mit der neuen Adresse.", + "regenerate_address_error": "Routing-Adresse konnte nicht neu generiert werden.", + "create_success": "Journaling-Quelle erfolgreich erstellt.", + "update_success": "Journaling-Quelle erfolgreich aktualisiert.", + "delete_success": "Journaling-Quelle erfolgreich gelöscht.", + "create_error": "Journaling-Quelle konnte nicht erstellt werden.", + "update_error": "Journaling-Quelle konnte nicht aktualisiert werden.", + "delete_error": "Journaling-Quelle konnte nicht gelöscht werden.", + "health_listening": "SMTP-Listener: Aktiv", + "health_down": "SMTP-Listener: Nicht erreichbar", + "never": "Nie" + }, "license_page": { "title": "Enterprise-Lizenzstatus", "meta_description": "Zeigen Sie den aktuellen Status Ihrer Open Archiver Enterprise-Lizenz an.", "revoked_title": "Lizenz widerrufen", "revoked_message": "Ihre Lizenz wurde vom Lizenzadministrator widerrufen. Enterprise-Funktionen werden deaktiviert {{grace_period}}. Bitte kontaktieren Sie Ihren Account Manager für Unterstützung.", - "revoked_grace_period": "am {{date}}", - "revoked_immediately": "sofort", + "notice_title": "Hinweis", "seat_limit_exceeded_title": "Sitzplatzlimit überschritten", "seat_limit_exceeded_message": "Ihre Lizenz gilt für {{planSeats}} Benutzer, aber Sie verwenden derzeit {{activeSeats}}. Bitte kontaktieren Sie den Vertrieb, um Ihr Abonnement anzupassen.", + "seat_limit_grace_deadline": "Enterprise-Funktionen werden am {{date}} deaktiviert, sofern die Anzahl der Plätze nicht reduziert wird.", "customer": "Kunde", "license_details": "Lizenzdetails", "license_status": "Lizenzstatus", "active": "Aktiv", "expired": "Abgelaufen", "revoked": "Widerrufen", + "overage": "Sitzplatzüberschreitung", "unknown": "Unbekannt", "expires": "Läuft ab", + "last_checked": "Zuletzt überprüft", "seat_usage": "Sitzplatznutzung", "seats_used": "{{activeSeats}} von {{planSeats}} Plätzen belegt", "enabled_features": "Aktivierte Funktionen", @@ -336,7 +718,12 @@ "enabled": "Aktiviert", "disabled": "Deaktiviert", "could_not_load_title": "Lizenz konnte nicht geladen werden", - "could_not_load_message": "Ein unerwarteter Fehler ist aufgetreten." + "could_not_load_message": "Ein unerwarteter Fehler ist aufgetreten.", + "revalidate": "Lizenz erneut validieren", + "revalidating": "Wird validiert...", + "revalidate_success": "Lizenz erfolgreich erneut validiert.", + "revoked_grace_period": "am {{date}}", + "revoked_immediately": "sofort" } } } diff --git a/packages/frontend/src/lib/translations/en.json b/packages/frontend/src/lib/translations/en.json index 6d2b202..053f2b2 100644 --- a/packages/frontend/src/lib/translations/en.json +++ b/packages/frontend/src/lib/translations/en.json @@ -87,7 +87,19 @@ "confirm": "Confirm", "cancel": "Cancel", "bulk_delete_confirmation_title": "Are you sure you want to delete {{count}} selected ingestions?", - "bulk_delete_confirmation_description": "This will delete all archived emails, attachments, indexing, and files associated with these ingestions. If you only want to stop syncing new emails, you can pause the ingestions instead." + "bulk_delete_confirmation_description": "This will delete all archived emails, attachments, indexing, and files associated with these ingestions. If you only want to stop syncing new emails, you can pause the ingestions instead.", + "merged_sources": "merged sources", + "unmerge": "Unmerge", + "unmerge_success": "Source has been detached from its group.", + "unmerge_confirmation_title": "Unmerge this source?", + "unmerge_confirmation_description": "This will detach the child source from the group and make it a standalone ingestion. Be aware of the following:", + "unmerge_warning_emails": "Emails already ingested by this source are stored under the root source. They will remain with the root and will not be moved.", + "unmerge_warning_future": "Only new emails ingested after unmerging will be stored under this source.", + "unmerge_confirm": "Unmerge", + "unmerging": "Unmerging", + "delete_root_warning": "This ingestion has {{count}} merged source(s). Deleting it will also delete all merged sources and their data.", + "expand": "Expand", + "collapse": "Collapse" }, "search": { "title": "Search", @@ -238,6 +250,11 @@ "org_wide_warning": "Please note that this is an organization-wide operation. This kind of ingestions will import and index all email inboxes in your organization. If you want to import only specific email inboxes, use the IMAP connector.", "upload_failed": "Upload Failed, please try again", "upload_network_error": "The server could not process the upload. The file may exceed the configured upload size limit (BODY_SIZE_LIMIT). For very large files, use the Local Path option instead.", + "merge_into": "Merge into existing ingestion", + "merge_into_description": "Emails from this source will be grouped with the selected ingestion. Both sources sync independently but emails appear together.", + "merge_into_tooltip": "When merging, this new source becomes a child of the selected root ingestion. All emails fetched by this source will be physically stored under the root ingestion — not this one.

The root ingestion's Preserve Original File (GoBD compliance) setting governs the entire group. The setting on this form is ignored if merging is enabled.

Both sources sync independently on their own schedule.", + "merge_into_select": "Select ingestion to merge into", + "advanced_options": "Advanced Options", "preserve_original_file": "Preserve Original File", "preserve_original_file_tooltip": "When checked: Stores the exact, unmodified email file as received from the server. No attachments are stripped. Required for GoBD (Germany) and SEC 17a-4 compliance.

When unchecked: Strips non-inline attachments and stores them separately with deduplication, saving storage space." }, @@ -704,7 +721,9 @@ "could_not_load_message": "An unexpected error occurred.", "revalidate": "Revalidate License", "revalidating": "Revalidating...", - "revalidate_success": "License revalidated successfully." + "revalidate_success": "License revalidated successfully.", + "revoked_grace_period": "on {{date}}", + "revoked_immediately": "immediately" } } } diff --git a/packages/frontend/src/lib/translations/es.json b/packages/frontend/src/lib/translations/es.json index 586bb3f..e19fa5a 100644 --- a/packages/frontend/src/lib/translations/es.json +++ b/packages/frontend/src/lib/translations/es.json @@ -7,7 +7,8 @@ "password": "Contraseña" }, "common": { - "working": "Trabajando" + "working": "Trabajando", + "read_docs": "Leer documentación" }, "archive": { "title": "Archivo", @@ -32,7 +33,30 @@ "deleting": "Eliminando", "confirm": "Confirmar", "cancel": "Cancelar", - "not_found": "Correo electrónico no encontrado." + "not_found": "Correo electrónico no encontrado.", + "integrity_report": "Informe de integridad", + "download_integrity_report_pdf": "Descargar informe de integridad (PDF)", + "downloading_integrity_report": "Generando...", + "integrity_report_download_error": "No se pudo generar el informe de integridad.", + "email_eml": "Correo electrónico (.eml)", + "valid": "Válido", + "invalid": "No válido", + "integrity_check_failed_title": "Error en la verificación de integridad", + "integrity_check_failed_message": "No se pudo verificar la integridad del correo electrónico y sus archivos adjuntos.", + "integrity_report_description": "Este informe verifica que el contenido de sus correos electrónicos archivados no ha sido alterado.", + "retention_policy": "Política de retención", + "retention_policy_description": "Muestra qué política de retención rige este correo electrónico y cuándo está programada su eliminación.", + "retention_no_policy": "No se aplica ninguna política: este correo electrónico no se eliminará automáticamente.", + "retention_period": "Período de retención", + "retention_action": "Acción al vencer", + "retention_matching_policies": "Políticas aplicables", + "retention_delete_permanently": "Eliminación permanente", + "retention_scheduled_deletion": "Eliminación programada", + "retention_policy_overridden_by_label": "Esta política está anulada por la etiqueta de retención ", + "embedded_attachments": "Archivos adjuntos incrustados", + "embedded": "Incrustado", + "embedded_attachment_title": "Archivo adjunto incrustado", + "embedded_attachment_description": "Este archivo adjunto está incrustado en el archivo de correo electrónico original y no se puede descargar por separado. Para obtenerlo, descargue el archivo de correo electrónico completo (.eml)." }, "ingestions": { "title": "Fuentes de ingesta", @@ -63,7 +87,19 @@ "confirm": "Confirmar", "cancel": "Cancelar", "bulk_delete_confirmation_title": "¿Está seguro de que desea eliminar {{count}} ingestas seleccionadas?", - "bulk_delete_confirmation_description": "Esto eliminará todos los correos electrónicos archivados, archivos adjuntos, indexación y archivos asociados con estas ingestas. Si solo desea dejar de sincronizar nuevos correos electrónicos, puede pausar las ingestas en su lugar." + "bulk_delete_confirmation_description": "Esto eliminará todos los correos electrónicos archivados, archivos adjuntos, indexación y archivos asociados con estas ingestas. Si solo desea dejar de sincronizar nuevos correos electrónicos, puede pausar las ingestas en su lugar.", + "merged_sources": "fuentes combinadas", + "unmerge": "Separar", + "unmerge_success": "La fuente ha sido desvinculada de su grupo.", + "unmerge_confirmation_title": "¿Separar esta fuente?", + "unmerge_confirmation_description": "Esto desvinculará la fuente secundaria del grupo y la convertirá en una ingesta independiente. Tenga en cuenta lo siguiente:", + "unmerge_warning_emails": "Los correos electrónicos ya ingestados por esta fuente están almacenados bajo la fuente raíz. Permanecerán allí y no serán movidos.", + "unmerge_warning_future": "Solo los nuevos correos electrónicos ingestados después de la separación se almacenarán bajo esta fuente.", + "unmerge_confirm": "Separar", + "unmerging": "Separando", + "delete_root_warning": "Esta ingesta tiene {{count}} fuente(s) combinada(s). Eliminarla también eliminará todas las fuentes combinadas y sus datos.", + "expand": "Expandir", + "collapse": "Contraer" }, "search": { "title": "Buscar", @@ -110,6 +146,23 @@ "confirm": "Confirmar", "cancel": "Cancelar" }, + "account": { + "title": "Configuración de cuenta", + "description": "Administre su perfil y configuración de seguridad.", + "personal_info": "Información personal", + "personal_info_desc": "Actualice sus datos personales.", + "security": "Seguridad", + "security_desc": "Administre su contraseña y preferencias de seguridad.", + "edit_profile": "Editar perfil", + "change_password": "Cambiar contraseña", + "edit_profile_desc": "Realice cambios en su perfil aquí.", + "change_password_desc": "Cambie su contraseña. Deberá ingresar su contraseña actual.", + "current_password": "Contraseña actual", + "new_password": "Nueva contraseña", + "confirm_new_password": "Confirmar nueva contraseña", + "operation_successful": "Operación exitosa", + "passwords_do_not_match": "Las contraseñas no coinciden" + }, "system_settings": { "title": "Configuración del sistema", "system_settings": "Configuración del sistema", @@ -146,6 +199,76 @@ "confirm": "Confirmar", "cancel": "Cancelar" }, + "components": { + "charts": { + "emails_ingested": "Correos electrónicos ingestados", + "storage_used": "Almacenamiento utilizado", + "emails": "Correos electrónicos" + }, + "common": { + "submitting": "Enviando...", + "submit": "Enviar", + "save": "Guardar" + }, + "email_preview": { + "loading": "Cargando vista previa del correo electrónico...", + "render_error": "No se pudo renderizar la vista previa del correo electrónico.", + "not_available": "El archivo .eml sin procesar no está disponible para este correo electrónico." + }, + "footer": { + "all_rights_reserved": "Todos los derechos reservados.", + "new_version_available": "Nueva versión disponible" + }, + "ingestion_source_form": { + "provider_generic_imap": "IMAP genérico", + "provider_google_workspace": "Google Workspace", + "provider_microsoft_365": "Microsoft 365", + "provider_pst_import": "Importación de PST", + "provider_eml_import": "Importación de EML", + "provider_mbox_import": "Importación de Mbox", + "select_provider": "Seleccione un proveedor", + "import_method": "Método de importación", + "upload_file": "Cargar archivo", + "local_path": "Ruta local (recomendado para archivos grandes)", + "local_file_path": "Ruta de archivo local", + "service_account_key": "Clave de cuenta de servicio (JSON)", + "service_account_key_placeholder": "Pegue el contenido JSON de su clave de cuenta de servicio", + "impersonated_admin_email": "Correo electrónico de administrador suplantado", + "client_id": "ID de aplicación (cliente)", + "client_secret": "Valor secreto del cliente", + "client_secret_placeholder": "Ingrese el valor secreto, no el ID secreto", + "tenant_id": "ID de directorio (inquilino)", + "host": "Host", + "port": "Puerto", + "username": "Nombre de usuario", + "use_tls": "Usar TLS", + "allow_insecure_cert": "Permitir certificado no seguro", + "pst_file": "Archivo PST", + "eml_file": "Archivo EML", + "mbox_file": "Archivo Mbox", + "heads_up": "¡Atención!", + "org_wide_warning": "Tenga en cuenta que esta es una operación para toda la organización. Este tipo de ingestas importará e indexará todos los buzones de correo electrónico de su organización. Si desea importar solo buzones de correo electrónico específicos, utilice el conector IMAP.", + "upload_failed": "Error al cargar, por favor intente de nuevo", + "upload_network_error": "El servidor no pudo procesar la carga. El archivo puede superar el límite de tamaño configurado (BODY_SIZE_LIMIT). Para archivos muy grandes, utilice la opción de ruta local.", + "merge_into": "Combinar con ingesta existente", + "merge_into_description": "Los correos electrónicos de esta fuente se agruparán con la ingesta seleccionada. Ambas fuentes sincronizan de forma independiente pero los correos aparecen juntos.", + "merge_into_tooltip": "Al combinar, esta nueva fuente se convierte en secundaria de la ingesta raíz seleccionada. Todos los correos obtenidos por esta fuente se almacenarán físicamente bajo la ingesta raíz, no bajo esta.

La configuración Conservar archivo original (cumplimiento GoBD) de la ingesta raíz rige para todo el grupo. La configuración de este formulario se ignora si la combinación está habilitada.

Ambas fuentes sincronizan de forma independiente según su propio calendario.", + "merge_into_select": "Seleccionar ingesta para combinar", + "advanced_options": "Opciones avanzadas", + "preserve_original_file": "Conservar archivo original", + "preserve_original_file_tooltip": "Si está marcado: Almacena el archivo de correo electrónico exacto y sin modificar tal como se recibió del servidor. No se eliminan archivos adjuntos. Requerido para el cumplimiento de GoBD (Alemania) y SEC 17a-4.

Si no está marcado: Elimina los archivos adjuntos no en línea y los almacena por separado con deduplicación, ahorrando espacio de almacenamiento." + }, + "role_form": { + "policies_json": "Políticas (JSON)", + "invalid_json": "Formato JSON no válido para las políticas." + }, + "theme_switcher": { + "toggle_theme": "Cambiar tema" + }, + "user_form": { + "select_role": "Seleccione un rol" + } + }, "setup": { "title": "Configuración", "description": "Configure la cuenta de administrador inicial para Open Archiver.", @@ -167,61 +290,52 @@ "system": "Sistema", "users": "Usuarios", "roles": "Roles", - "logout": "Cerrar sesión" + "api_keys": "Claves de API", + "account": "Cuenta", + "logout": "Cerrar sesión", + "admin": "Admin" }, - "components": { - "charts": { - "emails_ingested": "Correos electrónicos ingeridos", - "storage_used": "Almacenamiento utilizado", - "emails": "Correos electrónicos" - }, - "common": { - "submitting": "Enviando...", - "submit": "Enviar", - "save": "Guardar" - }, - "email_preview": { - "loading": "Cargando vista previa del correo electrónico...", - "render_error": "No se pudo renderizar la vista previa del correo electrónico.", - "not_available": "El archivo .eml sin procesar no está disponible para este correo electrónico." - }, - "footer": { - "all_rights_reserved": "Todos los derechos reservados." - }, - "ingestion_source_form": { - "provider_generic_imap": "IMAP genérico", - "provider_google_workspace": "Google Workspace", - "provider_microsoft_365": "Microsoft 365", - "provider_pst_import": "Importación de PST", - "provider_eml_import": "Importación de EML", - "select_provider": "Seleccione un proveedor", - "service_account_key": "Clave de cuenta de servicio (JSON)", - "service_account_key_placeholder": "Pegue el contenido JSON de su clave de cuenta de servicio", - "impersonated_admin_email": "Correo electrónico de administrador suplantado", - "client_id": "ID de aplicación (cliente)", - "client_secret": "Valor secreto del cliente", - "client_secret_placeholder": "Ingrese el valor secreto, no el ID secreto", - "tenant_id": "ID de directorio (inquilino)", - "host": "Host", - "port": "Puerto", - "username": "Nombre de usuario", - "use_tls": "Usar TLS", - "pst_file": "Archivo PST", - "eml_file": "Archivo EML", - "heads_up": "¡Atención!", - "org_wide_warning": "Tenga en cuenta que esta es una operación para toda la organización. Este tipo de ingestas importará e indexará todos los buzones de correo electrónico de su organización. Si desea importar solo buzones de correo electrónico específicos, utilice el conector IMAP.", - "upload_failed": "Error al cargar, por favor intente de nuevo" - }, - "role_form": { - "policies_json": "Políticas (JSON)", - "invalid_json": "Formato JSON no válido para las políticas." - }, - "theme_switcher": { - "toggle_theme": "Cambiar tema" - }, - "user_form": { - "select_role": "Seleccione un rol" - } + "api_keys_page": { + "title": "Claves de API", + "header": "Claves de API", + "generate_new_key": "Generar nueva clave", + "name": "Nombre", + "key": "Clave", + "expires_at": "Vence el", + "created_at": "Creado el", + "actions": "Acciones", + "delete": "Eliminar", + "no_keys_found": "No se encontraron claves de API.", + "generate_modal_title": "Generar nueva clave de API", + "generate_modal_description": "Proporcione un nombre y una fecha de vencimiento para su nueva clave de API.", + "expires_in": "Vence en", + "select_expiration": "Seleccione una fecha de vencimiento", + "30_days": "30 días", + "60_days": "60 días", + "6_months": "6 meses", + "12_months": "12 meses", + "24_months": "24 meses", + "generate": "Generar", + "new_api_key": "Nueva clave de API", + "failed_to_delete": "No se pudo eliminar la clave de API", + "api_key_deleted": "Clave de API eliminada", + "generated_title": "Clave de API generada", + "generated_message": "Su clave de API ha sido generada. Cópiela y guárdela en un lugar seguro. Esta clave solo se mostrará una vez." + }, + "archived_emails_page": { + "title": "Correos electrónicos archivados", + "header": "Correos electrónicos archivados", + "select_ingestion_source": "Seleccione una fuente de ingesta", + "date": "Fecha", + "subject": "Asunto", + "sender": "Remitente", + "inbox": "Bandeja de entrada", + "path": "Ruta", + "actions": "Acciones", + "view": "Ver", + "no_emails_found": "No se encontraron correos electrónicos archivados.", + "prev": "Anterior", + "next": "Siguiente" }, "dashboard_page": { "title": "Tablero", @@ -241,20 +355,372 @@ "top_10_senders": "Los 10 principales remitentes", "no_indexed_insights": "No hay información indexada disponible." }, - "archived_emails_page": { - "title": "Correos electrónicos archivados", - "header": "Correos electrónicos archivados", - "select_ingestion_source": "Seleccione una fuente de ingesta", - "date": "Fecha", - "subject": "Asunto", - "sender": "Remitente", - "inbox": "Bandeja de entrada", - "path": "Ruta", + "retention_policies": { + "title": "Políticas de retención", + "header": "Políticas de retención", + "meta_description": "Gestione las políticas de retención de datos para automatizar el ciclo de vida del correo electrónico y el cumplimiento normativo.", + "create_new": "Crear nueva política", + "no_policies_found": "No se encontraron políticas de retención.", + "name": "Nombre", + "description": "Descripción", + "priority": "Prioridad", + "retention_period": "Período de retención", + "retention_period_days": "Período de retención (días)", + "action_on_expiry": "Acción al vencer", + "delete_permanently": "Eliminar permanentemente", + "status": "Estado", + "active": "Activo", + "inactive": "Inactivo", + "conditions": "Condiciones", + "conditions_description": "Defina reglas para hacer coincidir correos electrónicos. Si no se establecen condiciones, la política se aplica a todos los correos electrónicos.", + "logical_operator": "Operador lógico", + "and": "Y", + "or": "O", + "add_rule": "Agregar regla", + "remove_rule": "Eliminar regla", + "field": "Campo", + "field_sender": "Remitente", + "field_recipient": "Destinatario", + "field_subject": "Asunto", + "field_attachment_type": "Tipo de adjunto", + "operator": "Operador", + "operator_equals": "Igual a", + "operator_not_equals": "No igual a", + "operator_contains": "Contiene", + "operator_not_contains": "No contiene", + "operator_starts_with": "Comienza con", + "operator_ends_with": "Termina con", + "operator_domain_match": "Coincidencia de dominio", + "operator_regex_match": "Coincidencia de expresión regular", + "value": "Valor", + "value_placeholder": "p. ej. usuario@ejemplo.com", + "edit": "Editar", + "delete": "Eliminar", + "create": "Crear", + "save": "Guardar cambios", + "cancel": "Cancelar", + "create_description": "Cree una nueva política de retención para gestionar el ciclo de vida de los correos electrónicos archivados.", + "edit_description": "Actualice la configuración de esta política de retención.", + "delete_confirmation_title": "¿Eliminar esta política de retención?", + "delete_confirmation_description": "Esta acción no se puede deshacer. Los correos electrónicos coincidentes con esta política ya no estarán sujetos a eliminación automática.", + "deleting": "Eliminando", + "confirm": "Confirmar", + "days": "días", + "no_conditions": "Todos los correos electrónicos (sin filtro)", + "rules": "reglas", + "simulator_title": "Simulador de políticas", + "simulator_description": "Pruebe los metadatos de un correo electrónico contra todas las políticas activas para ver qué período de retención se aplicaría.", + "simulator_sender": "Correo del remitente", + "simulator_sender_placeholder": "p. ej. juan@finanzas.empresa.es", + "simulator_recipients": "Destinatarios", + "simulator_recipients_placeholder": "Separados por comas, p. ej. ana@empresa.es, pedro@empresa.es", + "simulator_subject": "Asunto", + "simulator_subject_placeholder": "p. ej. Informe fiscal T4", + "simulator_attachment_types": "Tipos de adjuntos", + "simulator_attachment_types_placeholder": "Separados por comas, p. ej. .pdf, .xlsx", + "simulator_run": "Ejecutar simulación", + "simulator_running": "Ejecutando...", + "simulator_result_title": "Resultado de la simulación", + "simulator_no_match": "Ninguna política activa coincidió con este correo electrónico. No estará sujeto a eliminación automática.", + "simulator_matched": "Coincidencia — se aplica un período de retención de {{days}} días.", + "simulator_matching_policies": "IDs de políticas coincidentes", + "simulator_no_result": "Ejecute una simulación para ver qué políticas se aplican a un correo electrónico determinado.", + "simulator_ingestion_source": "Simular para fuente de ingesta", + "simulator_ingestion_source_description": "Seleccione una fuente de ingesta para probar políticas con ámbito. Deje en blanco para evaluar contra todas las políticas independientemente del ámbito.", + "simulator_ingestion_all": "Todas las fuentes (ignorar ámbito)", + "ingestion_scope": "Ámbito de ingesta", + "ingestion_scope_description": "Restrinja esta política a fuentes de ingesta específicas. Deje todas sin marcar para aplicar a todas las fuentes.", + "ingestion_scope_all": "Todas las fuentes de ingesta", + "ingestion_scope_selected": "{{count}} fuente(s) seleccionada(s) — esta política solo se aplicará a correos electrónicos de esas fuentes.", + "create_success": "Política de retención creada correctamente.", + "update_success": "Política de retención actualizada correctamente.", + "delete_success": "Política de retención eliminada correctamente.", + "delete_error": "No se pudo eliminar la política de retención." + }, + "retention_labels": { + "title": "Etiquetas de retención", + "header": "Etiquetas de retención", + "meta_description": "Gestione las etiquetas de retención para anulaciones de cumplimiento a nivel de elemento en correos electrónicos archivados individuales.", + "create_new": "Crear etiqueta", + "no_labels_found": "No se encontraron etiquetas de retención.", + "name": "Nombre", + "description": "Descripción", + "retention_period": "Período de retención", + "retention_period_days": "Período de retención (días)", + "applied_count": "Correos aplicados", + "status": "Estado", + "enabled": "Habilitado", + "disabled": "Deshabilitado", + "created_at": "Creado el", "actions": "Acciones", - "view": "Ver", - "no_emails_found": "No se encontraron correos electrónicos archivados.", + "create": "Crear", + "edit": "Editar", + "delete": "Eliminar", + "disable": "Deshabilitar", + "save": "Guardar cambios", + "cancel": "Cancelar", + "days": "días", + "create_description": "Cree una nueva etiqueta de retención. Una vez aplicada a correos electrónicos, el período de retención de la etiqueta no puede modificarse.", + "edit_description": "Actualice los detalles de esta etiqueta de retención.", + "delete_confirmation_title": "¿Eliminar esta etiqueta de retención?", + "delete_confirmation_description": "Esta acción eliminará permanentemente la etiqueta. No podrá aplicarse a nuevos correos electrónicos.", + "disable_confirmation_title": "¿Deshabilitar esta etiqueta de retención?", + "disable_confirmation_description": "Esta etiqueta está actualmente aplicada a correos electrónicos archivados y no puede eliminarse. Se deshabilitará para que no pueda aplicarse a nuevos correos, pero los correos ya etiquetados conservarán esta etiqueta aunque no tendrá efecto.", + "force_delete_confirmation_title": "¿Eliminar permanentemente esta etiqueta deshabilitada?", + "force_delete_confirmation_description": "Esta etiqueta está deshabilitada pero aún tiene asociaciones de correo electrónico. Eliminarla borrará todas esas asociaciones y la etiqueta de forma permanente. Esta acción no se puede deshacer.", + "deleting": "Procesando", + "confirm": "Confirmar", + "create_success": "Etiqueta de retención creada correctamente.", + "update_success": "Etiqueta de retención actualizada correctamente.", + "delete_success": "Etiqueta de retención eliminada correctamente.", + "disable_success": "Etiqueta de retención deshabilitada correctamente.", + "delete_error": "No se pudo eliminar la etiqueta de retención.", + "create_error": "No se pudo crear la etiqueta de retención.", + "update_error": "No se pudo actualizar la etiqueta de retención.", + "retention_period_locked": "El período de retención no puede modificarse mientras la etiqueta esté aplicada a correos electrónicos.", + "name_placeholder": "p. ej. Registro fiscal - 10 años", + "description_placeholder": "p. ej. Aplicado a documentos fiscales que requieren retención extendida." + }, + "archive_labels": { + "section_title": "Etiqueta de retención", + "section_description": "Anule el calendario de retención de este correo electrónico con una etiqueta específica.", + "current_label": "Etiqueta actual", + "no_label": "Sin etiqueta aplicada", + "select_label": "Seleccionar etiqueta", + "select_label_placeholder": "Elegir etiqueta de retención...", + "apply": "Aplicar etiqueta", + "applying": "Aplicando...", + "remove": "Eliminar etiqueta", + "removing": "Eliminando...", + "apply_success": "Etiqueta de retención aplicada correctamente.", + "remove_success": "Etiqueta de retención eliminada correctamente.", + "apply_error": "No se pudo aplicar la etiqueta de retención.", + "remove_error": "No se pudo eliminar la etiqueta de retención.", + "label_overrides_policy": "Esta etiqueta anula las políticas de retención generales para este correo electrónico.", + "no_labels_available": "No hay etiquetas de retención disponibles. Cree etiquetas en la configuración de cumplimiento.", + "label_inactive": "Inactivo", + "label_inactive_note": "Esta etiqueta ha sido deshabilitada. Ya no proporciona una anulación de retención ni una fecha de eliminación programada para este correo electrónico. Puede eliminarla y aplicar una etiqueta activa si es necesario." + }, + "legal_holds": { + "title": "Retenciones legales", + "header": "Retenciones legales", + "meta_description": "Gestione las retenciones legales para preservar correos electrónicos de la eliminación automática durante litigios o investigaciones regulatorias.", + "header_description": "Las retenciones legales suspenden la eliminación automática de registros específicos relevantes para litigios o investigaciones regulatorias.", + "create_new": "Crear retención", + "no_holds_found": "No se encontraron retenciones legales.", + "name": "Nombre", + "reason": "Motivo / Descripción", + "no_reason": "Sin motivo proporcionado", + "email_count": "Correos protegidos", + "status": "Estado", + "active": "Activo", + "inactive": "Inactivo", + "created_at": "Creado el", + "actions": "Acciones", + "create": "Crear", + "edit": "Editar", + "delete": "Eliminar", + "activate": "Activar", + "deactivate": "Desactivar", + "bulk_apply": "Aplicación masiva mediante búsqueda", + "release_all": "Liberar todos los correos", + "save": "Guardar cambios", + "cancel": "Cancelar", + "confirm": "Confirmar eliminación", + "name_placeholder": "p. ej. Litigio Proyecto Titán - 2026", + "reason_placeholder": "p. ej. Litigio pendiente relacionado con el Proyecto Titán. Todas las comunicaciones deben conservarse.", + "create_description": "Cree una nueva retención legal para evitar la eliminación automática de correos electrónicos relevantes.", + "edit_description": "Actualice el nombre o la descripción de esta retención legal.", + "delete_confirmation_title": "¿Eliminar permanentemente esta retención legal?", + "delete_confirmation_description": "Esto eliminará permanentemente la retención y todas las asociaciones de correo electrónico. Los correos previamente protegidos estarán sujetos a las reglas de retención normales en la próxima ejecución del trabajador del ciclo de vida.", + "bulk_apply_title": "Aplicar retención legal masivamente mediante búsqueda", + "bulk_apply_description": "Busque correos electrónicos usando filtros de texto completo y metadatos. Todos los correos coincidentes se colocarán bajo esta retención legal. La consulta exacta se guarda en el registro de auditoría como prueba del alcance.", + "bulk_query": "Palabras clave de búsqueda", + "bulk_query_placeholder": "p. ej. Proyecto Titán confidencial", + "bulk_query_hint": "Busca en el cuerpo del correo, el asunto y el contenido de los adjuntos a través del índice de texto completo.", + "bulk_from": "De (correo del remitente)", + "bulk_date_start": "Fecha desde", + "bulk_date_end": "Fecha hasta", + "bulk_apply_warning": "Esta acción se aplicará a TODOS los correos electrónicos que coincidan con su búsqueda en todo el archivo. La consulta de búsqueda se registrará permanentemente en el registro de auditoría.", + "bulk_apply_confirm": "Aplicar retención a los correos coincidentes", + "release_all_title": "¿Liberar todos los correos de esta retención?", + "release_all_description": "Todos los correos electrónicos perderán su inmunidad de retención legal. Serán evaluados contra las políticas de retención estándar en la próxima ejecución del trabajador del ciclo de vida y pueden eliminarse permanentemente.", + "release_all_confirm": "Liberar todos los correos", + "create_success": "Retención legal creada correctamente.", + "update_success": "Retención legal actualizada correctamente.", + "delete_success": "Retención legal eliminada correctamente.", + "activated_success": "Retención legal activada. Los correos protegidos ahora son inmunes a la eliminación.", + "deactivated_success": "Retención legal desactivada. Los correos ya no están protegidos por esta retención.", + "bulk_apply_success": "Retención legal aplicada correctamente.", + "release_all_success": "Todos los correos liberados de la retención.", + "create_error": "No se pudo crear la retención legal.", + "update_error": "No se pudo actualizar la retención legal.", + "delete_error": "No se pudo eliminar la retención legal.", + "bulk_apply_error": "Error en la aplicación masiva.", + "release_all_error": "No se pudieron liberar los correos de la retención." + }, + "archive_legal_holds": { + "section_title": "Retenciones legales", + "section_description": "Suspenda la eliminación automática de este correo electrónico colocándolo bajo una retención legal.", + "no_holds": "No hay retenciones legales aplicadas a este correo electrónico.", + "hold_name": "Nombre de la retención", + "hold_status": "Estado", + "applied_at": "Aplicado el", + "apply_hold": "Aplicar retención", + "apply_hold_placeholder": "Seleccionar retención legal...", + "apply": "Aplicar retención", + "applying": "Aplicando...", + "remove": "Eliminar", + "removing": "Eliminando...", + "apply_success": "Retención legal aplicada a este correo electrónico.", + "remove_success": "Retención legal eliminada de este correo electrónico.", + "apply_error": "No se pudo aplicar la retención legal.", + "remove_error": "No se pudo eliminar la retención legal.", + "immune_notice": "Este correo electrónico está protegido por una retención legal activa y no puede eliminarse.", + "no_active_holds": "No hay retenciones legales activas disponibles. Cree retenciones en la configuración de cumplimiento." + }, + "audit_log": { + "title": "Registro de auditoría", + "header": "Registro de auditoría", + "verify_integrity": "Verificar integridad del registro", + "log_entries": "Entradas del registro", + "timestamp": "Marca de tiempo", + "actor": "Actor", + "action": "Acción", + "target": "Destino", + "details": "Detalles", + "ip_address": "Dirección IP", + "target_type": "Tipo de destino", + "target_id": "ID de destino", + "no_logs_found": "No se encontraron registros de auditoría.", "prev": "Anterior", - "next": "Siguiente" + "next": "Siguiente", + "log_entry_details": "Detalles de entrada del registro", + "viewing_details_for": "Viendo los detalles completos de la entrada del registro #", + "actor_id": "ID del actor", + "previous_hash": "Hash anterior", + "current_hash": "Hash actual", + "close": "Cerrar", + "verification_successful_title": "Verificación exitosa", + "verification_successful_message": "Integridad del registro de auditoría verificada correctamente.", + "verification_failed_title": "Verificación fallida", + "verification_failed_message": "La verificación de integridad del registro de auditoría falló. Revise los registros del sistema para más detalles.", + "verification_error_message": "Ocurrió un error inesperado durante la verificación. Por favor, inténtelo de nuevo." + }, + "jobs": { + "title": "Colas de trabajo", + "queues": "Colas de trabajo", + "active": "Activo", + "completed": "Completado", + "failed": "Fallido", + "delayed": "Retrasado", + "waiting": "En espera", + "paused": "Pausado", + "back_to_queues": "Volver a las colas", + "queue_overview": "Resumen de colas", + "jobs": "Trabajos", + "id": "ID", + "name": "Nombre", + "state": "Estado", + "created_at": "Creado el", + "processed_at": "Procesado el", + "finished_at": "Finalizado el", + "showing": "Mostrando", + "of": "de", + "previous": "Anterior", + "next": "Siguiente", + "ingestion_source": "Fuente de ingesta" + }, + "journaling": { + "title": "Registro SMTP", + "header": "Fuentes de registro SMTP", + "meta_description": "Configure puntos de conexión de registro SMTP en tiempo real para el archivado de correo electrónico sin interrupciones desde MTA corporativos.", + "header_description": "Reciba una copia en tiempo real de cada correo electrónico directamente desde su servidor de correo mediante el registro SMTP, garantizando cero pérdida de datos.", + "create_new": "Crear fuente", + "no_sources_found": "No hay fuentes de registro configuradas.", + "name": "Nombre", + "allowed_ips": "IPs permitidas / CIDR", + "require_tls": "Requerir TLS", + "smtp_username": "Usuario SMTP", + "smtp_password": "Contraseña SMTP", + "status": "Estado", + "active": "Activo", + "paused": "Pausado", + "total_received": "Correos recibidos", + "last_received_at": "Último recibido", + "created_at": "Creado el", + "actions": "Acciones", + "create": "Crear", + "edit": "Editar", + "delete": "Eliminar", + "pause": "Pausar", + "activate": "Activar", + "save": "Guardar cambios", + "cancel": "Cancelar", + "confirm": "Confirmar eliminación", + "name_placeholder": "p. ej. Receptor de diario MS365", + "allowed_ips_placeholder": "p. ej. 40.107.0.0/16, 52.100.0.0/14", + "allowed_ips_hint": "Direcciones IP o bloques CIDR separados por comas de sus servidores de correo autorizados a enviar informes de diario.", + "smtp_username_placeholder": "p. ej. journal-tenant-123", + "smtp_password_placeholder": "Ingrese una contraseña segura para la autenticación SMTP", + "smtp_auth_hint": "Opcional. Si se configura, el MTA debe autenticarse con estas credenciales al conectarse.", + "create_description": "Configure un nuevo punto de conexión de registro SMTP. Su MTA enviará informes de diario a este punto para el archivado en tiempo real.", + "edit_description": "Actualice la configuración de esta fuente de registro.", + "delete_confirmation_title": "¿Eliminar esta fuente de registro?", + "delete_confirmation_description": "Esto eliminará permanentemente el punto de conexión de registro y todos los correos electrónicos archivados asociados. Su MTA ya no podrá enviar informes de diario a este punto.", + "deleting": "Eliminando", + "smtp_connection_info": "Información de conexión SMTP", + "smtp_host": "Host", + "smtp_port": "Puerto", + "routing_address": "Dirección de enrutamiento", + "routing_address_hint": "Configure esta dirección como destinatario del diario en su MTA (Exchange, MS365, Postfix).", + "regenerate_address": "Regenerar dirección", + "regenerate_address_warning": "Esto invalidará la dirección actual. Debe actualizar su configuración de MTA para usar la nueva dirección.", + "regenerate_address_confirm": "¿Está seguro de que desea regenerar la dirección de enrutamiento? La dirección actual dejará de funcionar inmediatamente y deberá actualizar su configuración de MTA.", + "regenerate_address_success": "Dirección de enrutamiento regenerada correctamente. Actualice su configuración de MTA con la nueva dirección.", + "regenerate_address_error": "No se pudo regenerar la dirección de enrutamiento.", + "create_success": "Fuente de registro creada correctamente.", + "update_success": "Fuente de registro actualizada correctamente.", + "delete_success": "Fuente de registro eliminada correctamente.", + "create_error": "No se pudo crear la fuente de registro.", + "update_error": "No se pudo actualizar la fuente de registro.", + "delete_error": "No se pudo eliminar la fuente de registro.", + "health_listening": "Listener SMTP: Activo", + "health_down": "Listener SMTP: Inactivo", + "never": "Nunca" + }, + "license_page": { + "title": "Estado de la licencia Enterprise", + "meta_description": "Vea el estado actual de su licencia Open Archiver Enterprise.", + "revoked_title": "Licencia no válida", + "revoked_message": "Su licencia ha sido revocada o el período de gracia por exceso de plazas ha expirado. Todas las funciones Enterprise están ahora deshabilitadas. Comuníquese con su gestor de cuenta para obtener asistencia.", + "notice_title": "Aviso", + "seat_limit_exceeded_title": "Límite de plazas superado", + "seat_limit_exceeded_message": "Su licencia cubre {{planSeats}} plazas pero actualmente hay {{activeSeats}} en uso. Reduzca el uso o actualice su plan.", + "seat_limit_grace_deadline": "Las funciones Enterprise se deshabilitarán el {{date}} a menos que se reduzca el número de plazas.", + "customer": "Cliente", + "license_details": "Detalles de la licencia", + "license_status": "Estado de la licencia", + "active": "Activo", + "expired": "Vencido", + "revoked": "Revocado", + "overage": "Exceso de plazas", + "unknown": "Desconocido", + "expires": "Vence", + "last_checked": "Última verificación", + "seat_usage": "Uso de plazas", + "seats_used": "{{activeSeats}} de {{planSeats}} plazas usadas", + "enabled_features": "Funciones habilitadas", + "enabled_features_description": "Las siguientes funciones Enterprise están actualmente habilitadas.", + "feature": "Función", + "status": "Estado", + "enabled": "Habilitado", + "disabled": "Deshabilitado", + "could_not_load_title": "No se pudo cargar la licencia", + "could_not_load_message": "Ocurrió un error inesperado.", + "revalidate": "Revalidar licencia", + "revalidating": "Revalidando...", + "revalidate_success": "Licencia revalidada correctamente." } } } diff --git a/packages/frontend/src/lib/translations/fr.json b/packages/frontend/src/lib/translations/fr.json index 1073276..9be4b8b 100644 --- a/packages/frontend/src/lib/translations/fr.json +++ b/packages/frontend/src/lib/translations/fr.json @@ -7,7 +7,8 @@ "password": "Mot de passe" }, "common": { - "working": "Travail en cours" + "working": "Travail en cours", + "read_docs": "Lire la documentation" }, "archive": { "title": "Archive", @@ -32,7 +33,30 @@ "deleting": "Suppression en cours", "confirm": "Confirmer", "cancel": "Annuler", - "not_found": "Email non trouvé." + "not_found": "Email non trouvé.", + "integrity_report": "Rapport d'intégrité", + "download_integrity_report_pdf": "Télécharger le rapport d'intégrité (PDF)", + "downloading_integrity_report": "Génération en cours...", + "integrity_report_download_error": "Échec de la génération du rapport d'intégrité.", + "email_eml": "Email (.eml)", + "valid": "Valide", + "invalid": "Non valide", + "integrity_check_failed_title": "Échec de la vérification d'intégrité", + "integrity_check_failed_message": "Impossible de vérifier l'intégrité de l'email et de ses pièces jointes.", + "integrity_report_description": "Ce rapport vérifie que le contenu de vos emails archivés n'a pas été modifié.", + "retention_policy": "Politique de conservation", + "retention_policy_description": "Indique quelle politique de conservation régit cet email et quand sa suppression est prévue.", + "retention_no_policy": "Aucune politique applicable — cet email ne sera pas supprimé automatiquement.", + "retention_period": "Durée de conservation", + "retention_action": "Action à l'expiration", + "retention_matching_policies": "Politiques applicables", + "retention_delete_permanently": "Suppression définitive", + "retention_scheduled_deletion": "Suppression planifiée", + "retention_policy_overridden_by_label": "Cette politique est remplacée par l'étiquette de conservation ", + "embedded_attachments": "Pièces jointes intégrées", + "embedded": "Intégré", + "embedded_attachment_title": "Pièce jointe intégrée", + "embedded_attachment_description": "Cette pièce jointe est intégrée dans le fichier email d'origine et ne peut pas être téléchargée séparément. Pour l'obtenir, téléchargez le fichier email complet (.eml)." }, "ingestions": { "title": "Sources d'ingestion", @@ -63,7 +87,19 @@ "confirm": "Confirmer", "cancel": "Annuler", "bulk_delete_confirmation_title": "Êtes-vous sûr de vouloir supprimer {{count}} ingestions sélectionnées ?", - "bulk_delete_confirmation_description": "Cela supprimera tous les emails archivés, les pièces jointes, l'indexation et les fichiers associés à ces ingestions. Si vous souhaitez uniquement arrêter la synchronisation des nouveaux emails, vous pouvez suspendre les ingestions à la place." + "bulk_delete_confirmation_description": "Cela supprimera tous les emails archivés, les pièces jointes, l'indexation et les fichiers associés à ces ingestions. Si vous souhaitez uniquement arrêter la synchronisation des nouveaux emails, vous pouvez suspendre les ingestions à la place.", + "merged_sources": "sources fusionnées", + "unmerge": "Dissocier", + "unmerge_success": "La source a été dissociée de son groupe.", + "unmerge_confirmation_title": "Dissocier cette source ?", + "unmerge_confirmation_description": "Cela dissociera la source secondaire du groupe et en fera une ingestion indépendante. Veuillez noter ce qui suit :", + "unmerge_warning_emails": "Les emails déjà ingérés par cette source sont stockés sous la source racine. Ils y resteront et ne seront pas déplacés.", + "unmerge_warning_future": "Seuls les nouveaux emails ingérés après la dissociation seront stockés sous cette source.", + "unmerge_confirm": "Dissocier", + "unmerging": "Dissociation en cours", + "delete_root_warning": "Cette ingestion possède {{count}} source(s) fusionnée(s). Sa suppression entraînera également la suppression de toutes les sources fusionnées et de leurs données.", + "expand": "Développer", + "collapse": "Réduire" }, "search": { "title": "Recherche", @@ -110,6 +146,23 @@ "confirm": "Confirmer", "cancel": "Annuler" }, + "account": { + "title": "Paramètres du compte", + "description": "Gérez votre profil et vos paramètres de sécurité.", + "personal_info": "Informations personnelles", + "personal_info_desc": "Mettez à jour vos données personnelles.", + "security": "Sécurité", + "security_desc": "Gérez votre mot de passe et vos préférences de sécurité.", + "edit_profile": "Modifier le profil", + "change_password": "Changer le mot de passe", + "edit_profile_desc": "Modifiez votre profil ici.", + "change_password_desc": "Changez votre mot de passe. Vous devrez saisir votre mot de passe actuel.", + "current_password": "Mot de passe actuel", + "new_password": "Nouveau mot de passe", + "confirm_new_password": "Confirmer le nouveau mot de passe", + "operation_successful": "Opération réussie", + "passwords_do_not_match": "Les mots de passe ne correspondent pas" + }, "system_settings": { "title": "Paramètres système", "system_settings": "Paramètres système", @@ -146,6 +199,76 @@ "confirm": "Confirmer", "cancel": "Annuler" }, + "components": { + "charts": { + "emails_ingested": "E-mails ingérés", + "storage_used": "Stockage utilisé", + "emails": "E-mails" + }, + "common": { + "submitting": "Soumission...", + "submit": "Soumettre", + "save": "Enregistrer" + }, + "email_preview": { + "loading": "Chargement de l'aperçu de l'email...", + "render_error": "Impossible de rendre l'aperçu de l'email.", + "not_available": "Le fichier .eml brut n'est pas disponible pour cet email." + }, + "footer": { + "all_rights_reserved": "Tous droits réservés.", + "new_version_available": "Nouvelle version disponible" + }, + "ingestion_source_form": { + "provider_generic_imap": "IMAP générique", + "provider_google_workspace": "Google Workspace", + "provider_microsoft_365": "Microsoft 365", + "provider_pst_import": "Importation PST", + "provider_eml_import": "Importation EML", + "provider_mbox_import": "Importation Mbox", + "select_provider": "Sélectionnez un fournisseur", + "import_method": "Méthode d'importation", + "upload_file": "Téléverser un fichier", + "local_path": "Chemin local (recommandé pour les fichiers volumineux)", + "local_file_path": "Chemin de fichier local", + "service_account_key": "Clé de compte de service (JSON)", + "service_account_key_placeholder": "Collez le contenu JSON de votre clé de compte de service", + "impersonated_admin_email": "Email de l'administrateur impersonné", + "client_id": "ID de l'application (client)", + "client_secret": "Valeur secrète du client", + "client_secret_placeholder": "Entrez la valeur secrète, pas l'ID secret", + "tenant_id": "ID du répertoire (locataire)", + "host": "Hôte", + "port": "Port", + "username": "Nom d'utilisateur", + "use_tls": "Utiliser TLS", + "allow_insecure_cert": "Autoriser les certificats non sécurisés", + "pst_file": "Fichier PST", + "eml_file": "Fichier EML", + "mbox_file": "Fichier Mbox", + "heads_up": "Attention !", + "org_wide_warning": "Veuillez noter qu'il s'agit d'une opération à l'échelle de l'organisation. Ce type d'ingestion importera et indexera toutes les boîtes de réception de votre organisation. Si vous souhaitez importer uniquement des boîtes de réception spécifiques, utilisez le connecteur IMAP.", + "upload_failed": "Échec du téléversement, veuillez réessayer", + "upload_network_error": "Le serveur n'a pas pu traiter le téléversement. Le fichier dépasse peut-être la limite de taille configurée (BODY_SIZE_LIMIT). Pour les fichiers très volumineux, utilisez l'option Chemin local.", + "merge_into": "Fusionner avec une ingestion existante", + "merge_into_description": "Les emails de cette source seront regroupés avec l'ingestion sélectionnée. Les deux sources se synchronisent indépendamment mais les emails apparaissent ensemble.", + "merge_into_tooltip": "Lors de la fusion, cette nouvelle source devient une source secondaire de l'ingestion racine sélectionnée. Tous les emails récupérés par cette source seront physiquement stockés sous l'ingestion racine, pas sous celle-ci.

Le paramètre Conserver le fichier original (conformité GoBD) de l'ingestion racine s'applique à l'ensemble du groupe. Le paramètre de ce formulaire est ignoré si la fusion est activée.

Les deux sources se synchronisent indépendamment selon leur propre calendrier.", + "merge_into_select": "Sélectionner l'ingestion à fusionner", + "advanced_options": "Options avancées", + "preserve_original_file": "Conserver le fichier original", + "preserve_original_file_tooltip": "Si coché : Stocke le fichier email exact et non modifié tel que reçu du serveur. Aucune pièce jointe n'est supprimée. Requis pour la conformité GoBD (Allemagne) et SEC 17a-4.

Si décoché : Supprime les pièces jointes non intégrées et les stocke séparément avec déduplication, économisant de l'espace de stockage." + }, + "role_form": { + "policies_json": "Politiques (JSON)", + "invalid_json": "Format JSON invalide pour les politiques." + }, + "theme_switcher": { + "toggle_theme": "Changer de thème" + }, + "user_form": { + "select_role": "Sélectionnez un rôle" + } + }, "setup": { "title": "Configuration", "description": "Configurez le compte administrateur initial pour Open Archiver.", @@ -167,61 +290,52 @@ "system": "Système", "users": "Utilisateurs", "roles": "Rôles", - "logout": "Déconnexion" + "api_keys": "Clés API", + "account": "Compte", + "logout": "Déconnexion", + "admin": "Admin" }, - "components": { - "charts": { - "emails_ingested": "E-mails ingérés", - "storage_used": "Stockage utilisé", - "emails": "E-mails" - }, - "common": { - "submitting": "Soumission...", - "submit": "Soumettre", - "save": "Enregistrer" - }, - "email_preview": { - "loading": "Chargement de l'aperçu de l'email...", - "render_error": "Impossible de rendre l'aperçu de l'email.", - "not_available": "Le fichier .eml brut n'est pas disponible pour cet email." - }, - "footer": { - "all_rights_reserved": "Tous droits réservés." - }, - "ingestion_source_form": { - "provider_generic_imap": "IMAP générique", - "provider_google_workspace": "Google Workspace", - "provider_microsoft_365": "Microsoft 365", - "provider_pst_import": "Importation PST", - "provider_eml_import": "Importation EML", - "select_provider": "Sélectionnez un fournisseur", - "service_account_key": "Clé de compte de service (JSON)", - "service_account_key_placeholder": "Collez le contenu JSON de votre clé de compte de service", - "impersonated_admin_email": "Email de l'administrateur impersonné", - "client_id": "ID de l'application (client)", - "client_secret": "Valeur secrète du client", - "client_secret_placeholder": "Entrez la valeur secrète, pas l'ID secret", - "tenant_id": "ID du répertoire (locataire)", - "host": "Hôte", - "port": "Port", - "username": "Nom d'utilisateur", - "use_tls": "Utiliser TLS", - "pst_file": "Fichier PST", - "eml_file": "Fichier EML", - "heads_up": "Attention !", - "org_wide_warning": "Veuillez noter qu'il s'agit d'une opération à l'échelle de l'organisation. Ce type d'ingestion importera et indexera toutes les boîtes de réception de votre organisation. Si vous souhaitez importer uniquement des boîtes de réception spécifiques, utilisez le connecteur IMAP.", - "upload_failed": "Échec du téléchargement, veuillez réessayer" - }, - "role_form": { - "policies_json": "Politiques (JSON)", - "invalid_json": "Format JSON invalide pour les politiques." - }, - "theme_switcher": { - "toggle_theme": "Changer de thème" - }, - "user_form": { - "select_role": "Sélectionnez un rôle" - } + "api_keys_page": { + "title": "Clés API", + "header": "Clés API", + "generate_new_key": "Générer une nouvelle clé", + "name": "Nom", + "key": "Clé", + "expires_at": "Expire le", + "created_at": "Créé le", + "actions": "Actions", + "delete": "Supprimer", + "no_keys_found": "Aucune clé API trouvée.", + "generate_modal_title": "Générer une nouvelle clé API", + "generate_modal_description": "Veuillez fournir un nom et une date d'expiration pour votre nouvelle clé API.", + "expires_in": "Expire dans", + "select_expiration": "Sélectionnez une expiration", + "30_days": "30 jours", + "60_days": "60 jours", + "6_months": "6 mois", + "12_months": "12 mois", + "24_months": "24 mois", + "generate": "Générer", + "new_api_key": "Nouvelle clé API", + "failed_to_delete": "Échec de la suppression de la clé API", + "api_key_deleted": "Clé API supprimée", + "generated_title": "Clé API générée", + "generated_message": "Votre clé API a été générée. Veuillez la copier et la sauvegarder dans un endroit sécurisé. Cette clé ne sera affichée qu'une seule fois." + }, + "archived_emails_page": { + "title": "E-mails archivés", + "header": "E-mails archivés", + "select_ingestion_source": "Sélectionnez une source d'ingestion", + "date": "Date", + "subject": "Sujet", + "sender": "Expéditeur", + "inbox": "Boîte de réception", + "path": "Chemin", + "actions": "Actions", + "view": "Voir", + "no_emails_found": "Aucun e-mail archivé trouvé.", + "prev": "Préc", + "next": "Suiv" }, "dashboard_page": { "title": "Tableau de bord", @@ -241,20 +355,372 @@ "top_10_senders": "Top 10 des expéditeurs", "no_indexed_insights": "Aucune information indexée disponible." }, - "archived_emails_page": { - "title": "E-mails archivés", - "header": "E-mails archivés", - "select_ingestion_source": "Sélectionnez une source d'ingestion", - "date": "Date", - "subject": "Sujet", - "sender": "Expéditeur", - "inbox": "Boîte de réception", - "path": "Chemin", + "retention_policies": { + "title": "Politiques de conservation", + "header": "Politiques de conservation", + "meta_description": "Gérez les politiques de conservation des données pour automatiser le cycle de vie des emails et la conformité.", + "create_new": "Créer une nouvelle politique", + "no_policies_found": "Aucune politique de conservation trouvée.", + "name": "Nom", + "description": "Description", + "priority": "Priorité", + "retention_period": "Durée de conservation", + "retention_period_days": "Durée de conservation (jours)", + "action_on_expiry": "Action à l'expiration", + "delete_permanently": "Supprimer définitivement", + "status": "Statut", + "active": "Actif", + "inactive": "Inactif", + "conditions": "Conditions", + "conditions_description": "Définissez des règles pour faire correspondre les emails. Si aucune condition n'est définie, la politique s'applique à tous les emails.", + "logical_operator": "Opérateur logique", + "and": "ET", + "or": "OU", + "add_rule": "Ajouter une règle", + "remove_rule": "Supprimer la règle", + "field": "Champ", + "field_sender": "Expéditeur", + "field_recipient": "Destinataire", + "field_subject": "Sujet", + "field_attachment_type": "Type de pièce jointe", + "operator": "Opérateur", + "operator_equals": "Égal à", + "operator_not_equals": "Différent de", + "operator_contains": "Contient", + "operator_not_contains": "Ne contient pas", + "operator_starts_with": "Commence par", + "operator_ends_with": "Se termine par", + "operator_domain_match": "Correspondance de domaine", + "operator_regex_match": "Correspondance regex", + "value": "Valeur", + "value_placeholder": "ex. utilisateur@exemple.com", + "edit": "Modifier", + "delete": "Supprimer", + "create": "Créer", + "save": "Enregistrer les modifications", + "cancel": "Annuler", + "create_description": "Créez une nouvelle politique de conservation pour gérer le cycle de vie des emails archivés.", + "edit_description": "Mettez à jour les paramètres de cette politique de conservation.", + "delete_confirmation_title": "Supprimer cette politique de conservation ?", + "delete_confirmation_description": "Cette action est irréversible. Les emails correspondant à cette politique ne seront plus soumis à la suppression automatique.", + "deleting": "Suppression en cours", + "confirm": "Confirmer", + "days": "jours", + "no_conditions": "Tous les emails (sans filtre)", + "rules": "règles", + "simulator_title": "Simulateur de politique", + "simulator_description": "Testez les métadonnées d'un email contre toutes les politiques actives pour voir quelle durée de conservation s'appliquerait.", + "simulator_sender": "Email de l'expéditeur", + "simulator_sender_placeholder": "ex. jean@finances.entreprise.fr", + "simulator_recipients": "Destinataires", + "simulator_recipients_placeholder": "Séparés par des virgules, ex. marie@entreprise.fr, paul@entreprise.fr", + "simulator_subject": "Sujet", + "simulator_subject_placeholder": "ex. Rapport fiscal T4", + "simulator_attachment_types": "Types de pièces jointes", + "simulator_attachment_types_placeholder": "Séparés par des virgules, ex. .pdf, .xlsx", + "simulator_run": "Lancer la simulation", + "simulator_running": "En cours...", + "simulator_result_title": "Résultat de la simulation", + "simulator_no_match": "Aucune politique active ne correspond à cet email. Il ne sera pas soumis à une suppression automatique.", + "simulator_matched": "Correspondance — durée de conservation de {{days}} jours applicable.", + "simulator_matching_policies": "IDs des politiques correspondantes", + "simulator_no_result": "Lancez une simulation pour voir quelles politiques s'appliquent à un email donné.", + "simulator_ingestion_source": "Simuler pour la source d'ingestion", + "simulator_ingestion_source_description": "Sélectionnez une source d'ingestion pour tester les politiques limitées. Laissez vide pour évaluer toutes les politiques quel que soit leur périmètre.", + "simulator_ingestion_all": "Toutes les sources (ignorer le périmètre)", + "ingestion_scope": "Périmètre d'ingestion", + "ingestion_scope_description": "Limitez cette politique à des sources d'ingestion spécifiques. Laissez tout décoché pour l'appliquer à toutes les sources.", + "ingestion_scope_all": "Toutes les sources d'ingestion", + "ingestion_scope_selected": "{{count}} source(s) sélectionnée(s) — cette politique s'appliquera uniquement aux emails de ces sources.", + "create_success": "Politique de conservation créée avec succès.", + "update_success": "Politique de conservation mise à jour avec succès.", + "delete_success": "Politique de conservation supprimée avec succès.", + "delete_error": "Échec de la suppression de la politique de conservation." + }, + "retention_labels": { + "title": "Étiquettes de conservation", + "header": "Étiquettes de conservation", + "meta_description": "Gérez les étiquettes de conservation pour les remplacements de conformité au niveau des éléments sur les emails archivés individuels.", + "create_new": "Créer une étiquette", + "no_labels_found": "Aucune étiquette de conservation trouvée.", + "name": "Nom", + "description": "Description", + "retention_period": "Durée de conservation", + "retention_period_days": "Durée de conservation (jours)", + "applied_count": "Emails appliqués", + "status": "Statut", + "enabled": "Activé", + "disabled": "Désactivé", + "created_at": "Créé le", "actions": "Actions", - "view": "Voir", - "no_emails_found": "Aucun e-mail archivé trouvé.", + "create": "Créer", + "edit": "Modifier", + "delete": "Supprimer", + "disable": "Désactiver", + "save": "Enregistrer les modifications", + "cancel": "Annuler", + "days": "jours", + "create_description": "Créez une nouvelle étiquette de conservation. Une fois appliquée aux emails, la durée de conservation de l'étiquette ne peut pas être modifiée.", + "edit_description": "Mettez à jour les détails de cette étiquette de conservation.", + "delete_confirmation_title": "Supprimer cette étiquette de conservation ?", + "delete_confirmation_description": "Cette action supprimera définitivement l'étiquette. Elle ne pourra plus être appliquée aux nouveaux emails.", + "disable_confirmation_title": "Désactiver cette étiquette de conservation ?", + "disable_confirmation_description": "Cette étiquette est actuellement appliquée à des emails archivés et ne peut pas être supprimée. Elle sera désactivée afin de ne plus pouvoir être appliquée aux nouveaux emails, mais les emails déjà étiquetés conserveront cette étiquette même si elle n'aura plus d'effet.", + "force_delete_confirmation_title": "Supprimer définitivement cette étiquette désactivée ?", + "force_delete_confirmation_description": "Cette étiquette est désactivée mais possède encore des associations d'emails. Sa suppression effacera toutes ces associations et supprimera définitivement l'étiquette. Cette action est irréversible.", + "deleting": "Traitement en cours", + "confirm": "Confirmer", + "create_success": "Étiquette de conservation créée avec succès.", + "update_success": "Étiquette de conservation mise à jour avec succès.", + "delete_success": "Étiquette de conservation supprimée avec succès.", + "disable_success": "Étiquette de conservation désactivée avec succès.", + "delete_error": "Échec de la suppression de l'étiquette de conservation.", + "create_error": "Échec de la création de l'étiquette de conservation.", + "update_error": "Échec de la mise à jour de l'étiquette de conservation.", + "retention_period_locked": "La durée de conservation ne peut pas être modifiée tant que l'étiquette est appliquée à des emails.", + "name_placeholder": "ex. Document fiscal - 10 ans", + "description_placeholder": "ex. Appliqué aux documents fiscaux nécessitant une conservation prolongée." + }, + "archive_labels": { + "section_title": "Étiquette de conservation", + "section_description": "Remplacez le calendrier de conservation de cet email par une étiquette spécifique.", + "current_label": "Étiquette actuelle", + "no_label": "Aucune étiquette appliquée", + "select_label": "Sélectionner une étiquette", + "select_label_placeholder": "Choisir une étiquette de conservation...", + "apply": "Appliquer l'étiquette", + "applying": "Application en cours...", + "remove": "Supprimer l'étiquette", + "removing": "Suppression en cours...", + "apply_success": "Étiquette de conservation appliquée avec succès.", + "remove_success": "Étiquette de conservation supprimée avec succès.", + "apply_error": "Échec de l'application de l'étiquette de conservation.", + "remove_error": "Échec de la suppression de l'étiquette de conservation.", + "label_overrides_policy": "Cette étiquette remplace les politiques de conservation générales pour cet email.", + "no_labels_available": "Aucune étiquette de conservation disponible. Créez des étiquettes dans les paramètres de conformité.", + "label_inactive": "Inactif", + "label_inactive_note": "Cette étiquette a été désactivée. Elle ne fournit plus de remplacement de conservation ni de date de suppression planifiée pour cet email. Vous pouvez la supprimer et appliquer une étiquette active si nécessaire." + }, + "legal_holds": { + "title": "Conservations légales", + "header": "Conservations légales", + "meta_description": "Gérez les conservations légales pour préserver les emails de la suppression automatique pendant les litiges ou enquêtes réglementaires.", + "header_description": "Les conservations légales suspendent la suppression automatique des enregistrements spécifiques pertinents pour les litiges ou enquêtes réglementaires.", + "create_new": "Créer une conservation", + "no_holds_found": "Aucune conservation légale trouvée.", + "name": "Nom", + "reason": "Motif / Description", + "no_reason": "Aucun motif fourni", + "email_count": "Emails protégés", + "status": "Statut", + "active": "Actif", + "inactive": "Inactif", + "created_at": "Créé le", + "actions": "Actions", + "create": "Créer", + "edit": "Modifier", + "delete": "Supprimer", + "activate": "Activer", + "deactivate": "Désactiver", + "bulk_apply": "Application en masse via recherche", + "release_all": "Libérer tous les emails", + "save": "Enregistrer les modifications", + "cancel": "Annuler", + "confirm": "Confirmer la suppression", + "name_placeholder": "ex. Litige Projet Titan - 2026", + "reason_placeholder": "ex. Litige en cours lié au Projet Titan. Toutes les communications doivent être conservées.", + "create_description": "Créez une nouvelle conservation légale pour empêcher la suppression automatique des emails pertinents.", + "edit_description": "Mettez à jour le nom ou la description de cette conservation légale.", + "delete_confirmation_title": "Supprimer définitivement cette conservation légale ?", + "delete_confirmation_description": "Cela supprimera définitivement la conservation et toutes les associations d'emails. Les emails précédemment protégés seront soumis aux règles de conservation normales lors de la prochaine exécution du worker du cycle de vie.", + "bulk_apply_title": "Appliquer la conservation légale en masse via recherche", + "bulk_apply_description": "Recherchez des emails en utilisant des filtres de texte intégral et de métadonnées. Tous les emails correspondants seront placés sous cette conservation légale. La requête exacte est sauvegardée dans le journal d'audit comme preuve du périmètre.", + "bulk_query": "Mots-clés de recherche", + "bulk_query_placeholder": "ex. Projet Titan confidentiel", + "bulk_query_hint": "Recherche dans le corps de l'email, le sujet et le contenu des pièces jointes via l'index de texte intégral.", + "bulk_from": "De (email de l'expéditeur)", + "bulk_date_start": "Date de début", + "bulk_date_end": "Date de fin", + "bulk_apply_warning": "Cette action s'appliquera à TOUS les emails correspondant à votre recherche dans l'ensemble de l'archive. La requête de recherche sera enregistrée définitivement dans le journal d'audit.", + "bulk_apply_confirm": "Appliquer la conservation aux emails correspondants", + "release_all_title": "Libérer tous les emails de cette conservation ?", + "release_all_description": "Tous les emails perdront leur immunité de conservation légale. Ils seront évalués par rapport aux politiques de conservation standard lors de la prochaine exécution du worker du cycle de vie et pourront être supprimés définitivement.", + "release_all_confirm": "Libérer tous les emails", + "create_success": "Conservation légale créée avec succès.", + "update_success": "Conservation légale mise à jour avec succès.", + "delete_success": "Conservation légale supprimée avec succès.", + "activated_success": "Conservation légale activée. Les emails protégés sont désormais immunisés contre la suppression.", + "deactivated_success": "Conservation légale désactivée. Les emails ne sont plus protégés par cette conservation.", + "bulk_apply_success": "Conservation légale appliquée avec succès.", + "release_all_success": "Tous les emails libérés de la conservation.", + "create_error": "Échec de la création de la conservation légale.", + "update_error": "Échec de la mise à jour de la conservation légale.", + "delete_error": "Échec de la suppression de la conservation légale.", + "bulk_apply_error": "Échec de l'application en masse.", + "release_all_error": "Échec de la libération des emails de la conservation." + }, + "archive_legal_holds": { + "section_title": "Conservations légales", + "section_description": "Suspendez la suppression automatique de cet email en le plaçant sous une conservation légale.", + "no_holds": "Aucune conservation légale appliquée à cet email.", + "hold_name": "Nom de la conservation", + "hold_status": "Statut", + "applied_at": "Appliqué le", + "apply_hold": "Appliquer une conservation", + "apply_hold_placeholder": "Sélectionner une conservation légale...", + "apply": "Appliquer la conservation", + "applying": "Application en cours...", + "remove": "Supprimer", + "removing": "Suppression en cours...", + "apply_success": "Conservation légale appliquée à cet email.", + "remove_success": "Conservation légale supprimée de cet email.", + "apply_error": "Échec de l'application de la conservation légale.", + "remove_error": "Échec de la suppression de la conservation légale.", + "immune_notice": "Cet email est protégé par une conservation légale active et ne peut pas être supprimé.", + "no_active_holds": "Aucune conservation légale active disponible. Créez des conservations dans les paramètres de conformité." + }, + "audit_log": { + "title": "Journal d'audit", + "header": "Journal d'audit", + "verify_integrity": "Vérifier l'intégrité du journal", + "log_entries": "Entrées du journal", + "timestamp": "Horodatage", + "actor": "Acteur", + "action": "Action", + "target": "Cible", + "details": "Détails", + "ip_address": "Adresse IP", + "target_type": "Type de cible", + "target_id": "ID de cible", + "no_logs_found": "Aucun journal d'audit trouvé.", "prev": "Préc", - "next": "Suiv" + "next": "Suiv", + "log_entry_details": "Détails de l'entrée du journal", + "viewing_details_for": "Affichage des détails complets de l'entrée du journal #", + "actor_id": "ID de l'acteur", + "previous_hash": "Hash précédent", + "current_hash": "Hash actuel", + "close": "Fermer", + "verification_successful_title": "Vérification réussie", + "verification_successful_message": "Intégrité du journal d'audit vérifiée avec succès.", + "verification_failed_title": "Échec de la vérification", + "verification_failed_message": "La vérification d'intégrité du journal d'audit a échoué. Veuillez consulter les journaux système pour plus de détails.", + "verification_error_message": "Une erreur inattendue s'est produite lors de la vérification. Veuillez réessayer." + }, + "jobs": { + "title": "Files de travaux", + "queues": "Files de travaux", + "active": "Actif", + "completed": "Terminé", + "failed": "Échoué", + "delayed": "Retardé", + "waiting": "En attente", + "paused": "En pause", + "back_to_queues": "Retour aux files", + "queue_overview": "Aperçu des files", + "jobs": "Travaux", + "id": "ID", + "name": "Nom", + "state": "État", + "created_at": "Créé le", + "processed_at": "Traité le", + "finished_at": "Terminé le", + "showing": "Affichage", + "of": "sur", + "previous": "Précédent", + "next": "Suivant", + "ingestion_source": "Source d'ingestion" + }, + "journaling": { + "title": "Journalisation SMTP", + "header": "Sources de journalisation SMTP", + "meta_description": "Configurez des points de terminaison de journalisation SMTP en temps réel pour l'archivage d'emails sans interruption depuis les MTA d'entreprise.", + "header_description": "Recevez une copie en temps réel de chaque email directement depuis votre serveur de messagerie via la journalisation SMTP, garantissant zéro perte de données.", + "create_new": "Créer une source", + "no_sources_found": "Aucune source de journalisation configurée.", + "name": "Nom", + "allowed_ips": "IPs autorisées / CIDR", + "require_tls": "Exiger TLS", + "smtp_username": "Nom d'utilisateur SMTP", + "smtp_password": "Mot de passe SMTP", + "status": "Statut", + "active": "Actif", + "paused": "En pause", + "total_received": "Emails reçus", + "last_received_at": "Dernier reçu", + "created_at": "Créé le", + "actions": "Actions", + "create": "Créer", + "edit": "Modifier", + "delete": "Supprimer", + "pause": "Mettre en pause", + "activate": "Activer", + "save": "Enregistrer les modifications", + "cancel": "Annuler", + "confirm": "Confirmer la suppression", + "name_placeholder": "ex. Récepteur journal MS365", + "allowed_ips_placeholder": "ex. 40.107.0.0/16, 52.100.0.0/14", + "allowed_ips_hint": "Adresses IP ou blocs CIDR séparés par des virgules de vos serveurs de messagerie autorisés à envoyer des rapports de journal.", + "smtp_username_placeholder": "ex. journal-tenant-123", + "smtp_password_placeholder": "Entrez un mot de passe fort pour l'authentification SMTP", + "smtp_auth_hint": "Optionnel. Si configuré, le MTA doit s'authentifier avec ces identifiants lors de la connexion.", + "create_description": "Configurez un nouveau point de terminaison de journalisation SMTP. Votre MTA enverra des rapports de journal à ce point pour l'archivage en temps réel.", + "edit_description": "Mettez à jour la configuration de cette source de journalisation.", + "delete_confirmation_title": "Supprimer cette source de journalisation ?", + "delete_confirmation_description": "Cela supprimera définitivement le point de terminaison de journalisation et tous les emails archivés associés. Votre MTA ne pourra plus envoyer de rapports de journal à ce point.", + "deleting": "Suppression en cours", + "smtp_connection_info": "Informations de connexion SMTP", + "smtp_host": "Hôte", + "smtp_port": "Port", + "routing_address": "Adresse de routage", + "routing_address_hint": "Configurez cette adresse comme destinataire du journal dans votre MTA (Exchange, MS365, Postfix).", + "regenerate_address": "Régénérer l'adresse", + "regenerate_address_warning": "Cela invalidera l'adresse actuelle. Vous devez mettre à jour votre configuration MTA pour utiliser la nouvelle adresse.", + "regenerate_address_confirm": "Êtes-vous sûr de vouloir régénérer l'adresse de routage ? L'adresse actuelle cessera de fonctionner immédiatement et vous devrez mettre à jour votre configuration MTA.", + "regenerate_address_success": "Adresse de routage régénérée avec succès. Mettez à jour votre configuration MTA avec la nouvelle adresse.", + "regenerate_address_error": "Échec de la régénération de l'adresse de routage.", + "create_success": "Source de journalisation créée avec succès.", + "update_success": "Source de journalisation mise à jour avec succès.", + "delete_success": "Source de journalisation supprimée avec succès.", + "create_error": "Échec de la création de la source de journalisation.", + "update_error": "Échec de la mise à jour de la source de journalisation.", + "delete_error": "Échec de la suppression de la source de journalisation.", + "health_listening": "Listener SMTP : Actif", + "health_down": "Listener SMTP : Inactif", + "never": "Jamais" + }, + "license_page": { + "title": "Statut de la licence Enterprise", + "meta_description": "Consultez le statut actuel de votre licence Open Archiver Enterprise.", + "revoked_title": "Licence non valide", + "revoked_message": "Votre licence a été révoquée ou votre période de grâce pour dépassement de sièges a expiré. Toutes les fonctionnalités Enterprise sont désormais désactivées. Veuillez contacter votre gestionnaire de compte pour obtenir de l'aide.", + "notice_title": "Avis", + "seat_limit_exceeded_title": "Limite de sièges dépassée", + "seat_limit_exceeded_message": "Votre licence couvre {{planSeats}} sièges mais {{activeSeats}} sont actuellement utilisés. Veuillez réduire l'utilisation ou mettre à niveau votre plan.", + "seat_limit_grace_deadline": "Les fonctionnalités Enterprise seront désactivées le {{date}} à moins que le nombre de sièges ne soit réduit.", + "customer": "Client", + "license_details": "Détails de la licence", + "license_status": "Statut de la licence", + "active": "Actif", + "expired": "Expiré", + "revoked": "Révoqué", + "overage": "Dépassement de sièges", + "unknown": "Inconnu", + "expires": "Expire", + "last_checked": "Dernière vérification", + "seat_usage": "Utilisation des sièges", + "seats_used": "{{activeSeats}} sur {{planSeats}} sièges utilisés", + "enabled_features": "Fonctionnalités activées", + "enabled_features_description": "Les fonctionnalités Enterprise suivantes sont actuellement activées.", + "feature": "Fonctionnalité", + "status": "Statut", + "enabled": "Activé", + "disabled": "Désactivé", + "could_not_load_title": "Impossible de charger la licence", + "could_not_load_message": "Une erreur inattendue s'est produite.", + "revalidate": "Revalider la licence", + "revalidating": "Revalidation en cours...", + "revalidate_success": "Licence revalidée avec succès." } } } diff --git a/packages/frontend/src/routes/dashboard/admin/license/+page.svelte b/packages/frontend/src/routes/dashboard/admin/license/+page.svelte index aa36053..a2d5fdb 100644 --- a/packages/frontend/src/routes/dashboard/admin/license/+page.svelte +++ b/packages/frontend/src/routes/dashboard/admin/license/+page.svelte @@ -37,7 +37,7 @@

{$t('app.license_page.title')}

- {#if data.licenseStatus.remoteStatus === 'REVOKED'} + {#if data.licenseStatus.remoteStatus === 'INVALID'}
@@ -112,7 +112,7 @@ > {:else if data.licenseStatus.isExpired} {$t('app.license_page.expired')} - {:else if data.licenseStatus.remoteStatus === 'REVOKED'} + {:else if data.licenseStatus.remoteStatus === 'INVALID'} {$t('app.license_page.revoked')} {:else} {$t('app.license_page.unknown')} diff --git a/packages/frontend/src/routes/dashboard/archived-emails/[id]/+page.svelte b/packages/frontend/src/routes/dashboard/archived-emails/[id]/+page.svelte index 4bf82da..3231df2 100644 --- a/packages/frontend/src/routes/dashboard/archived-emails/[id]/+page.svelte +++ b/packages/frontend/src/routes/dashboard/archived-emails/[id]/+page.svelte @@ -360,7 +360,7 @@
{/if} - {#if embeddedAttachments.length > 0} + {#if embeddedAttachments.length > 0 && (!email.attachments || email.attachments.length === 0)}

{$t('app.archive.embedded_attachments')} diff --git a/packages/frontend/src/routes/dashboard/ingestions/+page.server.ts b/packages/frontend/src/routes/dashboard/ingestions/+page.server.ts index d6a5326..fd069dc 100644 --- a/packages/frontend/src/routes/dashboard/ingestions/+page.server.ts +++ b/packages/frontend/src/routes/dashboard/ingestions/+page.server.ts @@ -1,6 +1,6 @@ import { api } from '$lib/server/api'; import type { PageServerLoad } from './$types'; -import type { IngestionSource } from '@open-archiver/types'; +import type { SafeIngestionSource } from '@open-archiver/types'; import { error } from '@sveltejs/kit'; export const load: PageServerLoad = async (event) => { const response = await api('/ingestion-sources', event); @@ -8,7 +8,7 @@ export const load: PageServerLoad = async (event) => { if (!response.ok) { throw error(response.status, responseText.message || 'Failed to fetch ingestions.'); } - const ingestionSources: IngestionSource[] = responseText; + const ingestionSources: SafeIngestionSource[] = responseText; return { ingestionSources, }; diff --git a/packages/frontend/src/routes/dashboard/ingestions/+page.svelte b/packages/frontend/src/routes/dashboard/ingestions/+page.svelte index 35116ec..7dbceec 100644 --- a/packages/frontend/src/routes/dashboard/ingestions/+page.svelte +++ b/packages/frontend/src/routes/dashboard/ingestions/+page.svelte @@ -3,43 +3,94 @@ import * as Table from '$lib/components/ui/table'; import { Button } from '$lib/components/ui/button'; import * as DropdownMenu from '$lib/components/ui/dropdown-menu'; - import { MoreHorizontal, Trash, RefreshCw } from 'lucide-svelte'; + import { MoreHorizontal, Trash, RefreshCw, ChevronRight } from 'lucide-svelte'; import * as Dialog from '$lib/components/ui/dialog'; import { Switch } from '$lib/components/ui/switch'; import { Checkbox } from '$lib/components/ui/checkbox'; import IngestionSourceForm from '$lib/components/custom/IngestionSourceForm.svelte'; import { api } from '$lib/api.client'; - import type { IngestionSource, CreateIngestionSourceDto } from '@open-archiver/types'; + import type { SafeIngestionSource, CreateIngestionSourceDto } from '@open-archiver/types'; import Badge from '$lib/components/ui/badge/badge.svelte'; import { setAlert } from '$lib/components/custom/alert/alert-state.svelte'; import * as HoverCard from '$lib/components/ui/hover-card/index.js'; import { t } from '$lib/translations'; let { data }: { data: PageData } = $props(); - let ingestionSources = $state(data.ingestionSources); + let ingestionSources = $state(data.ingestionSources as SafeIngestionSource[]); let isDialogOpen = $state(false); let isDeleteDialogOpen = $state(false); - let selectedSource = $state(null); - let sourceToDelete = $state(null); + let selectedSource = $state(null); + let sourceToDelete = $state(null); let isDeleting = $state(false); let selectedIds = $state([]); let isBulkDeleteDialogOpen = $state(false); + let isUnmergeDialogOpen = $state(false); + let sourceToUnmerge = $state(null); + let isUnmerging = $state(false); + /** Tracks which root source groups are expanded in the table */ + let expandedGroups = $state>(new Set()); + + // Group sources: roots (mergedIntoId is null/undefined) and their children + const rootSources = $derived(ingestionSources.filter((s) => !s.mergedIntoId)); + + /** Returns children for a given root source ID */ + function getChildren(rootId: string): SafeIngestionSource[] { + return ingestionSources.filter((s) => s.mergedIntoId === rootId); + } + + /** Returns aggregated status for a group. + * If the root is paused but children are still active, show 'active' + * so the group does not appear fully paused when children are running. */ + function getGroupStatus( + root: SafeIngestionSource, + children: SafeIngestionSource[] + ): SafeIngestionSource['status'] { + const all = [root, ...children]; + if (all.some((s) => s.status === 'error')) return 'error'; + if (all.some((s) => s.status === 'syncing')) return 'syncing'; + if (all.some((s) => s.status === 'importing')) return 'importing'; + if (all.every((s) => s.status === 'paused')) return 'paused'; + // Root paused but some children are active/imported — show active so the + // group badge reflects that ingestion is still ongoing via the children. + if ( + root.status === 'paused' && + children.some((s) => ['active', 'imported', 'syncing', 'importing'].includes(s.status)) + ) + return 'partially_active'; + if (all.every((s) => ['imported', 'active'].includes(s.status))) return 'active'; + return root.status; + } + + const toggleGroup = (rootId: string) => { + const next = new Set(expandedGroups); + if (next.has(rootId)) { + next.delete(rootId); + } else { + next.add(rootId); + } + expandedGroups = next; + }; const openCreateDialog = () => { selectedSource = null; isDialogOpen = true; }; - const openEditDialog = (source: IngestionSource) => { - selectedSource = source; + const openEditDialog = (source: SafeIngestionSource) => { + selectedSource = source as SafeIngestionSource; isDialogOpen = true; }; - const openDeleteDialog = (source: IngestionSource) => { + const openDeleteDialog = (source: SafeIngestionSource) => { sourceToDelete = source; isDeleteDialogOpen = true; }; + /** Count of children that will be deleted alongside a root source */ + const deleteChildCount = $derived( + sourceToDelete && !sourceToDelete.mergedIntoId ? getChildren(sourceToDelete.id).length : 0 + ); + const confirmDelete = async () => { if (!sourceToDelete) return; isDeleting = true; @@ -56,7 +107,11 @@ }); return; } - ingestionSources = ingestionSources.filter((s) => s.id !== sourceToDelete!.id); + // Remove the deleted source and any children from state + const deletedId = sourceToDelete.id; + ingestionSources = ingestionSources.filter( + (s) => s.id !== deletedId && s.mergedIntoId !== deletedId + ); isDeleteDialogOpen = false; sourceToDelete = null; } finally { @@ -77,16 +132,15 @@ }); return; } - const updatedSources = ingestionSources.map((s) => { + ingestionSources = ingestionSources.map((s) => { if (s.id === id) { return { ...s, status: 'syncing' as const }; } return s; }); - ingestionSources = updatedSources; }; - const handleToggle = async (source: IngestionSource) => { + const handleToggle = async (source: SafeIngestionSource) => { try { const isPaused = source.status === 'paused'; const newStatus = isPaused ? 'active' : 'paused'; @@ -126,6 +180,46 @@ } }; + const openUnmergeDialog = (source: SafeIngestionSource) => { + sourceToUnmerge = source; + isUnmergeDialogOpen = true; + }; + + const confirmUnmerge = async () => { + if (!sourceToUnmerge) return; + isUnmerging = true; + try { + const res = await api(`/ingestion-sources/${sourceToUnmerge.id}/unmerge`, { + method: 'POST', + }); + if (!res.ok) { + const errorBody = await res.json(); + throw Error(errorBody.message || 'Unmerge failed'); + } + const updated: SafeIngestionSource = await res.json(); + ingestionSources = ingestionSources.map((s) => (s.id === updated.id ? updated : s)); + isUnmergeDialogOpen = false; + sourceToUnmerge = null; + setAlert({ + type: 'success', + title: $t('app.ingestions.unmerge_success'), + message: '', + duration: 3000, + show: true, + }); + } catch (e) { + setAlert({ + type: 'error', + title: 'Failed to unmerge', + message: e instanceof Error ? e.message : JSON.stringify(e), + duration: 5000, + show: true, + }); + } finally { + isUnmerging = false; + } + }; + const handleBulkDelete = async () => { isDeleting = true; try { @@ -143,7 +237,11 @@ return; } } - ingestionSources = ingestionSources.filter((s) => !selectedIds.includes(s.id)); + // Remove deleted roots and their children from local state + // (backend cascades child deletion, so we mirror that here) + ingestionSources = ingestionSources.filter( + (s) => !selectedIds.includes(s.id) && !selectedIds.includes(s.mergedIntoId ?? '') + ); selectedIds = []; isBulkDeleteDialogOpen = false; } finally { @@ -166,13 +264,25 @@ }); } } - const updatedSources = ingestionSources.map((s) => { + // Backend cascades force sync to non-file-based children, + // so optimistically mark root + eligible children as syncing + const fileBasedProviders = ['pst_import', 'eml_import', 'mbox_import']; + ingestionSources = ingestionSources.map((s) => { + // Mark selected roots as syncing if (selectedIds.includes(s.id)) { return { ...s, status: 'syncing' as const }; } + // Mark non-file-based children of selected roots as syncing + if ( + s.mergedIntoId && + selectedIds.includes(s.mergedIntoId) && + !fileBasedProviders.includes(s.provider) && + (s.status === 'active' || s.status === 'error') + ) { + return { ...s, status: 'syncing' as const }; + } return s; }); - ingestionSources = updatedSources; selectedIds = []; } catch (e) { setAlert({ @@ -230,10 +340,12 @@ } }; - function getStatusClasses(status: IngestionSource['status']): string { + function getStatusClasses(status: SafeIngestionSource['status']): string { switch (status) { case 'active': return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300'; + case 'partially_active': + return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300'; case 'imported': return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300'; case 'paused': @@ -299,13 +411,13 @@ { if (checked) { - selectedIds = ingestionSources.map((s) => s.id); + selectedIds = rootSources.map((s) => s.id); } else { selectedIds = []; } }} - checked={ingestionSources.length > 0 && - selectedIds.length === ingestionSources.length + checked={rootSources.length > 0 && + selectedIds.length === rootSources.length ? true : ((selectedIds.length > 0 ? 'indeterminate' : false) as any)} /> @@ -319,8 +431,16 @@ - {#if ingestionSources.length > 0} - {#each ingestionSources as source (source.id)} + {#if rootSources.length > 0} + {#each rootSources as source (source.id)} + {@const children = getChildren(source.id)} + {@const hasChildren = children.length > 0} + {@const isExpanded = expandedGroups.has(source.id)} + {@const displayStatus = hasChildren + ? getGroupStatus(source, children) + : source.status} + + - {source.name} +
+ {#if hasChildren} + + {/if} + {source.name} + {#if hasChildren} + ({children.length} + {$t('app.ingestions.merged_sources')}) + {/if} +
{source.provider.split('_').join(' ')} - {source.status.split('_').join(' ')} + {displayStatus.split('_').join(' ')} - +

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

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

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

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

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

+ {/if}
@@ -512,3 +778,31 @@ + + + + + + {$t('app.ingestions.unmerge_confirmation_title')} + + {$t('app.ingestions.unmerge_confirmation_description')} + + +
    +
  • {$t('app.ingestions.unmerge_warning_emails')}
  • +
  • {$t('app.ingestions.unmerge_warning_future')}
  • +
+ + + + + + +
+
diff --git a/packages/types/src/ingestion.types.ts b/packages/types/src/ingestion.types.ts index e398286..27a195a 100644 --- a/packages/types/src/ingestion.types.ts +++ b/packages/types/src/ingestion.types.ts @@ -35,7 +35,8 @@ export type IngestionStatus = | 'syncing' | 'importing' | 'auth_success' - | 'imported'; + | 'imported' + | 'partially_active'; // For sources with merged children where some are active and others are not export interface BaseIngestionCredentials { type: IngestionProvider; @@ -123,6 +124,9 @@ export interface IngestionSource { /** When true, the raw EML file is stored without any modification (no attachment * stripping). Required for GoBD / SEC 17a-4 compliance. Defaults to false. */ preserveOriginalFile: boolean; + /** The ID of the root ingestion source this child is merged into. + * Null or undefined when this source is a standalone root. */ + mergedIntoId?: string | null; } /** @@ -138,6 +142,8 @@ export interface CreateIngestionSourceDto { providerConfig: Record; /** Store the unmodified raw EML for GoBD compliance. Defaults to false. */ preserveOriginalFile?: boolean; + /** Merge this new source into an existing root source's group. */ + mergedIntoId?: string; } export interface UpdateIngestionSourceDto { @@ -149,6 +155,8 @@ export interface UpdateIngestionSourceDto { lastSyncFinishedAt?: Date; lastSyncStatusMessage?: string; syncState?: SyncState; + /** Set or clear the merge parent. Use null to unmerge. */ + mergedIntoId?: string | null; } export interface IContinuousSyncJob {