Compare commits

...

11 Commits

Author SHA1 Message Date
wayneshn
0c2bcd8f28 Legal hold + retention label 2026-03-12 22:29:16 +01:00
Wei S.
81b87b4b7e Retention policy (#329)
* Retention policy schema/types

* schema generate

* retention policy (backend/frontend)
2026-03-09 18:31:38 +01:00
Wei S.
b5f95760f4 schema generation files (#326)
* Retention policy schema/types

* schema generate
2026-03-07 01:19:29 +01:00
Wei S.
85000ad82b Retention policy schema/types (#325) 2026-03-07 01:16:14 +01:00
Wei S.
c5672d0f81 V0.4.3 dev (#324)
* feat(types): update license types and prepare @open-archiver/types for public publish

- Add `LicensePingRequest` and `LicensePingResponse` interfaces for the license server ping endpoint
- Update `LicenseStatusPayload` to include `lastCheckedAt` and `planSeats` fields, and change status from `REVOKED` to `INVALID`
- Update `ConsolidatedLicenseStatus` to reflect `INVALID` status and add `lastCheckedAt`
- Bump `@open-archiver/types` version from 0.1.0 to 0.1.2, set license to MIT, make package public, and add `files` field

* update license types
2026-03-06 13:29:42 +01:00
Wei S.
9b303c963e feat(types): update license types and prepare @open-archiver/types for public publish (#320)
- Add `LicensePingRequest` and `LicensePingResponse` interfaces for the license server ping endpoint
- Update `LicenseStatusPayload` to include `lastCheckedAt` and `planSeats` fields, and change status from `REVOKED` to `INVALID`
- Update `ConsolidatedLicenseStatus` to reflect `INVALID` status and add `lastCheckedAt`
- Bump `@open-archiver/types` version from 0.1.0 to 0.1.2, set license to MIT, make package public, and add `files` field
2026-03-02 13:50:37 +01:00
Wei S.
9228f64221 Update demo user (#318)
Updated section headers and improved grammar in the README.
2026-02-27 13:22:57 +01:00
Wei S.
481a5ce6f9 Job tab dark theme fix, demo mode (#317) 2026-02-27 12:58:25 +01:00
Wei S.
3434e8d6ef v0.4.2-fix: improve ingestion error handling and error messages (#312)
* fix(backend): improve ingestion error handling and error messages

This commit introduces a "force delete" mechanism for Ingestion Sources and improves error messages for file-based connectors.

Changes:
- Update `IngestionService.delete` to accept a `force` flag, bypassing the `checkDeletionEnabled` check.
- Use `force` deletion when rolling back failed ingestion source creations (e.g., decryption errors or connection failures) to ensure cleanup even if deletion is globally disabled.
- Enhance error messages in `EMLConnector`, `MboxConnector`, and `PSTConnector` to distinguish between missing local files and failed uploads, providing more specific feedback to the user.

* feat(ingestion): optimize duplicate handling and fix race conditions in Google Workspace

- Implement fast duplicate check (by Message-ID) to skip full content download for existing emails in Google Workspace and IMAP connectors.
- Fix race condition in Google Workspace initial import by capturing `historyId` before listing messages, ensuring no data loss for incoming mail during import.
2026-02-24 18:10:32 +01:00
Wei S.
7dac3b2bfd V0.4.2 (#310)
* fix(api): correct API key generation and proxy handling

This commit resolves an issue where generating a new API key would fail. The root cause was improper handling of POST request bodies in the frontend proxy server.

- Refactored `ApiKeyController` methods to use arrow functions to ensure correct `this` binding.

* User profile/account page, change password, API

* docs(api): update ingestion source provider values

Update the `CreateIngestionSourceDto` documentation in `ingestion.md` to reflect the current set of supported providers.

* updating tag

* feat: add REDIS_USER env variable (#172)

* feat: add REDIS_USER env variable

fixes #171

* add proper type for bullmq config

* Bulgarian UI language strings added (backend+frontend) (#194)

* Bulgarian UI Support added

* BG language UI support - Create translation.json

* update redis config logic

* Update Bulgarian language setting, register language

* Allow specifying local file path for mbox/eml/pst (#214)

* Add agents AI doc

* Allow local file path for Mbox file ingestion


---------

Co-authored-by: Wei S. <5291640+wayneshn@users.noreply.github.com>

* feat(ingestion): add local file path support and optimize EML processing

- Frontend: Updated IngestionSourceForm to allow toggling between "Upload File" and "Local File Path" for PST, EML, and Mbox providers.
- Frontend: Added logic to clear irrelevant form data when switching import methods.
- Frontend: Added English translations for new form fields.
- Backend: Refactored EMLConnector to stream ZIP entries using yauzl instead of extracting the full archive to disk, significantly improving efficiency for large archives.
- Docs: Updated API documentation and User Guides (PST, EML, Mbox) to clarify "Local File Path" usage, specifically within Docker environments.

* docs: add meilisearch dumpless upgrade guide and snapshot config

Update `docker-compose.yml` to include the `MEILI_SCHEDULE_SNAPSHOT` environment variable, defaulting to 86400 seconds (24 hours), enabling periodic data snapshots for easier recovery. Shout out to @morph027 for the inspiration!

Additionally, update the Meilisearch upgrade documentation to include an experimental "dumpless" upgrade guide while marking the previous method as the standard recommended process.

* build(coolify): enable daily snapshots for meilisearch

Configure the Meilisearch service in `open-archiver.yml` to create snapshots every 86400 seconds (24 hours) by setting the `MEILI_SCHEDULE_SNAPSHOT` environment variable.

---------

Co-authored-by: Antonia Schwennesen <53372671+zophiana@users.noreply.github.com>
Co-authored-by: IT Creativity + Art Team <admin@it-playground.net>
Co-authored-by: Jan Berdajs <mrbrdo@gmail.com>
2026-02-23 21:25:44 +01:00
albanobattistella
cf121989ae Update Italian linguage (#278) 2026-01-18 15:28:20 +01:00
80 changed files with 12450 additions and 754 deletions

View File

@@ -36,6 +36,8 @@ REDIS_PORT=6379
REDIS_PASSWORD=defaultredispassword
# If you run Valkey service from Docker Compose, set the REDIS_TLS_ENABLED variable to false.
REDIS_TLS_ENABLED=false
# Redis username. Only required if not using the default user.
REDIS_USER=notdefaultuser
# --- Storage Settings ---

View File

@@ -11,7 +11,7 @@
Open Archiver provides a robust, self-hosted solution for archiving, storing, indexing, and searching emails from major platforms, including Google Workspace (Gmail), Microsoft 365, PST files, as well as generic IMAP-enabled email inboxes. Use Open Archiver to keep a permanent, tamper-proof record of your communication history, free from vendor lock-in.
## 📸 Screenshots
## Screenshots
![Open Archiver Preview](assets/screenshots/dashboard-1.png)
_Dashboard_
@@ -22,9 +22,9 @@ _Archived emails_
![Open Archiver Preview](assets/screenshots/search.png)
_Full-text search across all your emails and attachments_
## 👨‍👩‍👧‍👦 Join our community!
## Join our community!
We are committed to build an engaging community around Open Archiver, and we are inviting all of you to join our community on Discord to get real-time support and connect with the team.
We are committed to building an engaging community around Open Archiver, and we are inviting all of you to join our community on Discord to get real-time support and connect with the team.
[![Discord](https://img.shields.io/badge/Join%20our%20Discord-7289DA?style=for-the-badge&logo=discord&logoColor=white)](https://discord.gg/MTtD7BhuTQ)
@@ -34,11 +34,11 @@ We are committed to build an engaging community around Open Archiver, and we are
Check out the live demo here: https://demo.openarchiver.com
Username: admin@local.com
Username: demo@openarchiver.com
Password: openarchiver_demo
## Key Features
## Key Features
- **Universal Ingestion**: Connect to any email provider to perform initial bulk imports and maintain continuous, real-time synchronization. Ingestion sources include:
- IMAP connection
@@ -57,7 +57,7 @@ Password: openarchiver_demo
- - Each archived email comes with an "Integrity Report" feature that indicates if the files are original.
- **Comprehensive Auditing**: An immutable audit trail logs all system activities, ensuring you have a clear record of who accessed what and when.
## 🛠️ Tech Stack
## Tech Stack
Open Archiver is built on a modern, scalable, and maintainable technology stack:
@@ -68,7 +68,7 @@ Open Archiver is built on a modern, scalable, and maintainable technology stack:
- **Database**: PostgreSQL for metadata, user management, and audit logs
- **Deployment**: Docker Compose deployment
## 📦 Deployment
## Deployment
### Prerequisites
@@ -104,7 +104,7 @@ Open Archiver is built on a modern, scalable, and maintainable technology stack:
4. **Access the application:**
Once the services are running, you can access the Open Archiver web interface by navigating to `http://localhost:3000` in your web browser.
## ⚙️ Data Source Configuration
## Data Source Configuration
After deploying the application, you will need to configure one or more ingestion sources to begin archiving emails. Follow our detailed guides to connect to your email provider:
@@ -112,7 +112,7 @@ After deploying the application, you will need to configure one or more ingestio
- [Connecting to Microsoft 365](https://docs.openarchiver.com/user-guides/email-providers/imap.html)
- [Connecting to a Generic IMAP Server](https://docs.openarchiver.com/user-guides/email-providers/imap.html)
## 🤝 Contributing
## Contributing
We welcome contributions from the community!

View File

@@ -47,6 +47,7 @@ services:
restart: unless-stopped
environment:
MEILI_MASTER_KEY: ${MEILI_MASTER_KEY:-aSampleMasterKey}
MEILI_SCHEDULE_SNAPSHOT: ${MEILI_SCHEDULE_SNAPSHOT:-86400}
volumes:
- meilidata:/meili_data
networks:

View File

@@ -6,7 +6,7 @@ export default defineConfig({
'script',
{
defer: '',
src: 'https://analytics.zenceipt.com/script.js',
src: 'https://analytics.openarchiver.com/script.js',
'data-website-id': '2c8b452e-eab5-4f82-8ead-902d8f8b976f',
},
],

View File

@@ -24,6 +24,40 @@ interface CreateIngestionSourceDto {
}
```
#### Example: Creating an Mbox Import Source with File Upload
```json
{
"name": "My Mbox Import",
"provider": "mbox_import",
"providerConfig": {
"type": "mbox_import",
"uploadedFileName": "emails.mbox",
"uploadedFilePath": "open-archiver/tmp/uuid-emails.mbox"
}
}
```
#### Example: Creating an Mbox Import Source with Local File Path
```json
{
"name": "My Mbox Import",
"provider": "mbox_import",
"providerConfig": {
"type": "mbox_import",
"localFilePath": "/path/to/emails.mbox"
}
}
```
**Note:** When using `localFilePath`, the file will not be deleted after import. When using `uploadedFilePath` (via the upload API), the file will be automatically deleted after import. The same applies to `pst_import` and `eml_import` providers.
**Important regarding `localFilePath`:** When running OpenArchiver in a Docker container (which is the standard deployment), `localFilePath` refers to the path **inside the Docker container**, not on the host machine.
To use a local file:
1. **Recommended:** Place your file inside the directory defined by `STORAGE_LOCAL_ROOT_PATH` (e.g., inside a `temp` folder). Since this directory is already mounted as a volume, the file will be accessible at the same path inside the container.
2. **Alternative:** Mount a specific directory containing your files as a volume in `docker-compose.yml`. For example, add `- /path/to/my/files:/imports` to the `volumes` section and use `/imports/myfile.pst` as the `localFilePath`.
#### Responses
- **201 Created:** The newly created ingestion source.

View File

@@ -0,0 +1,454 @@
# Legal Holds: API Endpoints
The legal holds feature exposes a RESTful API for managing holds and linking them to archived emails. All endpoints require authentication and appropriate permissions as specified below.
**Base URL:** `/api/v1/enterprise/legal-holds`
All endpoints also require the `LEGAL_HOLDS` feature to be enabled in the enterprise license.
---
## Hold Management Endpoints
### List All Holds
Retrieves all legal holds ordered by creation date ascending, each annotated with the count of currently linked emails.
- **Endpoint:** `GET /holds`
- **Method:** `GET`
- **Authentication:** Required
- **Permission:** `manage:all`
#### Response Body
```json
[
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"name": "Project Titan Litigation — 2026",
"reason": "Preservation order received 2026-01-15 re: IP dispute",
"isActive": true,
"caseId": null,
"emailCount": 4821,
"createdAt": "2026-01-15T10:30:00.000Z",
"updatedAt": "2026-01-15T10:30:00.000Z"
},
{
"id": "b2c3d4e5-f6a7-8901-bcde-f23456789012",
"name": "SEC Investigation Q3 2025",
"reason": null,
"isActive": false,
"caseId": "c3d4e5f6-a7b8-9012-cdef-345678901234",
"emailCount": 310,
"createdAt": "2025-09-01T08:00:00.000Z",
"updatedAt": "2025-11-20T16:45:00.000Z"
}
]
```
---
### Get Hold by ID
Retrieves a single legal hold by its UUID.
- **Endpoint:** `GET /holds/:id`
- **Method:** `GET`
- **Authentication:** Required
- **Permission:** `manage:all`
#### Path Parameters
| Parameter | Type | Description |
| --------- | ------ | ----------------------------- |
| `id` | `uuid` | The UUID of the hold to get. |
#### Response
Returns a single hold object (same shape as the list endpoint), or `404` if not found.
---
### Create Hold
Creates a new legal hold. Holds are always created in the **active** state.
- **Endpoint:** `POST /holds`
- **Method:** `POST`
- **Authentication:** Required
- **Permission:** `manage:all`
#### Request Body
| Field | Type | Required | Description |
| -------- | -------- | -------- | ----------------------------------------------------------- |
| `name` | `string` | Yes | Unique hold name. Max 255 characters. |
| `reason` | `string` | No | Legal basis or description for the hold. Max 2 000 characters. |
| `caseId` | `uuid` | No | Optional UUID of an `ediscovery_cases` record to link to. |
#### Example Request
```json
{
"name": "Project Titan Litigation — 2026",
"reason": "Preservation notice received from outside counsel on 2026-01-15 regarding IP dispute with ExCorp.",
"caseId": null
}
```
#### Response
- **`201 Created`** — Returns the created hold object with `emailCount: 0`.
- **`409 Conflict`** — A hold with this name already exists.
- **`422 Unprocessable Entity`** — Validation errors.
---
### Update Hold
Updates the name, reason, or `isActive` state of a hold. Only the fields provided in the request body are modified.
- **Endpoint:** `PUT /holds/:id`
- **Method:** `PUT`
- **Authentication:** Required
- **Permission:** `manage:all`
#### Path Parameters
| Parameter | Type | Description |
| --------- | ------ | -------------------------------- |
| `id` | `uuid` | The UUID of the hold to update. |
#### Request Body
All fields are optional. At least one must be provided.
| Field | Type | Description |
| ---------- | --------- | -------------------------------------------------- |
| `name` | `string` | New hold name. Max 255 characters. |
| `reason` | `string` | Updated reason/description. Max 2 000 characters. |
| `isActive` | `boolean` | Set to `false` to deactivate, `true` to reactivate. |
#### Example — Deactivate a Hold
```json
{
"isActive": false
}
```
#### Response
- **`200 OK`** — Returns the updated hold object.
- **`404 Not Found`** — Hold with the given ID does not exist.
- **`422 Unprocessable Entity`** — Validation errors.
> **Important:** Setting `isActive` to `false` immediately lifts deletion immunity from all emails solely protected by this hold. The next lifecycle worker cycle will evaluate those emails against retention labels and policies.
---
### Delete Hold
Permanently deletes a legal hold and (via database CASCADE) all associated `email_legal_holds` rows.
- **Endpoint:** `DELETE /holds/:id`
- **Method:** `DELETE`
- **Authentication:** Required
- **Permission:** `manage:all`
#### Path Parameters
| Parameter | Type | Description |
| --------- | ------ | -------------------------------- |
| `id` | `uuid` | The UUID of the hold to delete. |
#### Response
- **`204 No Content`** — Hold successfully deleted.
- **`404 Not Found`** — Hold with the given ID does not exist.
- **`409 Conflict`** — The hold is currently active. Deactivate it first by calling `PUT /holds/:id` with `{ "isActive": false }`.
> **Security note:** Active holds cannot be deleted. This requirement forces an explicit, auditable deactivation step before the hold record is removed.
---
## Bulk Operations
### Bulk Apply Hold via Search Query
Applies a legal hold to **all emails matching a Meilisearch query**. The operation is asynchronous-safe: the UI fires the request and the server processes results in pages of 1 000, so even very large result sets do not time out.
- **Endpoint:** `POST /holds/:id/bulk-apply`
- **Method:** `POST`
- **Authentication:** Required
- **Permission:** `manage:all`
#### Path Parameters
| Parameter | Type | Description |
| --------- | ------ | ----------------------------------- |
| `id` | `uuid` | The UUID of the hold to apply. |
#### Request Body
| Field | Type | Required | Description |
| ------------- | -------- | -------- | ------------------------------------------------------------------- |
| `searchQuery` | `object` | Yes | A Meilisearch query object (see structure below). |
##### `searchQuery` Object
| Field | Type | Required | Description |
| ------------------ | -------- | -------- | ------------------------------------------------------------------ |
| `query` | `string` | Yes | Full-text search string. Pass `""` to match all documents. |
| `filters` | `object` | No | Key-value filter object (e.g., `{ "from": "user@corp.com" }`). |
| `matchingStrategy` | `string` | No | Meilisearch matching strategy: `"last"`, `"all"`, or `"frequency"`. |
#### Example Request
```json
{
"searchQuery": {
"query": "Project Titan confidential",
"filters": {
"from": "john.doe@acme.com",
"startDate": "2023-01-01",
"endDate": "2025-12-31"
},
"matchingStrategy": "all"
}
}
```
#### Response Body
```json
{
"legalHoldId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"emailsLinked": 1247,
"queryUsed": {
"query": "Project Titan confidential",
"filters": {
"from": "john.doe@acme.com",
"startDate": "2023-01-01",
"endDate": "2025-12-31"
},
"matchingStrategy": "all"
}
}
```
- `emailsLinked` — The number of emails **newly** linked to the hold by this operation. Emails already linked to this hold are not counted.
- `queryUsed` — The exact query JSON that was executed, mirroring what was written to the audit log for GoBD proof of scope.
#### Response Codes
- **`200 OK`** — Operation completed. Returns `emailsLinked: 0` if no new emails matched.
- **`404 Not Found`** — Hold with the given ID does not exist.
- **`409 Conflict`** — The hold is inactive. Only active holds can receive new email links.
- **`422 Unprocessable Entity`** — Invalid request body.
---
### Release All Emails from Hold
Removes all `email_legal_holds` associations for the given hold in a single operation. The hold itself is **not** deleted.
- **Endpoint:** `POST /holds/:id/release-all`
- **Method:** `POST`
- **Authentication:** Required
- **Permission:** `manage:all`
#### Path Parameters
| Parameter | Type | Description |
| --------- | ------ | ------------------------------------ |
| `id` | `uuid` | The UUID of the hold to release. |
#### Response Body
```json
{
"emailsReleased": 4821
}
```
#### Response Codes
- **`200 OK`** — All email associations removed. Returns `emailsReleased: 0` if the hold had no linked emails.
- **`500 Internal Server Error`** — The hold ID was not found or a database error occurred.
> **Warning:** After release, emails that were solely protected by this hold will be evaluated normally on the next lifecycle worker cycle. Emails with expired retention periods will be deleted.
---
## Per-Email Hold Endpoints
### Get Holds Applied to an Email
Returns all legal holds currently linked to a specific archived email, including both active and inactive holds.
- **Endpoint:** `GET /email/:emailId/holds`
- **Method:** `GET`
- **Authentication:** Required
- **Permission:** `read:archive`
#### Path Parameters
| Parameter | Type | Description |
| --------- | ------ | ---------------------------------------- |
| `emailId` | `uuid` | The UUID of the archived email. |
#### Response Body
Returns an empty array `[]` if no holds are applied, or an array of hold-link objects:
```json
[
{
"legalHoldId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"holdName": "Project Titan Litigation — 2026",
"isActive": true,
"appliedAt": "2026-01-15T11:00:00.000Z",
"appliedByUserId": "user-uuid-here"
},
{
"legalHoldId": "b2c3d4e5-f6a7-8901-bcde-f23456789012",
"holdName": "SEC Investigation Q3 2025",
"isActive": false,
"appliedAt": "2025-09-05T09:15:00.000Z",
"appliedByUserId": null
}
]
```
#### Response Codes
- **`200 OK`** — Returns the array of hold-link objects (may be empty).
---
### Apply a Hold to a Specific Email
Links a single archived email to an active legal hold. The operation is idempotent — linking the same email to the same hold twice has no effect.
- **Endpoint:** `POST /email/:emailId/holds`
- **Method:** `POST`
- **Authentication:** Required
- **Permission:** `manage:all`
#### Path Parameters
| Parameter | Type | Description |
| --------- | ------ | ---------------------------------------- |
| `emailId` | `uuid` | The UUID of the archived email. |
#### Request Body
| Field | Type | Required | Description |
| -------- | ------ | -------- | ------------------------------------ |
| `holdId` | `uuid` | Yes | The UUID of the hold to apply. |
#### Example Request
```json
{
"holdId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
```
#### Response Body
Returns the hold-link object with the DB-authoritative `appliedAt` timestamp:
```json
{
"legalHoldId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"holdName": "Project Titan Litigation — 2026",
"isActive": true,
"appliedAt": "2026-01-16T14:22:00.000Z",
"appliedByUserId": "user-uuid-here"
}
```
#### Response Codes
- **`200 OK`** — Hold successfully applied (or was already applied — idempotent).
- **`404 Not Found`** — Email or hold not found.
- **`409 Conflict`** — The hold is inactive and cannot be applied to new emails.
- **`422 Unprocessable Entity`** — Invalid request body.
---
### Remove a Hold from a Specific Email
Unlinks a specific legal hold from a specific archived email. The hold itself is not modified; other emails linked to the same hold are unaffected.
- **Endpoint:** `DELETE /email/:emailId/holds/:holdId`
- **Method:** `DELETE`
- **Authentication:** Required
- **Permission:** `manage:all`
#### Path Parameters
| Parameter | Type | Description |
| --------- | ------ | ---------------------------------------- |
| `emailId` | `uuid` | The UUID of the archived email. |
| `holdId` | `uuid` | The UUID of the hold to remove. |
#### Response Body
```json
{
"message": "Hold removed from email successfully."
}
```
#### Response Codes
- **`200 OK`** — Hold link removed.
- **`404 Not Found`** — No such hold was applied to this email.
---
## Error Responses
All endpoints use the standard error response format:
```json
{
"status": "error",
"statusCode": 409,
"message": "Cannot delete an active legal hold. Deactivate it first to explicitly lift legal protection before deletion.",
"errors": null
}
```
For validation errors (`422 Unprocessable Entity`):
```json
{
"status": "error",
"statusCode": 422,
"message": "Invalid input provided.",
"errors": [
{
"field": "name",
"message": "Name is required."
}
]
}
```
---
## Validation Constraints
| Field | Constraint |
| -------------- | ----------------------------------------------- |
| Hold name | 1255 characters. |
| Reason | Max 2 000 characters. |
| `caseId` | Must be a valid UUID if provided. |
| `holdId` | Must be a valid UUID. |
| `emailId` | Must be a valid UUID. |
| Search `query` | String (may be empty `""`). |
| `matchingStrategy` | One of `"last"`, `"all"`, `"frequency"`. |

View File

@@ -0,0 +1,157 @@
# Legal Holds: User Interface Guide
The legal holds management interface is located at **Dashboard → Compliance → Legal Holds**. It provides a complete view of all configured holds and tools for creating, applying, releasing, and deactivating them. Per-email hold controls are also available on each archived email's detail page.
## Overview
Legal holds suspend all automated and manual deletion for specific emails, regardless of any retention labels or policies that might otherwise govern them. They are the highest-priority mechanism in the data lifecycle and are intended for use by compliance officers and legal counsel responding to litigation, investigations, or audit requests.
## Holds Table
The main page displays a table of all legal holds with the following columns:
- **Name:** The hold name and its UUID displayed underneath for reference.
- **Reason:** A short excerpt of the hold's reason/description. Shows _"No reason provided"_ if omitted.
- **Emails:** A badge showing how many archived emails are currently linked to this hold.
- **Status:** A badge indicating whether the hold is:
- **Active** (red badge): The hold is currently granting deletion immunity to linked emails.
- **Inactive** (gray badge): The hold is deactivated; linked emails are no longer immune.
- **Created At:** The date the hold was created, in local date format.
- **Actions:** Dropdown menu with options depending on the hold's state (see below).
The table is sorted by creation date in ascending order.
## Creating a Hold
Click the **"Create New"** button above the table to open the creation dialog. New holds are always created in the **Active** state.
### Form Fields
- **Name** (Required): A unique, descriptive name. Maximum 255 characters.
Examples: `"Project Titan Litigation — 2026"`, `"SEC Investigation Q3 2025"`
- **Reason** (Optional): A free-text description of the legal basis for the hold. Maximum 2 000 characters. This appears in the audit log and is visible to other compliance officers.
### After Creation
The hold immediately becomes active. No emails are linked to it yet — use Bulk Apply or the individual email detail page to add emails.
## Editing a Hold
Click **Edit** from the actions dropdown to modify the hold's name or reason. The `isActive` state is changed separately via the **Activate / Deactivate** action.
## Activating and Deactivating a Hold
The **Deactivate** / **Activate** option appears inline in the actions dropdown. Changing the active state does not remove any email links — it only determines whether those links grant deletion immunity.
> **Important:** Deactivating a hold means that all emails linked *solely* to this hold lose their deletion immunity immediately. If any such emails have an expired retention period, they will be permanently deleted on the very next lifecycle worker cycle.
## Deleting a Hold
A hold **cannot be deleted while it is active**. Attempting to delete an active hold returns a `409 Conflict` error with the message: _"Cannot delete an active legal hold. Deactivate it first..."_
To delete a hold:
1. **Deactivate** it first using the Activate/Deactivate action.
2. Click **Delete** from the actions dropdown.
3. Confirm in the dialog.
Deletion permanently removes the hold record and, via database CASCADE, all `email_legal_holds` link rows. The emails themselves are not deleted — they simply lose the protection that this hold was providing. Any other active holds on those emails continue to protect them.
## Bulk Apply
The **Bulk Apply** option (available only on active holds) opens a search dialog that lets you cast a preservation net across potentially thousands of emails in a single operation.
### Search Fields
- **Full-text query:** Keywords to match against email subject, body, and attachment content. This uses Meilisearch's full-text engine with typo tolerance.
- **From (sender):** Filter by sender email address.
- **Start date / End date:** Filter by the date range of the email's `sentAt` field.
At least one of these fields must be filled before the **Apply Hold** button becomes enabled.
### What Happens During Bulk Apply
1. The system pages through all Meilisearch results matching the query (1 000 hits per page).
2. Each hit's email ID is validated against the database to discard any stale index entries.
3. New hold links are inserted in batches of 500. Emails already linked to this hold are skipped (idempotent).
4. A success notification shows **how many emails were newly placed under the hold** (already-protected emails are not counted again).
5. The exact search query JSON is written to the audit log as GoBD proof of the scope of protection.
> **Warning:** Bulk Apply is a wide-net operation. Review your query carefully — there is no per-email confirmation step. Use the search page first to preview results before applying.
### Bulk Apply and the Audit Log
The audit log entry for a bulk apply contains:
- `action: "BulkApplyHold"`
- `searchQuery`: the exact JSON query used
- `emailsLinked`: number of emails newly linked
- `emailsAlreadyProtected`: number of emails that were already under this hold
## Release All Emails
The **Release All** option (available when the hold has at least one linked email) removes every `email_legal_holds` link for this hold in a single operation.
> **Warning:** This immediately lifts deletion immunity for all emails that were solely protected by this hold. Emails with expired retention periods will be deleted on the next lifecycle worker cycle.
A confirmation dialog is shown before the operation proceeds. On success, a notification reports how many email links were removed.
## Per-Email Hold Controls
### Viewing Holds on a Specific Email
On any archived email's detail page, the **Legal Holds** card lists all holds currently applied to that email, showing:
- Hold name and active/inactive badge
- Date the hold was applied
### Applying a Hold to a Specific Email
In the Legal Holds card, a dropdown lists all currently **active** holds. Select a hold and click **Apply**. The operation is idempotent — applying the same hold twice has no effect.
### Removing a Hold from a Specific Email
Each linked hold in the card has a **Remove** button. Clicking it removes only the link between this email and that specific hold. The hold itself remains and continues to protect other emails.
> **Note:** Removing the last active hold from an email means the email is no longer immune. If its retention period has expired, it will be deleted on the next lifecycle worker cycle.
### Delete Button Behaviour Under a Hold
The **Delete Email** button on the email detail page is not disabled in the UI, but the backend will reject the request if the email is under an active hold. An error toast is displayed: _"Deletion blocked by retention policy (Legal Hold or similar)."_
## Permissions Reference
| Operation | Required Permission |
| -------------------------------- | ------------------- |
| View holds table | `manage:all` |
| Create / edit / delete a hold | `manage:all` |
| Activate / deactivate a hold | `manage:all` |
| Bulk apply | `manage:all` |
| Release all emails from a hold | `manage:all` |
| View holds on a specific email | `read:archive` |
| Apply / remove a hold from email | `manage:all` |
## Workflow: Responding to a Litigation Notice
1. **Receive the litigation notice.** Identify the relevant custodians, date range, and keywords.
2. **Create a hold**: Navigate to Dashboard → Compliance → Legal Holds and click **Create New**. Name it descriptively (e.g., `"Doe v. Acme Corp — 2026"`). Add the legal matter reference as the reason.
3. **Bulk apply**: Click **Bulk Apply** on the new hold. Enter keywords, the custodian's email address in the **From** field, and the relevant date range. Submit.
4. **Verify**: Check the email count badge on the hold row. Review the audit log to confirm the search query was recorded.
5. **Individual additions**: If specific emails not captured by the bulk query need to be preserved, open each email's detail page and apply the hold manually.
6. **When the matter concludes**: Click **Deactivate** on the hold, then **Release All** to remove all email links, and finally **Delete** the hold record if desired.
## Troubleshooting
### Cannot Delete Hold — "Cannot delete an active legal hold"
**Cause:** The hold is still active.
**Solution:** Use the **Deactivate** option from the actions dropdown first.
### Bulk Apply Returns 0 Emails
**Cause 1:** The search query matched no documents in the Meilisearch index.
**Solution:** Verify the query in the main Search page to preview results before applying.
**Cause 2:** All Meilisearch results were stale (emails deleted from the archive before this operation).
**Solution:** This is a data state issue; the stale index entries will be cleaned up on the next index rebuild.
### Delete Email Returns an Error Instead of Deleting
**Cause:** The email is under one or more active legal holds.
**Solution:** This is expected behavior. Deactivate or remove the hold(s) from this email before deleting.
### Hold Emails Count Shows 0 After Bulk Apply
**Cause:** The `emailCount` field is fetched when the page loads. If the bulk operation was just completed, refresh the page to see the updated count.

View File

@@ -0,0 +1,125 @@
# Legal Holds
The Legal Holds feature is an enterprise-grade eDiscovery and compliance mechanism designed to prevent the spoliation (destruction) of evidence. It provides **absolute, unconditional immunity** from deletion for archived emails that are relevant to pending litigation, regulatory investigations, or audits.
## Core Principles
### 1. Absolute Immunity — Highest Precedence in the Lifecycle Pipeline
A legal hold is the final word on whether an email can be deleted. The [Lifecycle Worker](../retention-policy/lifecycle-worker.md) evaluates emails in a strict three-step precedence pipeline:
1. **Step 0 — Legal Hold** ← this feature
2. Step 1 — Retention Label
3. Step 2 — Retention Policy
If an email is linked to **at least one active** legal hold, the lifecycle worker immediately flags it as immune and stops evaluation. No retention label or policy can override this decision. The `RetentionHook` mechanism also blocks any **manual deletion** attempt from the UI — the backend will return an error before any `DELETE` SQL is issued.
### 2. Many-to-Many Relationship
A single email can be placed under multiple holds simultaneously (e.g., one hold for a litigation case and another for a regulatory investigation). The email remains immune as long as **any one** of those holds is active. Each hold-to-email link is recorded independently with its own `appliedAt` timestamp and actor attribution.
### 3. Active/Inactive State Management
Every hold has an `isActive` flag. When a legal matter concludes, the responsible officer deactivates the hold. The deactivation is instantaneous — on the very next lifecycle worker cycle, emails that were solely protected by that hold will be evaluated normally against retention labels and policies. If their retention period has already expired, they will be permanently deleted in that same cycle.
A hold **must be deactivated before it can be deleted**. This requirement forces an explicit, auditable act of lifting legal protection before the hold record can be removed from the system.
### 4. Bulk Preservation via Search Queries
The primary use case for legal holds is casting a wide preservation net quickly. The bulk-apply operation accepts a full Meilisearch query (full-text search + metadata filters such as sender, date range, etc.) and links every matching email to the hold in a single operation. The system pages through results in batches of 1 000 to handle datasets of any size without timing out the UI.
### 5. GoBD Audit Trail
Every action within the legal hold module — hold creation, modification, deactivation, deletion, email linkage, email removal, and bulk operations — is immutably recorded in the cryptographically chained `audit_logs` table. For bulk operations, the exact `SearchQuery` JSON used to cast the hold net is persisted in the audit log as proof of scope, satisfying GoBD and similar evidence-preservation requirements.
## Feature Requirements
The Legal Holds feature requires:
- An active **Enterprise license** with the `LEGAL_HOLDS` feature enabled.
- The `manage:all` permission for all hold management and bulk operations.
- The `read:archive` permission for viewing holds applied to a specific email.
- The `manage:all` permission for applying or removing a hold from an individual email.
## Use Cases
### Active Litigation Hold
Upon receiving a litigation notice, a compliance officer creates a hold named "Project Titan Litigation — 2026", applies it via a bulk query scoped to a specific custodian's emails and a date range, and immediately freezes those records. The audit log provides timestamped proof that the hold was in place from the moment of creation.
### Regulatory Investigation
A regulator requests preservation of all finance-related communications from a specific period. The officer creates a hold and uses a keyword + date-range bulk query to capture every relevant email in seconds, regardless of which users sent or received them.
### Tax Audit
Before an annual audit window, an officer applies a hold to all emails matching tax-relevant keywords. The hold is released once the audit concludes, and standard retention policies resume.
### eDiscovery Case Management
Holds can optionally be linked to an `ediscovery_cases` record (`caseId` field) to organise multiple holds under a single legal matter. This allows all holds, emails, and audit events for a case to be referenced together.
## Architecture Overview
| Component | Location | Description |
| --------------- | ------------------------------------------------------------------------------ | -------------------------------------------------------------- |
| Types | `packages/types/src/retention.types.ts` | `LegalHold`, `EmailLegalHoldInfo`, `BulkApplyHoldResult` types |
| Database Schema | `packages/backend/src/database/schema/compliance.ts` | `legal_holds` and `email_legal_holds` table definitions |
| Service | `packages/enterprise/src/modules/legal-holds/LegalHoldService.ts` | All business logic for CRUD, linkage, and bulk operations |
| Controller | `packages/enterprise/src/modules/legal-holds/legal-hold.controller.ts` | Express request handlers with Zod validation |
| Routes | `packages/enterprise/src/modules/legal-holds/legal-hold.routes.ts` | Route registration with auth and feature guards |
| Module | `packages/enterprise/src/modules/legal-holds/legal-hold.module.ts` | App-startup integration and `RetentionHook` registration |
| Frontend Page | `packages/frontend/src/routes/dashboard/compliance/legal-holds/` | SvelteKit management page for holds |
| Email Detail | `packages/frontend/src/routes/dashboard/archived-emails/[id]/` | Per-email hold card in the email detail view |
| Lifecycle Guard | `packages/backend/src/hooks/RetentionHook.ts` | Static hook that blocks deletion if a hold is active |
## Data Model
### `legal_holds` Table
| Column | Type | Description |
| ------------ | -------------- | --------------------------------------------------------------------------- |
| `id` | `uuid` (PK) | Auto-generated unique identifier. |
| `name` | `varchar(255)` | Human-readable hold name. |
| `reason` | `text` | Optional description of why the hold was placed. |
| `is_active` | `boolean` | Whether the hold currently grants immunity. Defaults to `true` on creation. |
| `case_id` | `uuid` (FK) | Optional reference to an `ediscovery_cases` row. |
| `created_at` | `timestamptz` | Hold creation timestamp. |
| `updated_at` | `timestamptz` | Last modification timestamp. |
### `email_legal_holds` Join Table
| Column | Type | Description |
| --------------------- | ------------- | --------------------------------------------------------------- |
| `email_id` | `uuid` (FK) | Reference to `archived_emails.id`. Cascades on delete. |
| `legal_hold_id` | `uuid` (FK) | Reference to `legal_holds.id`. Cascades on delete. |
| `applied_at` | `timestamptz` | DB-server timestamp of when the link was created. |
| `applied_by_user_id` | `uuid` (FK) | User who applied the hold (nullable for system operations). |
The table uses a composite primary key of `(email_id, legal_hold_id)`, enforcing uniqueness at the database level. Duplicate inserts use `ON CONFLICT DO NOTHING` for idempotency.
## Integration Points
### RetentionHook (Deletion Guard)
`LegalHoldModule.initialize()` registers an async check with `RetentionHook` at application startup. `ArchivedEmailService.deleteArchivedEmail()` calls `RetentionHook.canDelete(emailId)` before any storage or database DELETE. If the email is under an active hold, the hook returns `false` and deletion is aborted with a `400 Bad Request` error. This guard is fail-safe: if the hook itself throws an error, deletion is also blocked.
### Lifecycle Worker
The lifecycle worker calls `legalHoldService.isEmailUnderActiveHold(emailId)` as the first step in its per-email evaluation loop. Immune emails are skipped immediately with a `debug`-level log entry; no further evaluation occurs.
### Audit Log
All legal hold operations generate entries in `audit_logs`:
| Action | `actionType` | `targetType` | `targetId` |
| -------------------------------- | ------------ | --------------- | ----------------- |
| Hold created | `CREATE` | `LegalHold` | hold ID |
| Hold updated / deactivated | `UPDATE` | `LegalHold` | hold ID |
| Hold deleted | `DELETE` | `LegalHold` | hold ID |
| Email linked to hold (individual)| `UPDATE` | `ArchivedEmail` | email ID |
| Email unlinked from hold | `UPDATE` | `ArchivedEmail` | email ID |
| Bulk apply via search | `UPDATE` | `LegalHold` | hold ID + query JSON |
| All emails released from hold | `UPDATE` | `LegalHold` | hold ID |
Individual email link/unlink events target `ArchivedEmail` so that a per-email audit search surfaces the complete hold history for that email.

View File

@@ -0,0 +1,360 @@
# Retention Labels: API Endpoints
The retention labels feature exposes a RESTful API for managing retention labels and applying them to individual archived emails. All endpoints require authentication and appropriate permissions as specified below.
**Base URL:** `/api/v1/enterprise/retention-policy`
All endpoints also require the `RETENTION_POLICY` feature to be enabled in the enterprise license.
---
## Label Management Endpoints
### List All Labels
Retrieves all retention labels, ordered by creation date ascending.
- **Endpoint:** `GET /labels`
- **Method:** `GET`
- **Authentication:** Required
- **Permission:** `manage:all`
#### Response Body
```json
[
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"name": "Legal Hold - Litigation ABC",
"description": "Extended retention for emails related to litigation ABC vs Company",
"retentionPeriodDays": 2555,
"isDisabled": false,
"createdAt": "2025-10-01T00:00:00.000Z"
},
{
"id": "b2c3d4e5-f6a7-8901-bcde-f23456789012",
"name": "Executive Communications",
"description": null,
"retentionPeriodDays": 3650,
"isDisabled": true,
"createdAt": "2025-09-15T12:30:00.000Z"
}
]
```
---
### Get Label by ID
Retrieves a single retention label by its UUID.
- **Endpoint:** `GET /labels/:id`
- **Method:** `GET`
- **Authentication:** Required
- **Permission:** `manage:all`
#### Path Parameters
| Parameter | Type | Description |
| --------- | ------ | ------------------------------ |
| `id` | `uuid` | The UUID of the label to get. |
#### Response Body
Returns a single label object (same shape as the list endpoint), or `404` if not found.
---
### Create Label
Creates a new retention label. The label name must be unique across the system.
- **Endpoint:** `POST /labels`
- **Method:** `POST`
- **Authentication:** Required
- **Permission:** `manage:all`
#### Request Body
| Field | Type | Required | Description |
| -------------------- | --------- | -------- | -------------------------------------------------------------- |
| `name` | `string` | Yes | Unique label name. Max 255 characters. |
| `description` | `string` | No | Human-readable description. Max 1000 characters. |
| `retentionPeriodDays` | `integer` | Yes | Number of days to retain emails with this label. Minimum 1. |
#### Example Request
```json
{
"name": "Financial Records - Q4 2025",
"description": "Extended retention for Q4 2025 financial correspondence per regulatory requirements",
"retentionPeriodDays": 2555
}
```
#### Response
- **`201 Created`** — Returns the created label object.
- **`409 Conflict`** — A label with this name already exists.
- **`422 Unprocessable Entity`** — Validation errors.
---
### Update Label
Updates an existing retention label. Only the fields included in the request body are modified.
- **Endpoint:** `PUT /labels/:id`
- **Method:** `PUT`
- **Authentication:** Required
- **Permission:** `manage:all`
#### Path Parameters
| Parameter | Type | Description |
| --------- | ------ | --------------------------------- |
| `id` | `uuid` | The UUID of the label to update. |
#### Request Body
All fields from the create endpoint are accepted, and all are optional. Only provided fields are updated.
**Important:** The `retentionPeriodDays` field cannot be modified if the label is currently applied to any emails. Attempting to do so will return a `409 Conflict` error.
#### Example Request
```json
{
"name": "Financial Records - Q4 2025 (Updated)",
"description": "Updated description for Q4 2025 financial records retention"
}
```
#### Response
- **`200 OK`** — Returns the updated label object.
- **`404 Not Found`** — Label with the given ID does not exist.
- **`409 Conflict`** — Attempted to modify retention period while label is applied to emails.
- **`422 Unprocessable Entity`** — Validation errors.
---
### Delete Label
Deletes or disables a retention label depending on its usage status.
- **Endpoint:** `DELETE /labels/:id`
- **Method:** `DELETE`
- **Authentication:** Required
- **Permission:** `manage:all`
#### Path Parameters
| Parameter | Type | Description |
| --------- | ------ | --------------------------------- |
| `id` | `uuid` | The UUID of the label to delete. |
#### Deletion Logic
- **Hard Delete**: If the label has never been applied to any emails, it is permanently removed.
- **Soft Disable**: If the label is currently applied to one or more emails, it is marked as `isDisabled = true` instead of being deleted. This preserves the retention clock for tagged emails while preventing new applications.
#### Response Body
```json
{
"action": "deleted"
}
```
or
```json
{
"action": "disabled"
}
```
#### Response Codes
- **`200 OK`** — Label successfully deleted or disabled. Check the `action` field in the response body.
- **`404 Not Found`** — Label with the given ID does not exist.
---
## Email Label Endpoints
### Get Email's Label
Retrieves the retention label currently applied to a specific archived email.
- **Endpoint:** `GET /email/:emailId/label`
- **Method:** `GET`
- **Authentication:** Required
- **Permission:** `read:archive`
#### Path Parameters
| Parameter | Type | Description |
| --------- | ------ | ------------------------------------- |
| `emailId` | `uuid` | The UUID of the archived email. |
#### Response Body
Returns `null` if no label is applied:
```json
null
```
Or the label information if a label is applied:
```json
{
"labelId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"labelName": "Legal Hold - Litigation ABC",
"retentionPeriodDays": 2555,
"appliedAt": "2025-10-15T14:30:00.000Z",
"appliedByUserId": "user123"
}
```
#### Response Codes
- **`200 OK`** — Returns label information or `null`.
- **`500 Internal Server Error`** — Server error during processing.
---
### Apply Label to Email
Applies a retention label to an archived email. If the email already has a label, the existing label is replaced.
- **Endpoint:** `POST /email/:emailId/label`
- **Method:** `POST`
- **Authentication:** Required
- **Permission:** `delete:archive`
#### Path Parameters
| Parameter | Type | Description |
| --------- | ------ | ------------------------------------- |
| `emailId` | `uuid` | The UUID of the archived email. |
#### Request Body
| Field | Type | Required | Description |
| --------- | ------ | -------- | ------------------------------------ |
| `labelId` | `uuid` | Yes | The UUID of the label to apply. |
#### Example Request
```json
{
"labelId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
```
#### Response Body
```json
{
"labelId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"labelName": "Legal Hold - Litigation ABC",
"retentionPeriodDays": 2555,
"appliedAt": "2025-10-15T14:30:00.000Z",
"appliedByUserId": "user123"
}
```
#### Response Codes
- **`200 OK`** — Label successfully applied.
- **`404 Not Found`** — Email or label not found.
- **`409 Conflict`** — Attempted to apply a disabled label.
- **`422 Unprocessable Entity`** — Invalid request body.
---
### Remove Label from Email
Removes the retention label from an archived email if one is applied.
- **Endpoint:** `DELETE /email/:emailId/label`
- **Method:** `DELETE`
- **Authentication:** Required
- **Permission:** `delete:archive`
#### Path Parameters
| Parameter | Type | Description |
| --------- | ------ | ------------------------------------- |
| `emailId` | `uuid` | The UUID of the archived email. |
#### Response Body
If a label was removed:
```json
{
"message": "Label removed successfully."
}
```
If no label was applied:
```json
{
"message": "No label was applied to this email."
}
```
#### Response Codes
- **`200 OK`** — Operation completed (regardless of whether a label was actually removed).
- **`500 Internal Server Error`** — Server error during processing.
---
## Error Responses
All endpoints use the standard error response format:
```json
{
"status": "error",
"statusCode": 404,
"message": "The requested resource could not be found.",
"errors": null
}
```
For validation errors (`422 Unprocessable Entity`):
```json
{
"status": "error",
"statusCode": 422,
"message": "Invalid input provided.",
"errors": [
{
"field": "name",
"message": "Name is required."
},
{
"field": "retentionPeriodDays",
"message": "Retention period must be at least 1 day."
}
]
}
```
## Validation Constraints
| Field | Constraint |
| -------------------- | --------------------------------------------- |
| Label name | 1255 characters, must be unique. |
| Description | Max 1000 characters. |
| Retention period | Positive integer (≥ 1 day). |
| Label ID (UUID) | Must be a valid UUID format. |
| Email ID (UUID) | Must be a valid UUID format. |

View File

@@ -0,0 +1,229 @@
# Retention Labels: Automated Application Guide
This guide explains how to use the API to automatically apply retention labels to archived emails, enabling automated compliance and retention management workflows.
## Overview
Automated retention label application allows external systems and services to programmatically tag emails with appropriate retention labels based on content analysis, business rules, or regulatory requirements. This eliminates manual tagging for large volumes of emails while ensuring consistent retention policy enforcement.
## Common Use Cases
### 1. Financial Document Classification
**Scenario**: Automatically identify and tag financial documents (invoices, receipts, payment confirmations) with extended retention periods for regulatory compliance.
**Implementation**:
- Monitor newly ingested emails for financial keywords in subject lines or attachment names
- Apply "Financial Records" label (typically 7+ years retention) to matching emails
- Use content analysis to identify financial document types
### 2. Legal and Compliance Tagging
**Scenario**: Apply legal hold labels to emails related to ongoing litigation or regulatory investigations.
**Implementation**:
- Scan emails for legal-related keywords or specific case references
- Tag emails from/to legal departments with "Legal Hold" labels
- Apply extended retention periods to preserve evidence
### 3. Executive Communication Preservation
**Scenario**: Ensure important communications involving executive leadership are retained beyond standard policies.
**Implementation**:
- Identify emails from C-level executives (CEO, CFO, CTO, etc.)
- Apply "Executive Communications" labels with extended retention
- Preserve strategic business communications for historical reference
### 4. Data Classification Integration
**Scenario**: Integrate with existing data classification systems to apply retention labels based on content sensitivity.
**Implementation**:
- Use AI/ML classification results to determine retention requirements
- Apply labels like "Confidential", "Public", or "Restricted" with appropriate retention periods
- Automate compliance with data protection regulations
### 5. Project-Based Retention
**Scenario**: Apply specific retention periods to emails related to particular projects or contracts.
**Implementation**:
- Identify project-related emails using subject line patterns or participant lists
- Tag with project-specific labels (e.g., "Project Alpha - 5 Year Retention")
- Ensure project documentation meets contractual retention requirements
## API Workflow
### Step 1: Authentication Setup
Create an API key with appropriate permissions:
- Navigate to **Dashboard → Admin → Roles/Users**
- Create a user with `read:archive` and `delete:archive` permissions (minimum required)
- Generate an API for the newly created user
- Securely store the API key for use in automated systems
### Step 2: Identify Target Emails
Use the archived emails API to find emails that need labeling:
**Get Recent Emails**:
```
GET /api/v1/archived-emails?limit=100&sort=archivedAt:desc
```
**Search for Specific Emails**:
```
GET /api/v1/archived-emails/search?query=subject:invoice&limit=50
```
### Step 3: Check Current Label Status
Before applying a new label, verify the email's current state:
**Check Email Label**:
```
GET /api/v1/enterprise/retention-policy/email/{emailId}/label
```
This returns `null` if no label is applied, or the current label information if one exists.
### Step 4: Apply Retention Label
Apply the appropriate label to the email:
**Apply Label**:
```
POST /api/v1/enterprise/retention-policy/email/{emailId}/label
Content-Type: application/json
{
"labelId": "your-label-uuid-here"
}
```
### Step 5: Verify Application
Confirm the label was successfully applied by checking the response or making another GET request.
## Label Management
### Getting Available Labels
List all available retention labels to identify which ones to use:
```
GET /api/v1/enterprise/retention-policy/labels
```
This returns all labels with their IDs, names, retention periods, and status (enabled/disabled).
### Label Selection Strategy
- **Pre-create labels** through the UI with appropriate names and retention periods
- **Map business rules** to specific label IDs in your automation logic
- **Cache label information** to avoid repeated API calls
- **Handle disabled labels** gracefully (they cannot be applied to new emails)
## Implementation Patterns
### Pattern 1: Post-Ingestion Processing
Apply labels after emails have been fully ingested and indexed:
1. Monitor for newly ingested emails (via webhooks or polling)
2. Analyze email content and metadata
3. Determine appropriate retention label based on business rules
4. Apply the label via API
### Pattern 2: Batch Processing
Process emails in scheduled batches:
1. Query for unlabeled emails periodically (daily/weekly)
2. Process emails in manageable batches (50-100 emails)
3. Apply classification logic and labels
4. Log results for audit and monitoring
### Pattern 3: Event-Driven Tagging
React to specific events or triggers:
1. Receive notification of specific events (legal hold notice, project start, etc.)
2. Search for relevant emails based on criteria
3. Apply appropriate labels to all matching emails
4. Document the mass labeling action
## Authentication and Security
### API Key Management
- **Use dedicated API keys** for automated systems (not user accounts)
- **Assign minimal required permissions** (`delete:archive` for label application)
- **Rotate API keys regularly** as part of security best practices
- **Store keys securely** using environment variables or secret management systems
### Request Authentication
Include the API key in all requests:
```
Authorization: Bearer your-api-key-here
Content-Type: application/json
```
## Error Handling
### Common Error Scenarios
- **404 Email Not Found**: The specified email ID doesn't exist
- **404 Label Not Found**: The label ID is invalid or label has been deleted
- **409 Conflict**: Attempting to apply a disabled label
- **422 Validation Error**: Invalid request format or missing required fields
### Best Practices
- **Check response status codes** and handle errors appropriately
- **Implement retry logic** for temporary failures (5xx errors)
- **Log all operations** for audit trails and debugging
- **Continue processing** other emails even if some fail
## Performance Considerations
### Rate Limiting
- **Process emails in batches** rather than individually when possible
- **Add delays between API calls** to avoid overwhelming the server
- **Monitor API response times** and adjust batch sizes accordingly
### Efficiency Tips
- **Cache label information** to reduce API calls
- **Check existing labels** before applying new ones to avoid unnecessary operations
- **Use search API** to filter emails rather than processing all emails
- **Implement incremental processing** to handle only new or modified emails
## Monitoring and Auditing
### Logging Recommendations
- **Log all label applications** with email ID, label ID, and timestamp
- **Track success/failure rates** for monitoring system health
- **Record business rule matches** for compliance reporting
### Audit Trail
All automated label applications are recorded in the system audit log with:
- Actor identified as the API key name
- Target email and applied label details
- Timestamp of the operation
This ensures full traceability of automated retention decisions.
## Integration Examples
### Scenario: Invoice Processing System
1. **Trigger**: New email arrives with invoice attachment
2. **Analysis**: System identifies invoice keywords or attachment types
3. **Action**: Apply "Financial Records - 7 Year" label via API
4. **Result**: Email retained for regulatory compliance period
### Scenario: Legal Hold Implementation
1. **Trigger**: Legal department issues hold notice for specific matter
2. **Search**: Find all emails matching case criteria (participants, keywords, date range)
3. **Action**: Apply "Legal Hold - Matter XYZ" label to all matching emails
4. **Result**: All relevant emails preserved indefinitely
### Scenario: Data Classification Integration
1. **Trigger**: Content classification system processes new emails
2. **Analysis**: ML system categorizes email as "Confidential Financial Data"
3. **Mapping**: Business rules map category to "Financial Confidential - 10 Year" label
4. **Action**: Apply label via API
5. **Result**: Automatic compliance with data retention policies
## Getting Started
1. **Set up authentication** by creating an API key with appropriate permissions
2. **Identify your use cases** and create corresponding retention labels through the UI
3. **Test the API** with a few sample emails to understand the workflow
4. **Implement your business logic** to identify which emails need which labels
5. **Deploy your automation** with proper error handling and monitoring
6. **Monitor results** and adjust your classification rules as needed
This automated approach ensures consistent retention policy enforcement while reducing manual administrative overhead.

View File

@@ -0,0 +1,206 @@
# Retention Labels: User Interface Guide
The retention labels management interface is located at **Dashboard → Compliance → Retention Labels**. It provides a comprehensive view of all configured labels and tools for creating, editing, deleting, and applying labels to individual archived emails.
## Overview
Retention labels provide item-level retention control, allowing administrators to override normal retention policies for specific emails with custom retention periods. This is particularly useful for legal holds, regulatory compliance, and preserving important business communications.
## Labels Table
The main page displays a table of all retention labels with the following columns:
- **Name:** The label name and its UUID displayed underneath for reference. If a description is provided, it appears below the name in smaller text.
- **Retention Period:** The number of days emails with this label are retained, displayed as "X days".
- **Status:** A badge indicating whether the label is:
- **Enabled** (green badge): The label can be applied to new emails
- **Disabled** (gray badge): The label cannot be applied to new emails but continues to govern already-labeled emails
- **Created At:** The date the label was created, displayed in local date format.
- **Actions:** Dropdown menu with Edit and Delete options for each label.
The table is sorted by creation date in ascending order by default.
## Creating a Label
Click the **"Create New"** button (with plus icon) above the table to open the creation dialog.
### Form Fields
- **Name** (Required): A unique, descriptive name for the label. Maximum 255 characters.
- **Description** (Optional): A detailed explanation of the label's purpose or usage. Maximum 1000 characters.
- **Retention Period (Days)** (Required): The number of days to retain emails with this label. Must be at least 1 day.
### Example Labels
- **Name:** "Legal Hold - Project Alpha"
**Description:** "Extended retention for emails related to ongoing litigation regarding Project Alpha intellectual property dispute"
**Retention Period:** 3650 days (10 years)
- **Name:** "Executive Communications"
**Description:** "Preserve important emails from C-level executives beyond normal retention periods"
**Retention Period:** 2555 days (7 years)
- **Name:** "Financial Records Q4 2025"
**Retention Period:** 2190 days (6 years)
### Success and Error Handling
- **Success**: The dialog closes and a green success notification appears confirming the label was created.
- **Name Conflict**: If a label with the same name already exists, an error notification will display.
- **Validation Errors**: Missing required fields or invalid values will show inline validation messages.
## Editing a Label
Click the **Edit** option from the actions dropdown on any label row to open the edit dialog.
### Editable Fields
- **Name**: Can always be modified (subject to uniqueness constraint)
- **Description**: Can always be modified
- **Retention Period**: Can only be modified if the label has never been applied to any emails
### Retention Period Restrictions
The edit dialog shows a warning message: "Retention period cannot be modified if this label is currently applied to emails." If you attempt to change the retention period for a label that's in use, the system will return a conflict error and display an appropriate error message.
This restriction prevents tampering with active retention schedules and ensures compliance integrity.
### Update Process
1. Modify the desired fields
2. Click **Save** to submit changes
3. The system validates the changes and updates the label
4. A success notification confirms the update
## Deleting a Label
Click the **Delete** option from the actions dropdown to open the deletion confirmation dialog.
### Smart Deletion Behavior
The system uses intelligent deletion logic:
#### Hard Delete
If the label has **never been applied** to any emails:
- The label is permanently removed from the system
- Success message: "Label deleted successfully"
#### Soft Disable
If the label is **currently applied** to one or more emails:
- The label is marked as "Disabled" instead of being deleted
- The label remains in the table with a "Disabled" status badge
- Existing emails keep their retention schedule based on this label
- The label cannot be applied to new emails
- Success message: "Label disabled successfully"
### Confirmation Dialog
The deletion dialog shows:
- **Title**: "Delete Retention Label"
- **Description**: Explains that this action cannot be undone and may disable the label if it's in use
- **Cancel** button to abort the operation
- **Confirm** button to proceed with deletion
## Applying Labels to Emails
Retention labels can be applied to individual archived emails through the email detail pages.
### From Email Detail Page
1. Navigate to an archived email by clicking on it from search results or the archived emails list
2. Look for the "Retention Label" section in the email metadata
3. If no label is applied, you'll see an "Apply Label" button (requires `delete:archive` permission)
4. If a label is already applied, you'll see:
- The current label name and retention period
- "Change Label" and "Remove Label" buttons
### Label Application Process
1. Click **"Apply Label"** or **"Change Label"**
2. A dropdown or dialog shows all available (enabled) labels
3. Select the desired label
4. Confirm the application
5. The system:
- Removes any existing label from the email
- Applies the new label
- Records the action in the audit log
- Updates the email's retention schedule
### One Label Per Email Rule
Each email can have at most one retention label. When you apply a new label to an email that already has a label, the previous label is automatically removed and replaced with the new one.
## Permissions Required
Different operations require different permission levels:
### Label Management
- **Create, Edit, Delete Labels**: Requires `manage:all` permission
- **View Labels Table**: Requires `manage:all` permission
### Email Label Operations
- **View Email Labels**: Requires `read:archive` permission
- **Apply/Remove Email Labels**: Requires `delete:archive` permission
## Status Indicators
### Enabled Labels (Green Badge)
- Can be applied to new emails
- Appears in label selection dropdowns
- Fully functional for all operations
### Disabled Labels (Gray Badge)
- Cannot be applied to new emails
- Does not appear in label selection dropdowns
- Continues to govern retention for already-labeled emails
- Can still be viewed and its details examined
- Results from attempting to delete a label that's currently in use
## Best Practices
### Naming Conventions
- Use descriptive names that indicate purpose: "Legal Hold - Case XYZ", "Executive - Q4 Review"
- Include time periods or case references where relevant
- Maintain consistent naming patterns across your organization
### Descriptions
- Always provide descriptions for complex or specialized labels
- Include the business reason or legal requirement driving the retention period
- Reference specific regulations, policies, or legal matters where applicable
### Retention Periods
- Consider your organization's legal and regulatory requirements
- Common periods:
- **3 years (1095 days)**: Standard business records
- **7 years (2555 days)**: Financial and tax records
- **10 years (3650 days)**: Legal holds and critical business documents
- **Permanent retention**: Use very large numbers (e.g., 36500 days = 100 years)
### Label Lifecycle
- Review labels periodically to identify unused or obsolete labels
- Disabled labels can accumulate over time - consider cleanup procedures
- Document the purpose and expected lifecycle of each label for future administrators
## Troubleshooting
### Cannot Edit Retention Period
**Problem**: Edit dialog shows retention period as locked or returns conflict error
**Cause**: The label is currently applied to one or more emails
**Solution**: Create a new label with the desired retention period instead of modifying the existing one
### Label Not Appearing in Email Application Dropdown
**Problem**: A label doesn't show up when trying to apply it to an email
**Cause**: The label is disabled
**Solution**: Check the labels table - disabled labels show a gray "Disabled" badge
### Cannot Delete Label
**Problem**: Deletion results in label being disabled instead of removed
**Cause**: The label is currently applied to emails
**Solution**: This is expected behavior to preserve retention integrity. The label can only be hard-deleted if it has never been used.
### Permission Denied Errors
**Problem**: Cannot access label management or apply labels to emails
**Cause**: Insufficient permissions
**Solution**: Contact your system administrator to verify you have the required permissions:
- `manage:all` for label management
- `delete:archive` for email label operations

View File

@@ -0,0 +1,117 @@
# Retention Labels
The Retention Labels feature is an enterprise-grade capability that provides item-level retention overrides for archived emails. Unlike retention policies which apply rules to groups of emails, retention labels are manually or programmatically applied to individual emails to override the normal retention lifecycle with specific retention periods.
## Core Principles
### 1. Item-Level Retention Override
Retention labels represent a specific, targeted retention requirement that takes precedence over any automated retention policies. When an email has a retention label applied, the label's `retentionPeriodDays` becomes the governing retention period for that email, regardless of what any retention policy would otherwise specify.
### 2. One Label Per Email
Each archived email can have at most one retention label applied at any time. Applying a new label to an email automatically removes any existing label, ensuring a clean, unambiguous retention state.
### 3. Deletion Behavior
Retention labels implement the following deletion logic:
- **Hard Delete**: If a label has never been applied to any emails, it can be completely removed from the system.
- **Soft Disable**: If a label is currently applied to one or more emails, deletion attempts result in the label being marked as `isDisabled = true`. This keeps the label-email relations but the retention label won't take effective.
- **Delete Disabled Labels**: If a label is currently applied to one or more emails, and it is disabled, a deletion request will delete the label itself and all label-email relations (remove the label from emails it is tagged with).
### 4. Immutable Retention Period
Once a retention label has been applied to any email, its `retentionPeriodDays` value becomes immutable to prevent tampering with active retention schedules. Labels can only have their retention period modified while they have zero applications.
### 5. User Attribution and Audit Trail
Every label application and removal is attributed to a specific user and recorded in the [Audit Log](../audit-log/index.md). This includes both manual UI actions and automated API operations, ensuring complete traceability of retention decisions.
### 6. Lifecycle Integration
The [Lifecycle Worker](../retention-policy/lifecycle-worker.md) gives retention labels the highest priority during email evaluation. If an email has a retention label applied, the label's retention period is used instead of any matching retention policy rules.
## Feature Requirements
The Retention Labels feature requires:
- An active **Enterprise license** with the `RETENTION_POLICY` feature enabled.
- The `manage:all` permission for administrative operations (creating, editing, deleting labels).
- The `delete:archive` permission for applying and removing labels from individual emails.
## Use Cases
### Legal Hold Alternative
Retention labels can serve as a lightweight alternative to formal legal holds by applying extended retention periods (e.g., 10+ years) to specific emails related to litigation or investigation.
### Executive Communications
Apply extended retention to emails from or to executive leadership to ensure important business communications are preserved beyond normal retention periods.
### Regulatory Exceptions
Mark specific emails that must be retained for regulatory compliance (e.g., financial records, safety incidents) with appropriate retention periods regardless of general policy rules.
### Project-Specific Retention
Apply custom retention periods to emails related to specific projects, contracts, or business initiatives that have unique preservation requirements.
## Architecture Overview
The feature is composed of the following components:
| Component | Location | Description |
| -------------------- | --------------------------------------------------------------------- | ------------------------------------------------------------ |
| Types | `packages/types/src/retention.types.ts` | Shared TypeScript types for labels and email label info. |
| Database Schema | `packages/backend/src/database/schema/compliance.ts` | Drizzle ORM table definitions for retention labels. |
| Label Service | `packages/enterprise/src/modules/retention-policy/RetentionLabelService.ts` | CRUD operations and label application logic. |
| API Controller | `packages/enterprise/src/modules/retention-policy/retention-label.controller.ts` | Express request handlers with Zod validation. |
| API Routes | `packages/enterprise/src/modules/retention-policy/retention-policy.routes.ts` | Route registration with auth and feature guards. |
| Frontend Page | `packages/frontend/src/routes/dashboard/compliance/retention-labels/` | SvelteKit page for label management. |
| Email Integration | Individual archived email pages | Label application UI in email detail views. |
## Data Model
### Retention Labels Table
| Column | Type | Description |
| -------------------- | ------------- | --------------------------------------------------------------- |
| `id` | `uuid` (PK) | Auto-generated unique identifier. |
| `name` | `varchar(255)` | Human-readable label name (unique constraint). |
| `retention_period_days` | `integer` | Number of days to retain emails with this label. |
| `description` | `text` | Optional description of the label's purpose. |
| `is_disabled` | `boolean` | Whether the label is disabled (cannot be applied to new emails). |
| `created_at` | `timestamptz` | Creation timestamp. |
### Email Label Applications Table
| Column | Type | Description |
| -------------------- | ------------- | --------------------------------------------------------------- |
| `email_id` | `uuid` (FK) | Reference to the archived email. |
| `label_id` | `uuid` (FK) | Reference to the retention label. |
| `applied_at` | `timestamptz` | Timestamp when the label was applied. |
| `applied_by_user_id` | `uuid` (FK) | User who applied the label (nullable for API key operations). |
The table uses a composite primary key of `(email_id, label_id)` to enforce the one-label-per-email constraint at the database level.
## Integration Points
### Lifecycle Worker
The lifecycle worker queries the `email_retention_labels` table during email evaluation. If an email has a retention label applied, the label's `retentionPeriodDays` takes precedence over any retention policy evaluation.
### Audit Log
All retention label operations generate audit log entries:
- **Label Creation**: Action type `CREATE`, target type `RetentionLabel`
- **Label Updates**: Action type `UPDATE`, target type `RetentionLabel`
- **Label Deletion/Disabling**: Action type `DELETE` or `UPDATE`, target type `RetentionLabel`
- **Label Application**: Action type `UPDATE`, target type `ArchivedEmail`, details include label information
- **Label Removal**: Action type `UPDATE`, target type `ArchivedEmail`, details include removed label information
### Email Detail Pages
Individual archived email pages display any applied retention label and provide controls for users with appropriate permissions to apply or remove labels.

View File

@@ -0,0 +1,267 @@
# Retention Policy: API Endpoints
The retention policy feature exposes a RESTful API for managing retention policies and simulating policy evaluation against email metadata. All endpoints require authentication and the `manage:all` permission.
**Base URL:** `/api/v1/enterprise/retention-policy`
All endpoints also require the `RETENTION_POLICY` feature to be enabled in the enterprise license.
---
## List All Policies
Retrieves all retention policies, ordered by priority ascending.
- **Endpoint:** `GET /policies`
- **Method:** `GET`
- **Authentication:** Required
- **Permission:** `manage:all`
### Response Body
```json
[
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"name": "Default 7-Year Retention",
"description": "Retain all emails for 7 years per regulatory requirements.",
"priority": 1,
"conditions": null,
"ingestionScope": null,
"retentionPeriodDays": 2555,
"isActive": true,
"createdAt": "2025-10-01T00:00:00.000Z",
"updatedAt": "2025-10-01T00:00:00.000Z"
}
]
```
---
## Get Policy by ID
Retrieves a single retention policy by its UUID.
- **Endpoint:** `GET /policies/:id`
- **Method:** `GET`
- **Authentication:** Required
- **Permission:** `manage:all`
### Path Parameters
| Parameter | Type | Description |
| --------- | ------ | ------------------------------ |
| `id` | `uuid` | The UUID of the policy to get. |
### Response Body
Returns a single policy object (same shape as the list endpoint), or `404` if not found.
---
## Create Policy
Creates a new retention policy. The policy name must be unique across the system.
- **Endpoint:** `POST /policies`
- **Method:** `POST`
- **Authentication:** Required
- **Permission:** `manage:all`
### Request Body
| Field | Type | Required | Description |
| ------------------- | --------------------- | -------- | ---------------------------------------------------------------------------------------------- |
| `name` | `string` | Yes | Unique policy name. Max 255 characters. |
| `description` | `string` | No | Human-readable description. Max 1000 characters. |
| `priority` | `integer` | Yes | Positive integer. Lower values indicate higher priority. |
| `retentionPeriodDays` | `integer` | Yes | Number of days to retain matching emails. Minimum 1. |
| `actionOnExpiry` | `string` | Yes | Action to take when the retention period expires. Currently only `"delete_permanently"`. |
| `isEnabled` | `boolean` | No | Whether the policy is active. Defaults to `true`. |
| `conditions` | `RuleGroup \| null` | No | Condition rules for targeting specific emails. `null` matches all emails. |
| `ingestionScope` | `string[] \| null` | No | Array of ingestion source UUIDs to scope the policy to. `null` applies to all sources. |
#### Conditions (RuleGroup) Schema
```json
{
"logicalOperator": "AND",
"rules": [
{
"field": "sender",
"operator": "domain_match",
"value": "example.com"
},
{
"field": "subject",
"operator": "contains",
"value": "invoice"
}
]
}
```
**Supported fields:** `sender`, `recipient`, `subject`, `attachment_type`
**Supported operators:**
| Operator | Description |
| -------------- | ------------------------------------------------------------------ |
| `equals` | Exact case-insensitive match. |
| `not_equals` | Inverse of `equals`. |
| `contains` | Case-insensitive substring match. |
| `not_contains` | Inverse of `contains`. |
| `starts_with` | Case-insensitive prefix match. |
| `ends_with` | Case-insensitive suffix match. |
| `domain_match` | Matches when an email address ends with `@<value>`. |
| `regex_match` | ECMAScript regex (case-insensitive). Max pattern length: 200 chars.|
**Validation limits:**
- Maximum 50 rules per group.
- Rule `value` must be between 1 and 500 characters.
### Example Request
```json
{
"name": "Finance Department - 10 Year",
"description": "Extended retention for finance-related correspondence.",
"priority": 2,
"retentionPeriodDays": 3650,
"actionOnExpiry": "delete_permanently",
"conditions": {
"logicalOperator": "OR",
"rules": [
{
"field": "sender",
"operator": "domain_match",
"value": "finance.acme.com"
},
{
"field": "recipient",
"operator": "domain_match",
"value": "finance.acme.com"
}
]
},
"ingestionScope": ["b2c3d4e5-f6a7-8901-bcde-f23456789012"]
}
```
### Response
- **`201 Created`** — Returns the created policy object.
- **`409 Conflict`** — A policy with this name already exists.
- **`422 Unprocessable Entity`** — Validation errors.
---
## Update Policy
Updates an existing retention policy. Only the fields included in the request body are modified.
- **Endpoint:** `PUT /policies/:id`
- **Method:** `PUT`
- **Authentication:** Required
- **Permission:** `manage:all`
### Path Parameters
| Parameter | Type | Description |
| --------- | ------ | --------------------------------- |
| `id` | `uuid` | The UUID of the policy to update. |
### Request Body
All fields from the create endpoint are accepted, and all are optional. Only provided fields are updated.
To clear conditions (make the policy match all emails), send `"conditions": null`.
To clear ingestion scope (make the policy apply to all sources), send `"ingestionScope": null`.
### Response
- **`200 OK`** — Returns the updated policy object.
- **`404 Not Found`** — Policy with the given ID does not exist.
- **`422 Unprocessable Entity`** — Validation errors.
---
## Delete Policy
Permanently deletes a retention policy. This action is irreversible.
- **Endpoint:** `DELETE /policies/:id`
- **Method:** `DELETE`
- **Authentication:** Required
- **Permission:** `manage:all`
### Path Parameters
| Parameter | Type | Description |
| --------- | ------ | --------------------------------- |
| `id` | `uuid` | The UUID of the policy to delete. |
### Response
- **`204 No Content`** — Policy successfully deleted.
- **`404 Not Found`** — Policy with the given ID does not exist.
---
## Evaluate Email (Policy Simulator)
Evaluates a set of email metadata against all active policies and returns the applicable retention period and matching policy IDs. This endpoint does not modify any data — it is a read-only simulation tool.
- **Endpoint:** `POST /policies/evaluate`
- **Method:** `POST`
- **Authentication:** Required
- **Permission:** `manage:all`
### Request Body
| Field | Type | Required | Description |
| ---------------------------------- | ---------- | -------- | -------------------------------------------------------- |
| `emailMetadata.sender` | `string` | Yes | Sender email address. Max 500 characters. |
| `emailMetadata.recipients` | `string[]` | Yes | Recipient email addresses. Max 500 entries. |
| `emailMetadata.subject` | `string` | Yes | Email subject line. Max 2000 characters. |
| `emailMetadata.attachmentTypes` | `string[]` | Yes | File extensions (e.g., `[".pdf", ".xml"]`). Max 100. |
| `emailMetadata.ingestionSourceId` | `uuid` | No | Optional ingestion source UUID for scope-aware evaluation.|
### Example Request
```json
{
"emailMetadata": {
"sender": "cfo@finance.acme.com",
"recipients": ["legal@acme.com"],
"subject": "Q4 Invoice Reconciliation",
"attachmentTypes": [".pdf", ".xlsx"],
"ingestionSourceId": "b2c3d4e5-f6a7-8901-bcde-f23456789012"
}
}
```
### Response Body
```json
{
"appliedRetentionDays": 3650,
"actionOnExpiry": "delete_permanently",
"matchingPolicyIds": [
"a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"c3d4e5f6-a7b8-9012-cdef-345678901234"
]
}
```
| Field | Type | Description |
| ---------------------- | ---------- | ------------------------------------------------------------------------------------- |
| `appliedRetentionDays` | `integer` | The longest retention period from all matching policies. `0` means no policy matched. |
| `actionOnExpiry` | `string` | The action to take on expiry. Currently always `"delete_permanently"`. |
| `matchingPolicyIds` | `string[]` | UUIDs of all policies that matched the provided metadata. |
### Response Codes
- **`200 OK`** — Evaluation completed.
- **`422 Unprocessable Entity`** — Validation errors in the request body.

View File

@@ -0,0 +1,93 @@
# Retention Policy: User Interface
The retention policy management interface is located at **Dashboard → Compliance → Retention Policies**. It provides a comprehensive view of all configured policies and tools for creating, editing, deleting, and simulating retention rules.
## Policy Table
The main page displays a table of all retention policies with the following columns:
- **Name:** The policy name and its UUID displayed underneath for reference.
- **Priority:** The numeric priority value. Lower values indicate higher priority.
- **Retention Period:** The number of days emails matching this policy are retained before expiry.
- **Ingestion Scope:** Shows which ingestion sources the policy is restricted to. Displays "All ingestion sources" when the policy has no scope restriction, or individual source name badges when scoped.
- **Conditions:** A summary of the rule group. Displays "No conditions (matches all emails)" for policies without conditions, or "N rule(s) (AND/OR)" for policies with conditions.
- **Status:** A badge indicating whether the policy is Active or Inactive.
- **Actions:** Edit and Delete buttons for each policy.
The table is sorted by policy priority by default.
## Creating a Policy
Click the **"Create Policy"** button above the table to open the creation dialog. The form contains the following sections:
### Basic Information
- **Policy Name:** A unique, descriptive name for the policy.
- **Description:** An optional detailed description of the policy's purpose.
- **Priority:** A positive integer determining evaluation order (lower = higher priority).
- **Retention Period (Days):** The number of days to retain matching emails.
### Ingestion Scope
This section controls which ingestion sources the policy applies to:
- **"All ingestion sources" toggle:** When enabled, the policy applies to emails from all ingestion sources. This is the default.
- **Per-source checkboxes:** When the "all" toggle is disabled, individual ingestion sources can be selected. Each source displays its name and provider type as a badge.
### Condition Rules
Conditions define which emails the policy targets. If no conditions are added, the policy matches all emails (within its ingestion scope).
- **Logical Operator:** Choose **AND** (all rules must match) or **OR** (any rule must match).
- **Add Rule:** Each rule consists of:
- **Field:** The email metadata field to evaluate (`sender`, `recipient`, `subject`, or `attachment_type`).
- **Operator:** The comparison operator (see [Supported Operators](#supported-operators) below).
- **Value:** The string value to compare against.
- **Remove Rule:** Each rule has a remove button to delete it from the group.
### Supported Operators
| Operator | Display Name | Description |
| -------------- | ------------- | ----------------------------------------------------------- |
| `equals` | Equals | Exact case-insensitive match. |
| `not_equals` | Not Equals | Inverse of equals. |
| `contains` | Contains | Case-insensitive substring match. |
| `not_contains` | Not Contains | Inverse of contains. |
| `starts_with` | Starts With | Case-insensitive prefix match. |
| `ends_with` | Ends With | Case-insensitive suffix match. |
| `domain_match` | Domain Match | Matches when an email address ends with `@<value>`. |
| `regex_match` | Regex Match | ECMAScript regular expression (case-insensitive, max 200 chars). |
### Policy Status
- **Enable Policy toggle:** Controls whether the policy is active immediately upon creation.
## Editing a Policy
Click the **Edit** button (pencil icon) on any policy row to open the edit dialog. The form is pre-populated with the policy's current values. All fields can be modified, and the same validation rules apply as during creation.
## Deleting a Policy
Click the **Delete** button (trash icon) on any policy row. A confirmation dialog appears to prevent accidental deletion. Deleting a policy is irreversible. Once deleted, the policy no longer affects the lifecycle worker's evaluation of emails.
## Policy Simulator
The **"Simulate Policy"** button opens a simulation tool that evaluates hypothetical email metadata against all active policies without making any changes.
### Simulator Input Fields
- **Sender Email:** The sender address to evaluate (e.g., `cfo@finance.acme.com`).
- **Recipients:** A comma-separated list of recipient email addresses.
- **Subject:** The email subject line.
- **Attachment Types:** A comma-separated list of file extensions (e.g., `.pdf, .xlsx`).
- **Ingestion Source:** An optional dropdown to select a specific ingestion source for scope-aware evaluation. Defaults to "All sources".
### Simulator Results
After submission, the simulator displays:
- **Applied Retention Period:** The longest retention period from all matching policies, displayed in days.
- **Action on Expiry:** The action that would be taken when the retention period expires (currently always "Permanent Deletion").
- **Matching Policies:** A list of all policy IDs (with their names) that matched the provided metadata. If no policies match, a message indicates that no matching policies were found.
The simulator is a safe, read-only tool intended for testing and verifying policy configurations before they affect live data.

View File

@@ -0,0 +1,55 @@
# Retention Policy
The Retention Policy Engine is an enterprise-grade feature that automates the lifecycle management of archived emails. It enables organizations to define time-based retention rules that determine how long archived emails are kept before they are permanently deleted, ensuring compliance with data protection regulations and internal data governance policies.
## Core Principles
### 1. Policy-Based Automation
Email deletion is never arbitrary. Every deletion is governed by one or more explicitly configured retention policies that define the retention period in days, the conditions under which the policy applies, and the action to take when an email expires. The lifecycle worker processes emails in batches on a recurring schedule, ensuring continuous enforcement without manual intervention.
### 2. Condition-Based Targeting
Policies can target specific subsets of archived emails using a flexible condition builder. Conditions are evaluated against email metadata fields (sender, recipient, subject, attachment type) using a variety of string-matching operators. Conditions within a policy are grouped using AND/OR logic, allowing precise control over which emails a policy applies to.
### 3. Ingestion Scope
Each policy can optionally be scoped to one or more ingestion sources. When an ingestion scope is set, the policy only applies to emails that were archived from those specific sources. Policies with no ingestion scope (null) apply to all emails regardless of their source.
### 4. Priority and Max-Duration-Wins
When multiple policies match a single email, the system applies **max-duration-wins** logic: the longest matching retention period is used. This ensures that if any policy requires an email to be kept longer, that requirement is honored. The priority field on each policy provides an ordering mechanism for administrative purposes and future conflict-resolution enhancements.
### 5. Full Audit Trail
Every policy lifecycle event — creation, modification, deletion, and every automated email deletion — is recorded in the immutable [Audit Log](../audit-log/index.md). Automated deletions include the IDs of the governing policies in the audit log entry, ensuring full traceability from deletion back to the rule that triggered it.
### 6. Fail-Safe Behavior
The system is designed to err on the side of caution:
- If no policy matches an email, the email is **not** deleted.
- If the lifecycle worker encounters an error processing a specific email, it logs the error and continues with the remaining emails in the batch.
- Invalid regex patterns in `regex_match` rules are treated as non-matching rather than causing failures.
## Feature Requirements
The Retention Policy Engine requires:
- An active **Enterprise license** with the `RETENTION_POLICY` feature enabled.
- The `manage:all` permission for the authenticated user to access the policy management API and UI.
## Architecture Overview
The feature is composed of the following components:
| Component | Location | Description |
| -------------------- | --------------------------------------------------------------------- | ------------------------------------------------------------ |
| Types | `packages/types/src/retention.types.ts` | Shared TypeScript types for policies, rules, and evaluation. |
| Database Schema | `packages/backend/src/database/schema/compliance.ts` | Drizzle ORM table definition for `retention_policies`. |
| Retention Service | `packages/enterprise/src/modules/retention-policy/RetentionService.ts`| CRUD operations and the evaluation engine. |
| API Controller | `packages/enterprise/src/modules/retention-policy/retention-policy.controller.ts` | Express request handlers with Zod validation. |
| API Routes | `packages/enterprise/src/modules/retention-policy/retention-policy.routes.ts` | Route registration with auth and feature guards. |
| Module | `packages/enterprise/src/modules/retention-policy/retention-policy.module.ts` | Enterprise module bootstrap. |
| Lifecycle Worker | `packages/enterprise/src/workers/lifecycle.worker.ts` | BullMQ worker for automated retention enforcement. |
| Frontend Page | `packages/frontend/src/routes/dashboard/compliance/retention-policies/` | SvelteKit page for policy management and simulation. |

View File

@@ -0,0 +1,106 @@
# Retention Policy: Lifecycle Worker
The lifecycle worker is the automated enforcement component of the retention policy engine. It runs as a BullMQ background worker that periodically scans all archived emails, evaluates them against active retention policies, and permanently deletes emails that have exceeded their retention period.
## Location
`packages/enterprise/src/workers/lifecycle.worker.ts`
## How It Works
### Scheduling
The lifecycle worker is registered as a repeatable BullMQ cron job on the `compliance-lifecycle` queue. It is scheduled to run daily at **02:00 UTC** by default. The cron schedule is configured via:
```typescript
repeat: { pattern: '0 2 * * *' } // daily at 02:00 UTC
```
The `scheduleLifecycleJob()` function is called once during enterprise application startup to register the repeatable job with BullMQ.
### Batch Processing
To avoid loading the entire `archived_emails` table into memory, the worker processes emails in configurable batches:
1. **Batch size** is controlled by the `RETENTION_BATCH_SIZE` environment variable.
2. Emails are ordered by `archivedAt` ascending.
3. The worker iterates through batches using offset-based pagination until an empty batch is returned, indicating all emails have been processed.
### Per-Email Processing Flow
For each email in a batch, the worker:
1. **Extracts metadata:** Builds a `PolicyEvaluationRequest` from the email's database record:
- `sender`: The sender email address.
- `recipients`: All To, CC, and BCC recipient addresses.
- `subject`: The email subject line.
- `attachmentTypes`: File extensions (e.g., `.pdf`) extracted from attachment filenames via a join query.
- `ingestionSourceId`: The UUID of the ingestion source that archived this email.
2. **Evaluates policies:** Passes the metadata to `RetentionService.evaluateEmail()`, which returns:
- `appliedRetentionDays`: The longest matching retention period (0 if no policy matches).
- `matchingPolicyIds`: UUIDs of all matching policies.
3. **Checks for expiry:**
- If `appliedRetentionDays === 0`, no policy matched — the email is **skipped** (not deleted).
- Otherwise, the email's age is calculated from its `sentAt` date.
- If the age in days exceeds `appliedRetentionDays`, the email has expired.
4. **Deletes expired emails:** Calls `ArchivedEmailService.deleteArchivedEmail()` with:
- `systemDelete: true` — Bypasses the `ENABLE_DELETION` configuration guard so retention enforcement always works regardless of that global setting.
- `governingRule` — A string listing the matching policy IDs for the audit log entry (e.g., `"Policy IDs: abc-123, def-456"`).
5. **Logs the deletion:** A structured log entry records the email ID and its age in days.
### Error Handling
If processing a specific email fails (e.g., due to a database error or storage issue), the error is logged and the worker continues to the next email in the batch. This ensures that a single problematic email does not block the processing of the remaining emails.
If the entire job fails, BullMQ records the failure and the job ID and error are logged. Failed jobs are retained (up to 50) for debugging.
## System Actor
Automated deletions are attributed to a synthetic system actor in the audit log:
| Field | Value |
| ------------ | ------------------------------------ |
| ID | `system:lifecycle-worker` |
| Email | `system@open-archiver.internal` |
| Name | System Lifecycle Worker |
| Actor IP | `system` |
This well-known identifier can be filtered in the [Audit Log](../audit-log/index.md) to view all retention-based deletions.
## Audit Trail
Every email deleted by the lifecycle worker produces an audit log entry with:
- **Action type:** `DELETE`
- **Target type:** `ArchivedEmail`
- **Target ID:** The UUID of the deleted email
- **Actor:** `system:lifecycle-worker`
- **Details:** Includes `reason: "RetentionExpiration"` and `governingRule` listing the matching policy IDs
This ensures that every automated deletion is fully traceable back to the specific policies that triggered it.
## Configuration
| Environment Variable | Description | Default |
| ------------------------- | ---------------------------------------------------- | ------- |
| `RETENTION_BATCH_SIZE` | Number of emails to process per batch iteration. | — |
## BullMQ Worker Settings
| Setting | Value | Description |
| -------------------- | ---------------------- | -------------------------------------------------- |
| Queue name | `compliance-lifecycle` | The BullMQ queue name. |
| Job ID | `lifecycle-daily` | Stable job ID for the repeatable cron job. |
| `removeOnComplete` | Keep last 10 | Completed jobs retained for monitoring. |
| `removeOnFail` | Keep last 50 | Failed jobs retained for debugging. |
## Integration with Deletion Guard
The core `ArchivedEmailService.deleteArchivedEmail()` method includes a deletion guard controlled by the `ENABLE_DELETION` system setting. When called with `systemDelete: true`, the lifecycle worker bypasses this guard. This design ensures that:
- Manual user deletions can be disabled organization-wide via the system setting.
- Automated retention enforcement always operates regardless of that setting, because retention compliance is a legal obligation that cannot be paused by a UI toggle.

View File

@@ -0,0 +1,138 @@
# Retention Policy: Backend Implementation
The backend implementation of the retention policy engine is handled by the `RetentionService`, located in `packages/enterprise/src/modules/retention-policy/RetentionService.ts`. This service encapsulates all CRUD operations for policies and the core evaluation engine that determines which policies apply to a given email.
## Database Schema
The `retention_policies` table is defined in `packages/backend/src/database/schema/compliance.ts` using Drizzle ORM:
| Column | Type | Description |
| --------------------- | -------------------------- | --------------------------------------------------------------------------- |
| `id` | `uuid` (PK) | Auto-generated unique identifier. |
| `name` | `text` (unique, not null) | Human-readable policy name. |
| `description` | `text` | Optional description. |
| `priority` | `integer` (not null) | Priority for ordering. Lower = higher priority. |
| `retention_period_days` | `integer` (not null) | Number of days to retain matching emails. |
| `action_on_expiry` | `enum` (not null) | Action on expiry (`delete_permanently`). |
| `is_enabled` | `boolean` (default: true) | Whether the policy is active. |
| `conditions` | `jsonb` | Serialized `RetentionRuleGroup` or null (null = matches all). |
| `ingestion_scope` | `jsonb` | Array of ingestion source UUIDs or null (null = all sources). |
| `created_at` | `timestamptz` | Creation timestamp. |
| `updated_at` | `timestamptz` | Last update timestamp. |
## CRUD Operations
The `RetentionService` class provides the following methods:
### `createPolicy(data, actorId, actorIp)`
Inserts a new policy into the database and creates an audit log entry with action type `CREATE` and target type `RetentionPolicy`. The audit log details include the policy name, retention period, priority, action on expiry, and ingestion scope.
### `getPolicies()`
Returns all policies ordered by priority ascending. The raw database rows are mapped through `mapDbPolicyToType()`, which converts the DB column `isEnabled` to the shared type field `isActive` and normalizes date fields to ISO strings.
### `getPolicyById(id)`
Returns a single policy by UUID, or null if not found.
### `updatePolicy(id, data, actorId, actorIp)`
Partially updates a policy — only fields present in the DTO are modified. The `updatedAt` timestamp is always set to the current time. An audit log entry is created with action type `UPDATE`, recording which fields were changed.
Throws an error if the policy is not found.
### `deletePolicy(id, actorId, actorIp)`
Deletes a policy by UUID and creates an audit log entry with action type `DELETE`, recording the deleted policy's name. Returns `false` if the policy was not found.
## Evaluation Engine
The evaluation engine is the core logic that determines which policies apply to a given email. It is used by both the lifecycle worker (for automated enforcement) and the policy simulator endpoint (for testing).
### `evaluateEmail(metadata)`
This is the primary evaluation method. It accepts email metadata and returns:
- `appliedRetentionDays`: The longest matching retention period (max-duration-wins).
- `matchingPolicyIds`: UUIDs of all policies that matched.
- `actionOnExpiry`: Always `"delete_permanently"` in the current implementation.
The evaluation flow:
1. **Fetch active policies:** Queries all policies where `isEnabled = true`.
2. **Ingestion scope check:** For each policy with a non-null `ingestionScope`, the email's `ingestionSourceId` must be included in the scope array. If not, the policy is skipped.
3. **Condition evaluation:** If the policy has no conditions (`null`), it matches all emails within scope. Otherwise, the condition rule group is evaluated.
4. **Max-duration-wins:** If multiple policies match, the longest `retentionPeriodDays` is used.
5. **Zero means no match:** A return value of `appliedRetentionDays = 0` indicates no policy matched — the lifecycle worker will not delete the email.
### `_evaluateRuleGroup(group, metadata)`
Evaluates a `RetentionRuleGroup` using AND or OR logic:
- **AND:** Every rule in the group must pass.
- **OR:** At least one rule must pass.
- An empty rules array evaluates to `true`.
### `_evaluateRule(rule, metadata)`
Evaluates a single rule against the email metadata. All string comparisons are case-insensitive (both sides are lowercased before comparison). The behavior depends on the field:
| Field | Behavior |
| ----------------- | ------------------------------------------------------------------------ |
| `sender` | Compares against the sender email address. |
| `recipient` | Passes if **any** recipient matches the operator. |
| `subject` | Compares against the email subject. |
| `attachment_type` | Passes if **any** attachment file extension matches (e.g., `.pdf`). |
### `_applyOperator(haystack, operator, needle)`
Applies a string-comparison operator between two pre-lowercased strings:
| Operator | Implementation |
| -------------- | ----------------------------------------------------------------------------- |
| `equals` | `haystack === needle` |
| `not_equals` | `haystack !== needle` |
| `contains` | `haystack.includes(needle)` |
| `not_contains` | `!haystack.includes(needle)` |
| `starts_with` | `haystack.startsWith(needle)` |
| `ends_with` | `haystack.endsWith(needle)` |
| `domain_match` | `haystack.endsWith('@' + needle)` (auto-prepends `@` if missing) |
| `regex_match` | `new RegExp(needle, 'i').test(haystack)` with safety guards (see below) |
### Security: `regex_match` Safeguards
The `regex_match` operator includes protections against Regular Expression Denial of Service (ReDoS):
1. **Length limit:** Patterns exceeding 200 characters (`MAX_REGEX_LENGTH`) are rejected and treated as non-matching. A warning is logged.
2. **Error handling:** Invalid regex syntax is caught in a try/catch block and treated as non-matching. A warning is logged.
3. **Flags:** Only the case-insensitive flag (`i`) is used. Global and multiline flags are excluded to prevent stateful matching bugs.
## Request Validation
The `RetentionPolicyController` (`retention-policy.controller.ts`) validates all incoming requests using Zod schemas before passing data to the service:
| Constraint | Limit |
| --------------------------- | -------------------------------------------------------------- |
| Policy name | 1255 characters. |
| Description | Max 1000 characters. |
| Priority | Positive integer (≥ 1). |
| Retention period | Positive integer (≥ 1 day). |
| Rules per group | Max 50. |
| Rule value | 1500 characters. |
| Ingestion scope entries | Each must be a valid UUID. Empty arrays are coerced to `null`. |
| Evaluate — sender | Max 500 characters. |
| Evaluate — recipients | Max 500 entries, each max 500 characters. |
| Evaluate — subject | Max 2000 characters. |
| Evaluate — attachment types | Max 100 entries, each max 50 characters. |
## Module Registration
The `RetentionPolicyModule` (`retention-policy.module.ts`) implements the `ArchiverModule` interface and registers the API routes at:
```
/{api.version}/enterprise/retention-policy
```
All routes are protected by:
1. `requireAuth` — Ensures the request includes a valid authentication token.
2. `featureEnabled(OpenArchiverFeature.RETENTION_POLICY)` — Ensures the enterprise license includes the retention policy feature.
3. `requirePermission('manage', 'all')` — Ensures the user has administrative permissions.

View File

@@ -30,7 +30,14 @@ archive.zip
2. Click the **Create New** button.
3. Select **EML Import** as the provider.
4. Enter a name for the ingestion source.
5. Click the **Choose File** button and select the zip archive containing your EML files.
5. **Choose Import Method:**
* **Upload File:** Click **Choose File** and select the zip archive containing your EML files. (Best for smaller archives)
* **Local Path:** Enter the path to the zip file **inside the container**. (Best for large archives)
> **Note on Local Path:** When using Docker, the "Local Path" is relative to the container's filesystem.
> * **Recommended:** Place your zip file in a `temp` folder inside your configured storage directory (`STORAGE_LOCAL_ROOT_PATH`). This path is already mounted. For example, if your storage path is `/data`, put the file in `/data/temp/emails.zip` and enter `/data/temp/emails.zip` as the path.
> * **Alternative:** Mount a separate volume in `docker-compose.yml` (e.g., `- /host/path:/container/path`) and use the container path.
6. Click the **Submit** button.
OpenArchiver will then start importing the EML files from the zip archive. The ingestion process may take some time, depending on the size of the archive.

View File

@@ -17,7 +17,13 @@ Once you have your `.mbox` file, you can upload it to OpenArchiver through the w
1. Navigate to the **Ingestion** page.
2. Click on the **New Ingestion** button.
3. Select **Mbox** as the source type.
4. Upload your `.mbox` file.
4. **Choose Import Method:**
* **Upload File:** Upload your `.mbox` file.
* **Local Path:** Enter the path to the mbox file **inside the container**.
> **Note on Local Path:** When using Docker, the "Local Path" is relative to the container's filesystem.
> * **Recommended:** Place your mbox file in a `temp` folder inside your configured storage directory (`STORAGE_LOCAL_ROOT_PATH`). This path is already mounted. For example, if your storage path is `/data`, put the file in `/data/temp/emails.mbox` and enter `/data/temp/emails.mbox` as the path.
> * **Alternative:** Mount a separate volume in `docker-compose.yml` (e.g., `- /host/path:/container/path`) and use the container path.
## 3. Folder Structure

View File

@@ -15,7 +15,14 @@ To ensure a successful import, you should prepare your PST file according to the
2. Click the **Create New** button.
3. Select **PST Import** as the provider.
4. Enter a name for the ingestion source.
5. Click the **Choose File** button and select the PST file.
5. **Choose Import Method:**
* **Upload File:** Click **Choose File** and select the PST file from your computer. (Best for smaller files)
* **Local Path:** Enter the path to the PST file **inside the container**. (Best for large files)
> **Note on Local Path:** When using Docker, the "Local Path" is relative to the container's filesystem.
> * **Recommended:** Place your file in a `temp` folder inside your configured storage directory (`STORAGE_LOCAL_ROOT_PATH`). This path is already mounted. For example, if your storage path is `/data`, put the file in `/data/temp/archive.pst` and enter `/data/temp/archive.pst` as the path.
> * **Alternative:** Mount a separate volume in `docker-compose.yml` (e.g., `- /host/path:/container/path`) and use the container path.
6. Click the **Submit** button.
OpenArchiver will then start importing the emails from the PST file. The ingestion process may take some time, depending on the size of the file.

View File

@@ -115,6 +115,7 @@ These variables are used by `docker-compose.yml` to configure the services.
| `MEILI_INDEXING_BATCH` | The number of emails to batch together for indexing. | `500` |
| `REDIS_HOST` | The host for the Valkey (Redis) service. | `valkey` |
| `REDIS_PORT` | The port for the Valkey (Redis) service. | `6379` |
| `REDIS_USER` | Optional Redis username if ACLs are used. | |
| `REDIS_PASSWORD` | The password for the Valkey (Redis) service. | `defaultredispassword` |
| `REDIS_TLS_ENABLED` | Enable or disable TLS for Redis. | `false` |

View File

@@ -4,9 +4,57 @@ Meilisearch, the search engine used by Open Archiver, requires a manual data mig
If an Open Archiver upgrade includes a major Meilisearch version change, you will need to migrate your search index by following the process below.
## Migration Process Overview
## Experimental: Dumpless Upgrade
For self-hosted instances using Docker Compose (as recommended), the migration process involves creating a data dump from your current Meilisearch instance, upgrading the Docker image, and then importing that dump into the new version.
> **Warning:** This feature is currently **experimental**. We do not recommend using it for production environments until it is marked as stable. Please use the [standard migration process](#standard-migration-process-recommended) instead. Proceed with caution.
Meilisearch recently introduced an experimental "dumpless" upgrade method. This allows you to migrate the database to a new Meilisearch version without manually creating and importing a dump. However, please note that **dumpless upgrades are not currently atomic**. If the process fails, your database may become corrupted, resulting in data loss.
**Prerequisite: Create a Snapshot**
Before attempting a dumpless upgrade, you **must** take a snapshot of your instance. This ensures you have a recovery point if the upgrade fails. Learn how to create snapshots in the [official Meilisearch documentation](https://www.meilisearch.com/docs/learn/data_backup/snapshots).
### How to Enable
To perform a dumpless upgrade, you need to configure your Meilisearch instance with the experimental flag. You can do this in one of two ways:
**Option 1: Using an Environment Variable**
Add the `MEILI_EXPERIMENTAL_DUMPLESS_UPGRADE` environment variable to your `docker-compose.yml` file for the Meilisearch service.
```yaml
services:
meilisearch:
image: getmeili/meilisearch:v1.x # The new version you want to upgrade to
environment:
- MEILI_MASTER_KEY=${MEILI_MASTER_KEY}
- MEILI_EXPERIMENTAL_DUMPLESS_UPGRADE=true
```
**Option 2: Using a CLI Option**
Alternatively, you can pass the `--experimental-dumpless-upgrade` flag in the command section of your `docker-compose.yml`.
```yaml
services:
meilisearch:
image: getmeili/meilisearch:v1.x # The new version you want to upgrade to
command: meilisearch --experimental-dumpless-upgrade
```
After updating your configuration, restart your container:
```bash
docker compose up -d
```
Meilisearch will attempt to migrate your database to the new version automatically.
---
## Standard Migration Process (Recommended)
For self-hosted instances using Docker Compose, the recommended migration process involves creating a data dump from your current Meilisearch instance, upgrading the Docker image, and then importing that dump into the new version.
### Step 1: Create a Dump

View File

@@ -22,6 +22,7 @@ services:
- MEILI_HOST=http://meilisearch:7700
- REDIS_HOST=valkey
- REDIS_PORT=6379
- REDIS_USER=default
- REDIS_PASSWORD=${SERVICE_PASSWORD_VALKEY}
- REDIS_TLS_ENABLED=false
- STORAGE_TYPE=${STORAGE_TYPE:-local}
@@ -73,5 +74,6 @@ services:
image: getmeili/meilisearch:v1.15
environment:
- MEILI_MASTER_KEY=${SERVICE_PASSWORD_MEILISEARCH}
- MEILI_SCHEDULE_SNAPSHOT=86400
volumes:
- meilidata:/meili_data

View File

@@ -1,6 +1,6 @@
{
"name": "open-archiver",
"version": "0.4.1",
"version": "0.4.2",
"private": true,
"license": "SEE LICENSE IN LICENSE file",
"scripts": {

View File

@@ -2,6 +2,7 @@ import { Request, Response } from 'express';
import { ApiKeyService } from '../../services/ApiKeyService';
import { z } from 'zod';
import { UserService } from '../../services/UserService';
import { config } from '../../config';
const generateApiKeySchema = z.object({
name: z
@@ -18,6 +19,9 @@ export class ApiKeyController {
private userService = new UserService();
public generateApiKey = async (req: Request, res: Response) => {
try {
if (config.app.isDemo) {
return res.status(403).json({ message: req.t('errors.demoMode') });
}
const { name, expiresInDays } = generateApiKeySchema.parse(req.body);
if (!req.user || !req.user.sub) {
return res.status(401).json({ message: 'Unauthorized' });
@@ -58,6 +62,9 @@ export class ApiKeyController {
};
public deleteApiKey = async (req: Request, res: Response) => {
if (config.app.isDemo) {
return res.status(403).json({ message: req.t('errors.demoMode') });
}
const { id } = req.params;
if (!req.user || !req.user.sub) {
return res.status(401).json({ message: 'Unauthorized' });

View File

@@ -3,6 +3,7 @@ import { UserService } from '../../services/UserService';
import * as schema from '../../database/schema';
import { sql } from 'drizzle-orm';
import { db } from '../../database';
import { config } from '../../config';
const userService = new UserService();
@@ -92,6 +93,9 @@ export const getProfile = async (req: Request, res: Response) => {
};
export const updateProfile = async (req: Request, res: Response) => {
if (config.app.isDemo) {
return res.status(403).json({ message: req.t('errors.demoMode') });
}
const { email, first_name, last_name } = req.body;
if (!req.user || !req.user.sub) {
return res.status(401).json({ message: 'Unauthorized' });
@@ -111,6 +115,9 @@ export const updateProfile = async (req: Request, res: Response) => {
};
export const updatePassword = async (req: Request, res: Response) => {
if (config.app.isDemo) {
return res.status(403).json({ message: req.t('errors.demoMode') });
}
const { currentPassword, newPassword } = req.body;
if (!req.user || !req.user.sub) {
return res.status(401).json({ message: 'Unauthorized' });

View File

@@ -7,4 +7,5 @@ export const app = {
syncFrequency: process.env.SYNC_FREQUENCY || '* * * * *', //default to 1 minute
enableDeletion: process.env.ENABLE_DELETION === 'true',
allInclusiveArchive: process.env.ALL_INCLUSIVE_ARCHIVE === 'true',
isDemo: process.env.IS_DEMO === 'true',
};

View File

@@ -1,15 +1,20 @@
import 'dotenv/config';
import { type ConnectionOptions } from 'bullmq';
/**
* @see https://github.com/taskforcesh/bullmq/blob/master/docs/gitbook/guide/connections.md
*/
const connectionOptions: any = {
const connectionOptions: ConnectionOptions = {
host: process.env.REDIS_HOST || 'localhost',
port: (process.env.REDIS_PORT && parseInt(process.env.REDIS_PORT, 10)) || 6379,
password: process.env.REDIS_PASSWORD,
enableReadyCheck: true,
};
if (process.env.REDIS_USER) {
connectionOptions.username = process.env.REDIS_USER;
}
if (process.env.REDIS_TLS_ENABLED === 'true') {
connectionOptions.tls = {
rejectUnauthorized: false,

View File

@@ -0,0 +1,51 @@
CREATE TABLE "email_legal_holds" (
"email_id" uuid NOT NULL,
"legal_hold_id" uuid NOT NULL,
CONSTRAINT "email_legal_holds_email_id_legal_hold_id_pk" PRIMARY KEY("email_id","legal_hold_id")
);
--> statement-breakpoint
CREATE TABLE "email_retention_labels" (
"email_id" uuid NOT NULL,
"label_id" uuid NOT NULL,
"applied_at" timestamp with time zone DEFAULT now() NOT NULL,
"applied_by_user_id" uuid,
CONSTRAINT "email_retention_labels_email_id_label_id_pk" PRIMARY KEY("email_id","label_id")
);
--> statement-breakpoint
CREATE TABLE "retention_events" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"event_name" varchar(255) NOT NULL,
"event_type" varchar(100) NOT NULL,
"event_timestamp" timestamp with time zone NOT NULL,
"target_criteria" jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "retention_labels" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" varchar(255) NOT NULL,
"retention_period_days" integer NOT NULL,
"description" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "legal_holds" DROP CONSTRAINT "legal_holds_custodian_id_custodians_id_fk";
--> statement-breakpoint
ALTER TABLE "legal_holds" DROP CONSTRAINT "legal_holds_case_id_ediscovery_cases_id_fk";
--> statement-breakpoint
ALTER TABLE "legal_holds" ALTER COLUMN "case_id" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "legal_holds" ADD COLUMN "name" varchar(255) NOT NULL;--> statement-breakpoint
ALTER TABLE "legal_holds" ADD COLUMN "is_active" boolean DEFAULT true NOT NULL;--> statement-breakpoint
ALTER TABLE "legal_holds" ADD COLUMN "created_at" timestamp with time zone DEFAULT now() NOT NULL;--> statement-breakpoint
ALTER TABLE "legal_holds" ADD COLUMN "updated_at" timestamp with time zone DEFAULT now() NOT NULL;--> statement-breakpoint
ALTER TABLE "email_legal_holds" ADD CONSTRAINT "email_legal_holds_email_id_archived_emails_id_fk" FOREIGN KEY ("email_id") REFERENCES "public"."archived_emails"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "email_legal_holds" ADD CONSTRAINT "email_legal_holds_legal_hold_id_legal_holds_id_fk" FOREIGN KEY ("legal_hold_id") REFERENCES "public"."legal_holds"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "email_retention_labels" ADD CONSTRAINT "email_retention_labels_email_id_archived_emails_id_fk" FOREIGN KEY ("email_id") REFERENCES "public"."archived_emails"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "email_retention_labels" ADD CONSTRAINT "email_retention_labels_label_id_retention_labels_id_fk" FOREIGN KEY ("label_id") REFERENCES "public"."retention_labels"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "email_retention_labels" ADD CONSTRAINT "email_retention_labels_applied_by_user_id_users_id_fk" FOREIGN KEY ("applied_by_user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "legal_holds" ADD CONSTRAINT "legal_holds_case_id_ediscovery_cases_id_fk" FOREIGN KEY ("case_id") REFERENCES "public"."ediscovery_cases"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "legal_holds" DROP COLUMN "custodian_id";--> statement-breakpoint
ALTER TABLE "legal_holds" DROP COLUMN "hold_criteria";--> statement-breakpoint
ALTER TABLE "legal_holds" DROP COLUMN "applied_by_identifier";--> statement-breakpoint
ALTER TABLE "legal_holds" DROP COLUMN "applied_at";--> statement-breakpoint
ALTER TABLE "legal_holds" DROP COLUMN "removed_at";

View File

@@ -0,0 +1,3 @@
ALTER TYPE "public"."audit_log_target_type" ADD VALUE 'RetentionPolicy' BEFORE 'Role';--> statement-breakpoint
ALTER TYPE "public"."audit_log_target_type" ADD VALUE 'SystemEvent' BEFORE 'SystemSettings';--> statement-breakpoint
ALTER TABLE "retention_policies" ADD COLUMN "ingestion_scope" jsonb DEFAULT 'null'::jsonb;

View File

@@ -0,0 +1,6 @@
ALTER TYPE "public"."audit_log_target_type" ADD VALUE 'RetentionLabel' BEFORE 'Role';--> statement-breakpoint
ALTER TYPE "public"."audit_log_target_type" ADD VALUE 'LegalHold' BEFORE 'Role';--> statement-breakpoint
ALTER TABLE "email_legal_holds" ADD COLUMN "applied_at" timestamp with time zone DEFAULT now() NOT NULL;--> statement-breakpoint
ALTER TABLE "email_legal_holds" ADD COLUMN "applied_by_user_id" uuid;--> statement-breakpoint
ALTER TABLE "retention_labels" ADD COLUMN "is_disabled" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "email_legal_holds" ADD CONSTRAINT "email_legal_holds_applied_by_user_id_users_id_fk" FOREIGN KEY ("applied_by_user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,174 +1,195 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1752225352591,
"tag": "0000_amusing_namora",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1752326803882,
"tag": "0001_odd_night_thrasher",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1752332648392,
"tag": "0002_lethal_quentin_quire",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1752332967084,
"tag": "0003_petite_wrecker",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1752606108876,
"tag": "0004_sleepy_paper_doll",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1752606327253,
"tag": "0005_chunky_sue_storm",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1753112018514,
"tag": "0006_majestic_caretaker",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1753190159356,
"tag": "0007_handy_archangel",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1753370737317,
"tag": "0008_eminent_the_spike",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1754337938241,
"tag": "0009_late_lenny_balinger",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1754420780849,
"tag": "0010_perpetual_lightspeed",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1754422064158,
"tag": "0011_tan_blackheart",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1754476962901,
"tag": "0012_warm_the_stranger",
"breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1754659373517,
"tag": "0013_classy_talkback",
"breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1754831765718,
"tag": "0014_foamy_vapor",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1755443936046,
"tag": "0015_wakeful_norman_osborn",
"breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1755780572342,
"tag": "0016_lonely_mariko_yashida",
"breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1755961566627,
"tag": "0017_tranquil_shooting_star",
"breakpoints": true
},
{
"idx": 18,
"version": "7",
"when": 1756911118035,
"tag": "0018_flawless_owl",
"breakpoints": true
},
{
"idx": 19,
"version": "7",
"when": 1756937533843,
"tag": "0019_confused_scream",
"breakpoints": true
},
{
"idx": 20,
"version": "7",
"when": 1757860242528,
"tag": "0020_panoramic_wolverine",
"breakpoints": true
},
{
"idx": 21,
"version": "7",
"when": 1759412986134,
"tag": "0021_nosy_veda",
"breakpoints": true
},
{
"idx": 22,
"version": "7",
"when": 1759701622932,
"tag": "0022_complete_triton",
"breakpoints": true
},
{
"idx": 23,
"version": "7",
"when": 1760354094610,
"tag": "0023_swift_swordsman",
"breakpoints": true
}
]
}
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1752225352591,
"tag": "0000_amusing_namora",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1752326803882,
"tag": "0001_odd_night_thrasher",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1752332648392,
"tag": "0002_lethal_quentin_quire",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1752332967084,
"tag": "0003_petite_wrecker",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1752606108876,
"tag": "0004_sleepy_paper_doll",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1752606327253,
"tag": "0005_chunky_sue_storm",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1753112018514,
"tag": "0006_majestic_caretaker",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1753190159356,
"tag": "0007_handy_archangel",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1753370737317,
"tag": "0008_eminent_the_spike",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1754337938241,
"tag": "0009_late_lenny_balinger",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1754420780849,
"tag": "0010_perpetual_lightspeed",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1754422064158,
"tag": "0011_tan_blackheart",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1754476962901,
"tag": "0012_warm_the_stranger",
"breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1754659373517,
"tag": "0013_classy_talkback",
"breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1754831765718,
"tag": "0014_foamy_vapor",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1755443936046,
"tag": "0015_wakeful_norman_osborn",
"breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1755780572342,
"tag": "0016_lonely_mariko_yashida",
"breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1755961566627,
"tag": "0017_tranquil_shooting_star",
"breakpoints": true
},
{
"idx": 18,
"version": "7",
"when": 1756911118035,
"tag": "0018_flawless_owl",
"breakpoints": true
},
{
"idx": 19,
"version": "7",
"when": 1756937533843,
"tag": "0019_confused_scream",
"breakpoints": true
},
{
"idx": 20,
"version": "7",
"when": 1757860242528,
"tag": "0020_panoramic_wolverine",
"breakpoints": true
},
{
"idx": 21,
"version": "7",
"when": 1759412986134,
"tag": "0021_nosy_veda",
"breakpoints": true
},
{
"idx": 22,
"version": "7",
"when": 1759701622932,
"tag": "0022_complete_triton",
"breakpoints": true
},
{
"idx": 23,
"version": "7",
"when": 1760354094610,
"tag": "0023_swift_swordsman",
"breakpoints": true
},
{
"idx": 24,
"version": "7",
"when": 1772842674479,
"tag": "0024_careful_black_panther",
"breakpoints": true
},
{
"idx": 25,
"version": "7",
"when": 1773013461190,
"tag": "0025_peaceful_grim_reaper",
"breakpoints": true
},
{
"idx": 26,
"version": "7",
"when": 1773326266420,
"tag": "0026_pink_fantastic_four",
"breakpoints": true
}
]
}

View File

@@ -5,11 +5,14 @@ import {
jsonb,
pgEnum,
pgTable,
primaryKey,
text,
timestamp,
uuid,
varchar,
} from 'drizzle-orm/pg-core';
import { custodians } from './custodians';
import { archivedEmails } from './archived-emails';
import { users } from './users';
// --- Enums ---
@@ -29,10 +32,46 @@ export const retentionPolicies = pgTable('retention_policies', {
actionOnExpiry: retentionActionEnum('action_on_expiry').notNull(),
isEnabled: boolean('is_enabled').notNull().default(true),
conditions: jsonb('conditions'),
/**
* Array of ingestion source UUIDs this policy is restricted to.
* null means the policy applies to all ingestion sources.
*/
ingestionScope: jsonb('ingestion_scope').$type<string[] | null>().default(null),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
});
export const retentionLabels = pgTable('retention_labels', {
id: uuid('id').defaultRandom().primaryKey(),
name: varchar('name', { length: 255 }).notNull(),
retentionPeriodDays: integer('retention_period_days').notNull(),
description: text('description'),
isDisabled: boolean('is_disabled').notNull().default(false),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
});
export const emailRetentionLabels = pgTable('email_retention_labels', {
emailId: uuid('email_id')
.references(() => archivedEmails.id, { onDelete: 'cascade' })
.notNull(),
labelId: uuid('label_id')
.references(() => retentionLabels.id, { onDelete: 'cascade' })
.notNull(),
appliedAt: timestamp('applied_at', { withTimezone: true }).notNull().defaultNow(),
appliedByUserId: uuid('applied_by_user_id').references(() => users.id),
}, (t) => [
primaryKey({ columns: [t.emailId, t.labelId] }),
]);
export const retentionEvents = pgTable('retention_events', {
id: uuid('id').defaultRandom().primaryKey(),
eventName: varchar('event_name', { length: 255 }).notNull(),
eventType: varchar('event_type', { length: 100 }).notNull(),
eventTimestamp: timestamp('event_timestamp', { withTimezone: true }).notNull(),
targetCriteria: jsonb('target_criteria').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
});
export const ediscoveryCases = pgTable('ediscovery_cases', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull().unique(),
@@ -44,18 +83,33 @@ export const ediscoveryCases = pgTable('ediscovery_cases', {
});
export const legalHolds = pgTable('legal_holds', {
id: uuid('id').primaryKey().defaultRandom(),
caseId: uuid('case_id')
.notNull()
.references(() => ediscoveryCases.id, { onDelete: 'cascade' }),
custodianId: uuid('custodian_id').references(() => custodians.id, { onDelete: 'cascade' }),
holdCriteria: jsonb('hold_criteria'),
id: uuid('id').defaultRandom().primaryKey(),
name: varchar('name', { length: 255 }).notNull(),
reason: text('reason'),
appliedByIdentifier: text('applied_by_identifier').notNull(),
appliedAt: timestamp('applied_at', { withTimezone: true }).notNull().defaultNow(),
removedAt: timestamp('removed_at', { withTimezone: true }),
isActive: boolean('is_active').notNull().default(true),
// Optional link to ediscovery cases for backward compatibility or future use
caseId: uuid('case_id').references(() => ediscoveryCases.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
});
export const emailLegalHolds = pgTable(
'email_legal_holds',
{
emailId: uuid('email_id')
.references(() => archivedEmails.id, { onDelete: 'cascade' })
.notNull(),
legalHoldId: uuid('legal_hold_id')
.references(() => legalHolds.id, { onDelete: 'cascade' })
.notNull(),
appliedAt: timestamp('applied_at', { withTimezone: true }).notNull().defaultNow(),
appliedByUserId: uuid('applied_by_user_id').references(() => users.id),
},
(t) => [
primaryKey({ columns: [t.emailId, t.legalHoldId] }),
],
);
export const exportJobs = pgTable('export_jobs', {
id: uuid('id').primaryKey().defaultRandom(),
caseId: uuid('case_id').references(() => ediscoveryCases.id, { onDelete: 'set null' }),
@@ -70,20 +124,51 @@ export const exportJobs = pgTable('export_jobs', {
// --- Relations ---
export const ediscoveryCasesRelations = relations(ediscoveryCases, ({ many }) => ({
legalHolds: many(legalHolds),
exportJobs: many(exportJobs),
export const retentionPoliciesRelations = relations(retentionPolicies, ({ many }) => ({
// Add relations if needed
}));
export const legalHoldsRelations = relations(legalHolds, ({ one }) => ({
export const retentionLabelsRelations = relations(retentionLabels, ({ many }) => ({
emailRetentionLabels: many(emailRetentionLabels),
}));
export const emailRetentionLabelsRelations = relations(emailRetentionLabels, ({ one }) => ({
label: one(retentionLabels, {
fields: [emailRetentionLabels.labelId],
references: [retentionLabels.id],
}),
email: one(archivedEmails, {
fields: [emailRetentionLabels.emailId],
references: [archivedEmails.id],
}),
appliedByUser: one(users, {
fields: [emailRetentionLabels.appliedByUserId],
references: [users.id],
}),
}));
export const legalHoldsRelations = relations(legalHolds, ({ one, many }) => ({
emailLegalHolds: many(emailLegalHolds),
ediscoveryCase: one(ediscoveryCases, {
fields: [legalHolds.caseId],
references: [ediscoveryCases.id],
}),
custodian: one(custodians, {
fields: [legalHolds.custodianId],
references: [custodians.id],
}));
export const emailLegalHoldsRelations = relations(emailLegalHolds, ({ one }) => ({
legalHold: one(legalHolds, {
fields: [emailLegalHolds.legalHoldId],
references: [legalHolds.id],
}),
email: one(archivedEmails, {
fields: [emailLegalHolds.emailId],
references: [archivedEmails.id],
}),
}));
export const ediscoveryCasesRelations = relations(ediscoveryCases, ({ many }) => ({
legalHolds: many(legalHolds),
exportJobs: many(exportJobs),
}));
export const exportJobsRelations = relations(exportJobs, ({ one }) => ({

View File

@@ -1,7 +1,16 @@
import { config } from '../config';
import i18next from 'i18next';
export function checkDeletionEnabled() {
interface DeletionOptions {
allowSystemDelete?: boolean;
}
export function checkDeletionEnabled(options?: DeletionOptions) {
// If system delete is allowed (e.g. by retention policy), bypass the config check
if (options?.allowSystemDelete) {
return;
}
if (!config.app.enableDeletion) {
const errorMessage = i18next.t('Deletion is disabled for this instance.');
throw new Error(errorMessage);

View File

@@ -0,0 +1,36 @@
import { logger } from '../config/logger';
export type DeletionCheck = (emailId: string) => Promise<boolean>;
export class RetentionHook {
private static checks: DeletionCheck[] = [];
/**
* Registers a function that checks if an email can be deleted.
* The function should return true if deletion is allowed, false otherwise.
*/
static registerCheck(check: DeletionCheck) {
this.checks.push(check);
}
/**
* Verifies if an email can be deleted by running all registered checks.
* If ANY check returns false, deletion is blocked.
*/
static async canDelete(emailId: string): Promise<boolean> {
for (const check of this.checks) {
try {
const allowed = await check(emailId);
if (!allowed) {
logger.info(`Deletion blocked by retention check for email ${emailId}`);
return false;
}
} catch (error) {
logger.error(`Error in retention check for email ${emailId}:`, error);
// Fail safe: if a check errors, assume we CANNOT delete to be safe
return false;
}
}
return true;
}
}

View File

@@ -6,5 +6,8 @@ export * from './services/AuditService';
export * from './api/middleware/requireAuth';
export * from './api/middleware/requirePermission';
export { db } from './database';
export * as drizzleOrm from 'drizzle-orm';
export * from './database/schema';
export { AuditService } from './services/AuditService';
export * from './config'
export * from './jobs/queues'
export { RetentionHook } from './hooks/RetentionHook';

View File

@@ -43,7 +43,16 @@ export const processMailboxProcessor = async (job: Job<IProcessMailboxJob, SyncS
const connector = EmailProviderFactory.createConnector(source);
const ingestionService = new IngestionService();
for await (const email of connector.fetchEmails(userEmail, source.syncState)) {
// Create a callback to check for duplicates without fetching full email content
const checkDuplicate = async (messageId: string) => {
return await IngestionService.doesEmailExist(messageId, ingestionSourceId);
};
for await (const email of connector.fetchEmails(
userEmail,
source.syncState,
checkDuplicate
)) {
if (email) {
const processedEmail = await ingestionService.processEmail(
email,

View File

@@ -27,3 +27,9 @@ export const indexingQueue = new Queue('indexing', {
connection,
defaultJobOptions,
});
// Queue for the Data Lifecycle Manager (retention policy enforcement)
export const complianceLifecycleQueue = new Queue('compliance-lifecycle', {
connection,
defaultJobOptions,
});

View File

@@ -0,0 +1,69 @@
{
"auth": {
"setup": {
"allFieldsRequired": "Изискват се поща, парола и име",
"alreadyCompleted": "Настройката вече е завършена."
},
"login": {
"emailAndPasswordRequired": "Изискват се поща и парола",
"invalidCredentials": "Невалидни идентификационни данни"
}
},
"errors": {
"internalServerError": "Възникна вътрешна грешка в сървъра",
"demoMode": "Тази операция не е разрешена в демо режим",
"unauthorized": "Неоторизирано",
"unknown": "Възникна неизвестна грешка",
"noPermissionToAction": "Нямате разрешение да извършите текущото действие."
},
"user": {
"notFound": "Потребителят не е открит",
"cannotDeleteOnlyUser": "Опитвате се да изтриете единствения потребител в базата данни, това не е позволено.",
"requiresSuperAdminRole": "За управление на потребители е необходима роля на супер администратор."
},
"iam": {
"failedToGetRoles": "Неуспешно получаване на роли.",
"roleNotFound": "Ролята не е намерена.",
"failedToGetRole": "Неуспешно получаване на роля.",
"missingRoleFields": "Липсват задължителни полета: име и политика.",
"invalidPolicy": "Невалидно твърдение за политика:",
"failedToCreateRole": "Създаването на роля неуспешно.",
"failedToDeleteRole": "Изтриването на роля неуспешно.",
"missingUpdateFields": "Липсват полета за актуализиране: име или политики.",
"failedToUpdateRole": "Актуализирането на ролята неуспешно.",
"requiresSuperAdminRole": "За управление на роли е необходима роля на супер администратор."
},
"settings": {
"failedToRetrieve": "Неуспешно извличане на настройките",
"failedToUpdate": "Неуспешно актуализиране на настройките",
"noPermissionToUpdate": "Нямате разрешение да актуализирате системните настройки."
},
"dashboard": {
"permissionRequired": "Необходимо ви е разрешение за четене на таблото, за да видите данните от него."
},
"ingestion": {
"failedToCreate": "Създаването на източник за приемане не бе успешно поради грешка при свързване.",
"notFound": "Източникът за приемане не е намерен",
"initialImportTriggered": "Първоначалният импорт е задействан успешно.",
"forceSyncTriggered": "Принудителното синхронизиране е задействано успешно."
},
"archivedEmail": {
"notFound": "Архивираната поща не е намерена"
},
"search": {
"keywordsRequired": "Ключовите думи са задължителни"
},
"storage": {
"filePathRequired": "Пътят към файла е задължителен",
"invalidFilePath": "Невалиден път към файла",
"fileNotFound": "Файлът не е намерен",
"downloadError": "Грешка при изтегляне на файла"
},
"apiKeys": {
"generateSuccess": "API ключът е генериран успешно.",
"deleteSuccess": "API ключът е успешно изтрит."
},
"api": {
"requestBodyInvalid": "Невалидно съдържание на заявката."
}
}

View File

@@ -20,6 +20,7 @@ import type { Readable } from 'stream';
import { AuditService } from './AuditService';
import { User } from '@open-archiver/types';
import { checkDeletionEnabled } from '../helpers/deletionGuard';
import { RetentionHook } from '../hooks/RetentionHook';
interface DbRecipients {
to: { name: string; address: string }[];
@@ -197,9 +198,22 @@ export class ArchivedEmailService {
public static async deleteArchivedEmail(
emailId: string,
actor: User,
actorIp: string
actorIp: string,
options: {
systemDelete?: boolean;
/**
* Human-readable name of the retention rule that triggered deletion
*/
governingRule?: string;
} = {}
): Promise<void> {
checkDeletionEnabled();
checkDeletionEnabled({ allowSystemDelete: options.systemDelete });
const canDelete = await RetentionHook.canDelete(emailId);
if (!canDelete) {
throw new Error('Deletion blocked by retention policy (Legal Hold or similar).');
}
const [email] = await db
.select()
.from(archivedEmails)
@@ -262,15 +276,22 @@ export class ArchivedEmailService {
await db.delete(archivedEmails).where(eq(archivedEmails.id, emailId));
// Build audit details: system-initiated deletions carry retention context
// for GoBD compliance; manual deletions record only the reason.
const auditDetails: Record<string, unknown> = {
reason: options.systemDelete ? 'RetentionExpiration' : 'ManualDeletion',
};
if (options.systemDelete && options.governingRule) {
auditDetails.governingRule = options.governingRule;
}
await this.auditService.createAuditLog({
actorIdentifier: actor.id,
actionType: 'DELETE',
targetType: 'ArchivedEmail',
targetId: emailId,
actorIp,
details: {
reason: 'ManualDeletion',
},
details: auditDetails,
});
}
}

View File

@@ -22,7 +22,8 @@ export interface IEmailConnector {
testConnection(): Promise<boolean>;
fetchEmails(
userEmail: string,
syncState?: SyncState | null
syncState?: SyncState | null,
checkDuplicate?: (messageId: string) => Promise<boolean>
): AsyncGenerator<EmailObject | null>;
getUpdatedSyncState(userEmail?: string): SyncState;
listAllUsers(): AsyncGenerator<MailboxUser>;

View File

@@ -85,7 +85,7 @@ export class IngestionService {
const decryptedSource = this.decryptSource(newSource);
if (!decryptedSource) {
await this.delete(newSource.id, actor, actorIp);
await this.delete(newSource.id, actor, actorIp, true);
throw new Error(
'Failed to process newly created ingestion source due to a decryption error.'
);
@@ -107,7 +107,7 @@ export class IngestionService {
}
} catch (error) {
// If connection fails, delete the newly created source and throw the error.
await this.delete(decryptedSource.id, actor, actorIp);
await this.delete(decryptedSource.id, actor, actorIp, true);
throw error;
}
}
@@ -205,8 +205,15 @@ export class IngestionService {
return decryptedSource;
}
public static async delete(id: string, actor: User, actorIp: string): Promise<IngestionSource> {
checkDeletionEnabled();
public static async delete(
id: string,
actor: User,
actorIp: string,
force: boolean = false
): Promise<IngestionSource> {
if (!force) {
checkDeletionEnabled();
}
const source = await this.findById(id);
if (!source) {
throw new Error('Ingestion source not found');
@@ -219,7 +226,8 @@ export class IngestionService {
if (
(source.credentials.type === 'pst_import' ||
source.credentials.type === 'eml_import') &&
source.credentials.type === 'eml_import' ||
source.credentials.type === 'mbox_import') &&
source.credentials.uploadedFilePath &&
(await storage.exists(source.credentials.uploadedFilePath))
) {
@@ -382,6 +390,25 @@ export class IngestionService {
}
}
/**
* Quickly checks if an email exists in the database by its Message-ID header.
* This is used to skip downloading duplicate emails during ingestion.
*/
public static async doesEmailExist(
messageId: string,
ingestionSourceId: string
): Promise<boolean> {
const existingEmail = await db.query.archivedEmails.findFirst({
where: and(
eq(archivedEmails.messageIdHeader, messageId),
eq(archivedEmails.ingestionSourceId, ingestionSourceId)
),
columns: { id: true },
});
return !!existingEmail;
}
public async processEmail(
email: EmailObject,
source: IngestionSource,

View File

@@ -32,29 +32,72 @@ export class EMLConnector implements IEmailConnector {
this.storage = new StorageService();
}
private getFilePath(): string {
return this.credentials.localFilePath || this.credentials.uploadedFilePath || '';
}
private getDisplayName(): string {
if (this.credentials.uploadedFileName) {
return this.credentials.uploadedFileName;
}
if (this.credentials.localFilePath) {
const parts = this.credentials.localFilePath.split('/');
return parts[parts.length - 1].replace('.zip', '');
}
return `eml-import-${new Date().getTime()}`;
}
private async getFileStream(): Promise<NodeJS.ReadableStream> {
if (this.credentials.localFilePath) {
return createReadStream(this.credentials.localFilePath);
}
return this.storage.get(this.getFilePath());
}
public async testConnection(): Promise<boolean> {
try {
if (!this.credentials.uploadedFilePath) {
throw Error('EML file path not provided.');
const filePath = this.getFilePath();
if (!filePath) {
throw Error('EML Zip file path not provided.');
}
if (!this.credentials.uploadedFilePath.includes('.zip')) {
if (!filePath.includes('.zip')) {
throw Error('Provided file is not in the ZIP format.');
}
const fileExist = await this.storage.exists(this.credentials.uploadedFilePath);
let fileExist = false;
if (this.credentials.localFilePath) {
try {
await fs.access(this.credentials.localFilePath);
fileExist = true;
} catch {
fileExist = false;
}
} else {
fileExist = await this.storage.exists(filePath);
}
if (!fileExist) {
throw Error('EML file upload not finished yet, please wait.');
if (this.credentials.localFilePath) {
throw Error(`EML Zip file not found at path: ${this.credentials.localFilePath}`);
} else {
throw Error(
'Uploaded EML Zip file not found. The upload may not have finished yet, or it failed.'
);
}
}
return true;
} catch (error) {
logger.error({ error, credentials: this.credentials }, 'EML file validation failed.');
logger.error(
{ error, credentials: this.credentials },
'EML Zip file validation failed.'
);
throw error;
}
}
public async *listAllUsers(): AsyncGenerator<MailboxUser> {
const displayName =
this.credentials.uploadedFileName || `eml-import-${new Date().getTime()}`;
const displayName = this.getDisplayName();
logger.info(`Found potential mailbox: ${displayName}`);
const constructedPrimaryEmail = `${displayName.replace(/ /g, '.').toLowerCase()}@eml.local`;
yield {
@@ -68,10 +111,8 @@ export class EMLConnector implements IEmailConnector {
userEmail: string,
syncState?: SyncState | null
): AsyncGenerator<EmailObject | null> {
const fileStream = await this.storage.get(this.credentials.uploadedFilePath);
const fileStream = await this.getFileStream();
const tempDir = await fs.mkdtemp(join('/tmp', `eml-import-${new Date().getTime()}`));
const unzippedPath = join(tempDir, 'unzipped');
await fs.mkdir(unzippedPath);
const zipFilePath = join(tempDir, 'eml.zip');
try {
@@ -82,99 +123,150 @@ export class EMLConnector implements IEmailConnector {
dest.on('error', reject);
});
await this.extract(zipFilePath, unzippedPath);
const files = await this.getAllFiles(unzippedPath);
for (const file of files) {
if (file.endsWith('.eml')) {
try {
// logger.info({ file }, 'Processing EML file.');
const stream = createReadStream(file);
const content = await streamToBuffer(stream);
// logger.info({ file, size: content.length }, 'Read file to buffer.');
let relativePath = file.substring(unzippedPath.length + 1);
if (dirname(relativePath) === '.') {
relativePath = '';
} else {
relativePath = dirname(relativePath);
}
const emailObject = await this.parseMessage(content, relativePath);
// logger.info({ file, messageId: emailObject.id }, 'Parsed email message.');
yield emailObject;
} catch (error) {
logger.error(
{ error, file },
'Failed to process a single EML file. Skipping.'
);
}
}
}
yield* this.processZipEntries(zipFilePath);
} catch (error) {
logger.error({ error }, 'Failed to fetch email.');
throw error;
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
try {
await this.storage.delete(this.credentials.uploadedFilePath);
} catch (error) {
logger.error(
{ error, file: this.credentials.uploadedFilePath },
'Failed to delete EML file after processing.'
);
if (this.credentials.uploadedFilePath && !this.credentials.localFilePath) {
try {
await this.storage.delete(this.credentials.uploadedFilePath);
} catch (error) {
logger.error(
{ error, file: this.credentials.uploadedFilePath },
'Failed to delete EML file after processing.'
);
}
}
}
}
private extract(zipFilePath: string, dest: string): Promise<void> {
return new Promise((resolve, reject) => {
private async *processZipEntries(zipFilePath: string): AsyncGenerator<EmailObject | null> {
// Open the ZIP file.
// Note: yauzl requires random access, so we must use the file on disk.
const zipfile = await new Promise<yauzl.ZipFile>((resolve, reject) => {
yauzl.open(zipFilePath, { lazyEntries: true, decodeStrings: false }, (err, zipfile) => {
if (err) reject(err);
zipfile.on('error', reject);
zipfile.readEntry();
zipfile.on('entry', (entry) => {
const fileName = entry.fileName.toString('utf8');
// Ignore macOS-specific metadata files.
if (fileName.startsWith('__MACOSX/')) {
zipfile.readEntry();
return;
}
const entryPath = join(dest, fileName);
if (/\/$/.test(fileName)) {
fs.mkdir(entryPath, { recursive: true })
.then(() => zipfile.readEntry())
.catch(reject);
} else {
zipfile.openReadStream(entry, (err, readStream) => {
if (err) reject(err);
const writeStream = createWriteStream(entryPath);
readStream.pipe(writeStream);
writeStream.on('finish', () => zipfile.readEntry());
writeStream.on('error', reject);
});
}
});
zipfile.on('end', () => resolve());
if (err || !zipfile) return reject(err);
resolve(zipfile);
});
});
}
private async getAllFiles(dirPath: string, arrayOfFiles: string[] = []): Promise<string[]> {
const files = await fs.readdir(dirPath);
// Create an async iterator for zip entries
const entryIterator = this.zipEntryGenerator(zipfile);
for (const file of files) {
const fullPath = join(dirPath, file);
if ((await fs.stat(fullPath)).isDirectory()) {
await this.getAllFiles(fullPath, arrayOfFiles);
} else {
arrayOfFiles.push(fullPath);
for await (const { entry, openReadStream } of entryIterator) {
const fileName = entry.fileName.toString();
if (fileName.startsWith('__MACOSX/') || /\/$/.test(fileName)) {
continue;
}
if (fileName.endsWith('.eml')) {
try {
const readStream = await openReadStream();
const relativePath = dirname(fileName) === '.' ? '' : dirname(fileName);
const emailObject = await this.parseMessage(readStream, relativePath);
yield emailObject;
} catch (error) {
logger.error(
{ error, file: fileName },
'Failed to process a single EML file from zip. Skipping.'
);
}
}
}
return arrayOfFiles;
}
private async parseMessage(emlBuffer: Buffer, path: string): Promise<EmailObject> {
private async *zipEntryGenerator(
zipfile: yauzl.ZipFile
): AsyncGenerator<{ entry: yauzl.Entry; openReadStream: () => Promise<Readable> }> {
let resolveNext: ((value: any) => void) | null = null;
let rejectNext: ((reason?: any) => void) | null = null;
let finished = false;
const queue: yauzl.Entry[] = [];
zipfile.readEntry();
zipfile.on('entry', (entry) => {
if (resolveNext) {
const resolve = resolveNext;
resolveNext = null;
rejectNext = null;
resolve(entry);
} else {
queue.push(entry);
}
});
zipfile.on('end', () => {
finished = true;
if (resolveNext) {
const resolve = resolveNext;
resolveNext = null;
rejectNext = null;
resolve(null); // Signal end
}
});
zipfile.on('error', (err) => {
finished = true;
if (rejectNext) {
const reject = rejectNext;
resolveNext = null;
rejectNext = null;
reject(err);
}
});
while (!finished || queue.length > 0) {
if (queue.length > 0) {
const entry = queue.shift()!;
yield {
entry,
openReadStream: () =>
new Promise<Readable>((resolve, reject) => {
zipfile.openReadStream(entry, (err, stream) => {
if (err || !stream) return reject(err);
resolve(stream);
});
}),
};
zipfile.readEntry(); // Read next entry only after yielding
} else {
const entry = await new Promise<yauzl.Entry | null>((resolve, reject) => {
resolveNext = resolve;
rejectNext = reject;
});
if (entry) {
yield {
entry,
openReadStream: () =>
new Promise<Readable>((resolve, reject) => {
zipfile.openReadStream(entry, (err, stream) => {
if (err || !stream) return reject(err);
resolve(stream);
});
}),
};
zipfile.readEntry(); // Read next entry only after yielding
} else {
break; // End of zip
}
}
}
}
private async parseMessage(
input: Buffer | Readable,
path: string
): Promise<EmailObject> {
let emlBuffer: Buffer;
if (Buffer.isBuffer(input)) {
emlBuffer = input;
} else {
emlBuffer = await streamToBuffer(input);
}
const parsedEmail: ParsedMail = await simpleParser(emlBuffer);
const attachments = parsedEmail.attachments.map((attachment: Attachment) => ({

View File

@@ -132,7 +132,8 @@ export class GoogleWorkspaceConnector implements IEmailConnector {
*/
public async *fetchEmails(
userEmail: string,
syncState?: SyncState | null
syncState?: SyncState | null,
checkDuplicate?: (messageId: string) => Promise<boolean>
): AsyncGenerator<EmailObject> {
const authClient = this.getAuthClient(userEmail, [
'https://www.googleapis.com/auth/gmail.readonly',
@@ -144,7 +145,7 @@ export class GoogleWorkspaceConnector implements IEmailConnector {
// If no sync state is provided for this user, this is an initial import. Get all messages.
if (!startHistoryId) {
yield* this.fetchAllMessagesForUser(gmail, userEmail);
yield* this.fetchAllMessagesForUser(gmail, userEmail, checkDuplicate);
return;
}
@@ -170,6 +171,16 @@ export class GoogleWorkspaceConnector implements IEmailConnector {
if (messageAdded.message?.id) {
try {
const messageId = messageAdded.message.id;
// Optimization: Check for existence before fetching full content
if (checkDuplicate && (await checkDuplicate(messageId))) {
logger.debug(
{ messageId, userEmail },
'Skipping duplicate email (pre-check)'
);
continue;
}
const metadataResponse = await gmail.users.messages.get({
userId: userEmail,
id: messageId,
@@ -258,8 +269,17 @@ export class GoogleWorkspaceConnector implements IEmailConnector {
private async *fetchAllMessagesForUser(
gmail: gmail_v1.Gmail,
userEmail: string
userEmail: string,
checkDuplicate?: (messageId: string) => Promise<boolean>
): AsyncGenerator<EmailObject> {
// Capture the history ID at the start to ensure no emails are missed during the import process.
// Any emails arriving during this import will be covered by the next sync starting from this point.
// Overlaps are handled by the duplicate check.
const profileResponse = await gmail.users.getProfile({ userId: userEmail });
if (profileResponse.data.historyId) {
this.newHistoryId = profileResponse.data.historyId;
}
let pageToken: string | undefined = undefined;
do {
const listResponse: Common.GaxiosResponseWithHTTP2<gmail_v1.Schema$ListMessagesResponse> =
@@ -277,6 +297,16 @@ export class GoogleWorkspaceConnector implements IEmailConnector {
if (message.id) {
try {
const messageId = message.id;
// Optimization: Check for existence before fetching full content
if (checkDuplicate && (await checkDuplicate(messageId))) {
logger.debug(
{ messageId, userEmail },
'Skipping duplicate email (pre-check)'
);
continue;
}
const metadataResponse = await gmail.users.messages.get({
userId: userEmail,
id: messageId,
@@ -352,12 +382,6 @@ export class GoogleWorkspaceConnector implements IEmailConnector {
}
pageToken = listResponse.data.nextPageToken ?? undefined;
} while (pageToken);
// After fetching all messages, get the latest history ID to use as the starting point for the next sync.
const profileResponse = await gmail.users.getProfile({ userId: userEmail });
if (profileResponse.data.historyId) {
this.newHistoryId = profileResponse.data.historyId;
}
}
public getUpdatedSyncState(userEmail: string): SyncState {

View File

@@ -27,7 +27,7 @@ export class ImapConnector implements IEmailConnector {
port: this.credentials.port,
secure: this.credentials.secure,
tls: {
rejectUnauthorized: this.credentials.allowInsecureCert,
rejectUnauthorized: !this.credentials.allowInsecureCert,
requestCert: true,
},
auth: {
@@ -142,7 +142,8 @@ export class ImapConnector implements IEmailConnector {
public async *fetchEmails(
userEmail: string,
syncState?: SyncState | null
syncState?: SyncState | null,
checkDuplicate?: (messageId: string) => Promise<boolean>
): AsyncGenerator<EmailObject | null> {
try {
// list all mailboxes first
@@ -218,6 +219,22 @@ export class ImapConnector implements IEmailConnector {
this.newMaxUids[mailboxPath] = msg.uid;
}
// Optimization: Verify existence using Message-ID from envelope before fetching full body
if (checkDuplicate && msg.envelope?.messageId) {
const isDuplicate = await checkDuplicate(msg.envelope.messageId);
if (isDuplicate) {
logger.debug(
{
mailboxPath,
uid: msg.uid,
messageId: msg.envelope.messageId,
},
'Skipping duplicate email (pre-check)'
);
continue;
}
}
logger.debug({ mailboxPath, uid: msg.uid }, 'Processing message');
if (msg.envelope && msg.source) {

View File

@@ -12,6 +12,7 @@ import { getThreadId } from './helpers/utils';
import { StorageService } from '../StorageService';
import { Readable, Transform } from 'stream';
import { createHash } from 'crypto';
import { promises as fs, createReadStream } from 'fs';
class MboxSplitter extends Transform {
private buffer: Buffer = Buffer.alloc(0);
@@ -60,27 +61,59 @@ export class MboxConnector implements IEmailConnector {
public async testConnection(): Promise<boolean> {
try {
if (!this.credentials.uploadedFilePath) {
const filePath = this.getFilePath();
if (!filePath) {
throw Error('Mbox file path not provided.');
}
if (!this.credentials.uploadedFilePath.includes('.mbox')) {
if (!filePath.includes('.mbox')) {
throw Error('Provided file is not in the MBOX format.');
}
const fileExist = await this.storage.exists(this.credentials.uploadedFilePath);
let fileExist = false;
if (this.credentials.localFilePath) {
try {
await fs.access(this.credentials.localFilePath);
fileExist = true;
} catch {
fileExist = false;
}
} else {
fileExist = await this.storage.exists(filePath);
}
if (!fileExist) {
throw Error('Mbox file upload not finished yet, please wait.');
if (this.credentials.localFilePath) {
throw Error(`Mbox file not found at path: ${this.credentials.localFilePath}`);
} else {
throw Error(
'Uploaded Mbox file not found. The upload may not have finished yet, or it failed.'
);
}
}
return true;
} catch (error) {
logger.error({ error, credentials: this.credentials }, 'Mbox file validation failed.');
logger.error(
{ error, credentials: this.credentials },
'Mbox file validation failed.'
);
throw error;
}
}
private getFilePath(): string {
return this.credentials.localFilePath || this.credentials.uploadedFilePath || '';
}
private async getFileStream(): Promise<NodeJS.ReadableStream> {
if (this.credentials.localFilePath) {
return createReadStream(this.credentials.localFilePath);
}
return this.storage.getStream(this.getFilePath());
}
public async *listAllUsers(): AsyncGenerator<MailboxUser> {
const displayName =
this.credentials.uploadedFileName || `mbox-import-${new Date().getTime()}`;
const displayName = this.getDisplayName();
logger.info(`Found potential mailbox: ${displayName}`);
const constructedPrimaryEmail = `${displayName.replace(/ /g, '.').toLowerCase()}@mbox.local`;
yield {
@@ -90,11 +123,23 @@ export class MboxConnector implements IEmailConnector {
};
}
private getDisplayName(): string {
if (this.credentials.uploadedFileName) {
return this.credentials.uploadedFileName;
}
if (this.credentials.localFilePath) {
const parts = this.credentials.localFilePath.split('/');
return parts[parts.length - 1].replace('.mbox', '');
}
return `mbox-import-${new Date().getTime()}`;
}
public async *fetchEmails(
userEmail: string,
syncState?: SyncState | null
): AsyncGenerator<EmailObject | null> {
const fileStream = await this.storage.getStream(this.credentials.uploadedFilePath);
const filePath = this.getFilePath();
const fileStream = await this.getFileStream();
const mboxSplitter = new MboxSplitter();
const emailStream = fileStream.pipe(mboxSplitter);
@@ -104,22 +149,21 @@ export class MboxConnector implements IEmailConnector {
yield emailObject;
} catch (error) {
logger.error(
{ error, file: this.credentials.uploadedFilePath },
{ error, file: filePath },
'Failed to process a single message from mbox file. Skipping.'
);
}
}
// After the stream is fully consumed, delete the file.
// The `for await...of` loop ensures streams are properly closed on completion,
// so we can safely delete the file here without causing a hang.
try {
await this.storage.delete(this.credentials.uploadedFilePath);
} catch (error) {
logger.error(
{ error, file: this.credentials.uploadedFilePath },
'Failed to delete mbox file after processing.'
);
if (this.credentials.uploadedFilePath && !this.credentials.localFilePath) {
try {
await this.storage.delete(filePath);
} catch (error) {
logger.error(
{ error, file: filePath },
'Failed to delete mbox file after processing.'
);
}
}
}

View File

@@ -14,7 +14,7 @@ import { StorageService } from '../StorageService';
import { Readable } from 'stream';
import { createHash } from 'crypto';
import { join } from 'path';
import { createWriteStream, promises as fs } from 'fs';
import { createWriteStream, createReadStream, promises as fs } from 'fs';
// We have to hardcode names for deleted and trash folders here as current lib doesn't support looking into PST properties.
const DELETED_FOLDERS = new Set([
@@ -111,8 +111,19 @@ export class PSTConnector implements IEmailConnector {
this.storage = new StorageService();
}
private getFilePath(): string {
return this.credentials.localFilePath || this.credentials.uploadedFilePath || '';
}
private async getFileStream(): Promise<NodeJS.ReadableStream> {
if (this.credentials.localFilePath) {
return createReadStream(this.credentials.localFilePath);
}
return this.storage.getStream(this.getFilePath());
}
private async loadPstFile(): Promise<{ pstFile: PSTFile; tempDir: string }> {
const fileStream = await this.storage.getStream(this.credentials.uploadedFilePath);
const fileStream = await this.getFileStream();
const tempDir = await fs.mkdtemp(join('/tmp', `pst-import-${new Date().getTime()}`));
const tempFilePath = join(tempDir, 'temp.pst');
@@ -129,19 +140,41 @@ export class PSTConnector implements IEmailConnector {
public async testConnection(): Promise<boolean> {
try {
if (!this.credentials.uploadedFilePath) {
const filePath = this.getFilePath();
if (!filePath) {
throw Error('PST file path not provided.');
}
if (!this.credentials.uploadedFilePath.includes('.pst')) {
if (!filePath.includes('.pst')) {
throw Error('Provided file is not in the PST format.');
}
const fileExist = await this.storage.exists(this.credentials.uploadedFilePath);
let fileExist = false;
if (this.credentials.localFilePath) {
try {
await fs.access(this.credentials.localFilePath);
fileExist = true;
} catch {
fileExist = false;
}
} else {
fileExist = await this.storage.exists(filePath);
}
if (!fileExist) {
throw Error('PST file upload not finished yet, please wait.');
if (this.credentials.localFilePath) {
throw Error(`PST file not found at path: ${this.credentials.localFilePath}`);
} else {
throw Error(
'Uploaded PST file not found. The upload may not have finished yet, or it failed.'
);
}
}
return true;
} catch (error) {
logger.error({ error, credentials: this.credentials }, 'PST file validation failed.');
logger.error(
{ error, credentials: this.credentials },
'PST file validation failed.'
);
throw error;
}
}
@@ -200,13 +233,15 @@ export class PSTConnector implements IEmailConnector {
if (tempDir) {
await fs.rm(tempDir, { recursive: true, force: true });
}
try {
await this.storage.delete(this.credentials.uploadedFilePath);
} catch (error) {
logger.error(
{ error, file: this.credentials.uploadedFilePath },
'Failed to delete PST file after processing.'
);
if (this.credentials.uploadedFilePath && !this.credentials.localFilePath) {
try {
await this.storage.delete(this.credentials.uploadedFilePath);
} catch (error) {
logger.error(
{ error, file: this.credentials.uploadedFilePath },
'Failed to delete PST file after processing.'
);
}
}
}
}

View File

@@ -7,6 +7,7 @@
import { Label } from '$lib/components/ui/label';
import * as Select from '$lib/components/ui/select';
import * as Alert from '$lib/components/ui/alert/index.js';
import * as RadioGroup from '$lib/components/ui/radio-group/index.js';
import { Textarea } from '$lib/components/ui/textarea/index.js';
import { setAlert } from '$lib/components/custom/alert/alert-state.svelte';
import { api } from '$lib/api.client';
@@ -70,6 +71,27 @@
let fileUploading = $state(false);
let importMethod = $state<'upload' | 'local'>(
source?.credentials && 'localFilePath' in source.credentials && source.credentials.localFilePath
? 'local'
: 'upload'
);
$effect(() => {
if (importMethod === 'upload') {
if ('localFilePath' in formData.providerConfig) {
delete formData.providerConfig.localFilePath;
}
} else {
if ('uploadedFilePath' in formData.providerConfig) {
delete formData.providerConfig.uploadedFilePath;
}
if ('uploadedFileName' in formData.providerConfig) {
delete formData.providerConfig.uploadedFileName;
}
}
});
const handleSubmit = async (event: Event) => {
event.preventDefault();
isSubmitting = true;
@@ -236,59 +258,143 @@
/>
</div>
{:else if formData.provider === 'pst_import'}
<div class="grid grid-cols-4 items-center gap-4">
<Label for="pst-file" class="text-left"
>{$t('app.components.ingestion_source_form.pst_file')}</Label
>
<div class="col-span-3 flex flex-row items-center space-x-2">
<Input
id="pst-file"
type="file"
class=""
accept=".pst"
onchange={handleFileChange}
/>
{#if fileUploading}
<span class=" text-primary animate-spin"><Loader2 /></span>
{/if}
</div>
<div class="grid grid-cols-4 items-start gap-4">
<Label class="text-left pt-2">{$t('app.components.ingestion_source_form.import_method')}</Label>
<RadioGroup.Root bind:value={importMethod} class="col-span-3 flex flex-col space-y-1">
<div class="flex items-center space-x-2">
<RadioGroup.Item value="upload" id="pst-upload" />
<Label for="pst-upload">{$t('app.components.ingestion_source_form.upload_file')}</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="local" id="pst-local" />
<Label for="pst-local">{$t('app.components.ingestion_source_form.local_path')}</Label>
</div>
</RadioGroup.Root>
</div>
{#if importMethod === 'upload'}
<div class="grid grid-cols-4 items-center gap-4">
<Label for="pst-file" class="text-left"
>{$t('app.components.ingestion_source_form.pst_file')}</Label
>
<div class="col-span-3 flex flex-row items-center space-x-2">
<Input
id="pst-file"
type="file"
class=""
accept=".pst"
onchange={handleFileChange}
/>
{#if fileUploading}
<span class=" text-primary animate-spin"><Loader2 /></span>
{/if}
</div>
</div>
{:else}
<div class="grid grid-cols-4 items-center gap-4">
<Label for="pst-local-path" class="text-left"
>{$t('app.components.ingestion_source_form.local_file_path')}</Label
>
<Input
id="pst-local-path"
bind:value={formData.providerConfig.localFilePath}
placeholder="/path/to/file.pst"
class="col-span-3"
/>
</div>
{/if}
{:else if formData.provider === 'eml_import'}
<div class="grid grid-cols-4 items-center gap-4">
<Label for="eml-file" class="text-left"
>{$t('app.components.ingestion_source_form.eml_file')}</Label
>
<div class="col-span-3 flex flex-row items-center space-x-2">
<Input
id="eml-file"
type="file"
class=""
accept=".zip"
onchange={handleFileChange}
/>
{#if fileUploading}
<span class=" text-primary animate-spin"><Loader2 /></span>
{/if}
</div>
<div class="grid grid-cols-4 items-start gap-4">
<Label class="text-left pt-2">{$t('app.components.ingestion_source_form.import_method')}</Label>
<RadioGroup.Root bind:value={importMethod} class="col-span-3 flex flex-col space-y-1">
<div class="flex items-center space-x-2">
<RadioGroup.Item value="upload" id="eml-upload" />
<Label for="eml-upload">{$t('app.components.ingestion_source_form.upload_file')}</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="local" id="eml-local" />
<Label for="eml-local">{$t('app.components.ingestion_source_form.local_path')}</Label>
</div>
</RadioGroup.Root>
</div>
{#if importMethod === 'upload'}
<div class="grid grid-cols-4 items-center gap-4">
<Label for="eml-file" class="text-left"
>{$t('app.components.ingestion_source_form.eml_file')}</Label
>
<div class="col-span-3 flex flex-row items-center space-x-2">
<Input
id="eml-file"
type="file"
class=""
accept=".zip"
onchange={handleFileChange}
/>
{#if fileUploading}
<span class=" text-primary animate-spin"><Loader2 /></span>
{/if}
</div>
</div>
{:else}
<div class="grid grid-cols-4 items-center gap-4">
<Label for="eml-local-path" class="text-left"
>{$t('app.components.ingestion_source_form.local_file_path')}</Label
>
<Input
id="eml-local-path"
bind:value={formData.providerConfig.localFilePath}
placeholder="/path/to/file.zip"
class="col-span-3"
/>
</div>
{/if}
{:else if formData.provider === 'mbox_import'}
<div class="grid grid-cols-4 items-center gap-4">
<Label for="mbox-file" class="text-left"
>{$t('app.components.ingestion_source_form.mbox_file')}</Label
>
<div class="col-span-3 flex flex-row items-center space-x-2">
<Input
id="mbox-file"
type="file"
class=""
accept=".mbox"
onchange={handleFileChange}
/>
{#if fileUploading}
<span class=" text-primary animate-spin"><Loader2 /></span>
{/if}
</div>
<div class="grid grid-cols-4 items-start gap-4">
<Label class="text-left pt-2">{$t('app.components.ingestion_source_form.import_method')}</Label>
<RadioGroup.Root bind:value={importMethod} class="col-span-3 flex flex-col space-y-1">
<div class="flex items-center space-x-2">
<RadioGroup.Item value="upload" id="mbox-upload" />
<Label for="mbox-upload">{$t('app.components.ingestion_source_form.upload_file')}</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="local" id="mbox-local" />
<Label for="mbox-local">{$t('app.components.ingestion_source_form.local_path')}</Label>
</div>
</RadioGroup.Root>
</div>
{#if importMethod === 'upload'}
<div class="grid grid-cols-4 items-center gap-4">
<Label for="mbox-file" class="text-left"
>{$t('app.components.ingestion_source_form.mbox_file')}</Label
>
<div class="col-span-3 flex flex-row items-center space-x-2">
<Input
id="mbox-file"
type="file"
class=""
accept=".mbox"
onchange={handleFileChange}
/>
{#if fileUploading}
<span class=" text-primary animate-spin"><Loader2 /></span>
{/if}
</div>
</div>
{:else}
<div class="grid grid-cols-4 items-center gap-4">
<Label for="mbox-local-path" class="text-left"
>{$t('app.components.ingestion_source_form.local_file_path')}</Label
>
<Input
id="mbox-local-path"
bind:value={formData.providerConfig.localFilePath}
placeholder="/path/to/file.mbox"
class="col-span-3"
/>
</div>
{/if}
{/if}
{#if formData.provider === 'google_workspace' || formData.provider === 'microsoft_365'}
<Alert.Root>

View File

@@ -0,0 +1,366 @@
<script lang="ts">
import { t } from '$lib/translations';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Button } from '$lib/components/ui/button';
import { Switch } from '$lib/components/ui/switch';
import { Badge } from '$lib/components/ui/badge';
import * as Select from '$lib/components/ui/select/index.js';
import { enhance } from '$app/forms';
import { Trash2, Plus, Database } from 'lucide-svelte';
import type {
RetentionPolicy,
RetentionRule,
ConditionField,
ConditionOperator,
LogicalOperator,
SafeIngestionSource,
} from '@open-archiver/types';
interface Props {
/** Existing policy to edit; undefined means create mode */
policy?: RetentionPolicy;
isLoading?: boolean;
/** All available ingestion sources for scope selection */
ingestionSources?: SafeIngestionSource[];
/** Form action to target, e.g. '?/create' or '?/update' */
action: string;
onCancel: () => void;
/** Called after successful submission so the parent can close the dialog */
onSuccess: () => void;
}
let {
policy,
isLoading = $bindable(false),
ingestionSources = [],
action,
onCancel,
onSuccess,
}: Props = $props();
// --- Form state ---
let name = $state(policy?.name ?? '');
let description = $state(policy?.description ?? '');
let priority = $state(policy?.priority ?? 10);
let retentionPeriodDays = $state(policy?.retentionPeriodDays ?? 365);
let isEnabled = $state(policy?.isActive ?? true);
// Conditions state
let logicalOperator = $state<LogicalOperator>(
policy?.conditions?.logicalOperator ?? 'AND'
);
let rules = $state<RetentionRule[]>(
policy?.conditions?.rules ? [...policy.conditions.rules] : []
);
// Ingestion scope: set of selected ingestion source IDs
// Empty set = null scope = applies to all
let selectedIngestionIds = $state<Set<string>>(
new Set(policy?.ingestionScope ?? [])
);
// The conditions JSON that gets sent as a hidden form field
const conditionsJson = $derived(JSON.stringify({ logicalOperator, rules }));
// The ingestionScope value: comma-separated UUIDs, or empty string for null (all)
const ingestionScopeValue = $derived(
selectedIngestionIds.size > 0 ? [...selectedIngestionIds].join(',') : ''
);
// --- Field options ---
const fieldOptions: { value: ConditionField; label: string }[] = [
{ value: 'sender', label: $t('app.retention_policies.field_sender') },
{ value: 'recipient', label: $t('app.retention_policies.field_recipient') },
{ value: 'subject', label: $t('app.retention_policies.field_subject') },
{ value: 'attachment_type', label: $t('app.retention_policies.field_attachment_type') },
];
// --- Operator options (grouped for readability) ---
const operatorOptions: { value: ConditionOperator; label: string }[] = [
{ value: 'equals', label: $t('app.retention_policies.operator_equals') },
{ value: 'not_equals', label: $t('app.retention_policies.operator_not_equals') },
{ value: 'contains', label: $t('app.retention_policies.operator_contains') },
{ value: 'not_contains', label: $t('app.retention_policies.operator_not_contains') },
{ value: 'starts_with', label: $t('app.retention_policies.operator_starts_with') },
{ value: 'ends_with', label: $t('app.retention_policies.operator_ends_with') },
{ value: 'domain_match', label: $t('app.retention_policies.operator_domain_match') },
{ value: 'regex_match', label: $t('app.retention_policies.operator_regex_match') },
];
function addRule() {
rules = [...rules, { field: 'sender', operator: 'contains', value: '' }];
}
function removeRule(index: number) {
rules = rules.filter((_, i) => i !== index);
}
function updateRuleField(index: number, field: ConditionField) {
rules = rules.map((r, i) => (i === index ? { ...r, field } : r));
}
function updateRuleOperator(index: number, operator: ConditionOperator) {
rules = rules.map((r, i) => (i === index ? { ...r, operator } : r));
}
function updateRuleValue(index: number, value: string) {
rules = rules.map((r, i) => (i === index ? { ...r, value } : r));
}
function toggleIngestionSource(id: string) {
const next = new Set(selectedIngestionIds);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
selectedIngestionIds = next;
}
</script>
<form
method="POST"
{action}
class="space-y-5"
use:enhance={() => {
isLoading = true;
return async ({ result, update }) => {
isLoading = false;
if (result.type === 'success') {
onSuccess();
}
await update({ reset: false });
};
}}
>
<!-- Hidden fields for policy id (edit mode), serialized conditions, and ingestion scope -->
{#if policy}
<input type="hidden" name="id" value={policy.id} />
{/if}
<input type="hidden" name="conditions" value={conditionsJson} />
<input type="hidden" name="ingestionScope" value={ingestionScopeValue} />
<!-- isEnabled as hidden field since Switch is not a native input -->
<input type="hidden" name="isEnabled" value={String(isEnabled)} />
<!-- Name -->
<div class="space-y-1.5">
<Label for="rp-name">{$t('app.retention_policies.name')}</Label>
<Input
id="rp-name"
name="name"
bind:value={name}
required
placeholder="e.g. Legal Department 7-Year"
/>
</div>
<!-- Description -->
<div class="space-y-1.5">
<Label for="rp-description">{$t('app.retention_policies.description')}</Label>
<Input
id="rp-description"
name="description"
bind:value={description}
placeholder="Optional description"
/>
</div>
<div class="grid grid-cols-2 gap-4">
<!-- Priority -->
<div class="space-y-1.5">
<Label for="rp-priority">{$t('app.retention_policies.priority')}</Label>
<Input
id="rp-priority"
name="priority"
type="number"
min={1}
bind:value={priority}
required
/>
</div>
<!-- Retention Period -->
<div class="space-y-1.5">
<Label for="rp-days">{$t('app.retention_policies.retention_period_days')}</Label>
<Input
id="rp-days"
name="retentionPeriodDays"
type="number"
min={1}
bind:value={retentionPeriodDays}
required
/>
</div>
</div>
<!-- Action on Expiry (fixed to delete_permanently for Phase 1) -->
<div class="space-y-1.5">
<Label>{$t('app.retention_policies.action_on_expiry')}</Label>
<Input value={$t('app.retention_policies.delete_permanently')} disabled />
</div>
<!-- Enabled toggle — value written to hidden input above -->
<div class="flex items-center gap-3">
<Switch
id="rp-enabled"
checked={isEnabled}
onCheckedChange={(v) => (isEnabled = v)}
/>
<Label for="rp-enabled">{$t('app.retention_policies.active')}</Label>
</div>
<!-- Ingestion Scope -->
{#if ingestionSources.length > 0}
<div class="space-y-2">
<div class="flex items-center gap-2">
<Database class="text-muted-foreground h-4 w-4" />
<Label>{$t('app.retention_policies.ingestion_scope')}</Label>
</div>
<p class="text-muted-foreground text-xs">
{$t('app.retention_policies.ingestion_scope_description')}
</p>
<div class="bg-muted/40 rounded-md border p-3">
<!-- "All sources" option -->
<label class="flex cursor-pointer items-center gap-2.5 py-1">
<input
type="checkbox"
class="h-4 w-4 rounded"
checked={selectedIngestionIds.size === 0}
onchange={() => {
selectedIngestionIds = new Set();
}}
/>
<span class="text-sm font-medium italic">
{$t('app.retention_policies.ingestion_scope_all')}
</span>
</label>
<div class="my-2 border-t"></div>
{#each ingestionSources as source (source.id)}
<label class="flex cursor-pointer items-center gap-2.5 py-1">
<input
type="checkbox"
class="h-4 w-4 rounded"
checked={selectedIngestionIds.has(source.id)}
onchange={() => toggleIngestionSource(source.id)}
/>
<span class="text-sm">{source.name}</span>
<Badge variant="secondary" class="ml-auto text-[10px]">
{source.provider.replace(/_/g, ' ')}
</Badge>
</label>
{/each}
</div>
{#if selectedIngestionIds.size > 0}
<p class="text-muted-foreground text-xs">
{($t as any)('app.retention_policies.ingestion_scope_selected', {
count: selectedIngestionIds.size,
})}
</p>
{/if}
</div>
{/if}
<!-- Conditions builder -->
<div class="space-y-3">
<div class="flex items-center justify-between">
<Label>{$t('app.retention_policies.conditions')}</Label>
{#if rules.length > 1}
<Select.Root
type="single"
value={logicalOperator}
onValueChange={(v) => (logicalOperator = v as LogicalOperator)}
>
<Select.Trigger class="h-8 w-24 text-xs">
{logicalOperator}
</Select.Trigger>
<Select.Content>
<Select.Item value="AND">{$t('app.retention_policies.and')}</Select.Item>
<Select.Item value="OR">{$t('app.retention_policies.or')}</Select.Item>
</Select.Content>
</Select.Root>
{/if}
</div>
<p class="text-muted-foreground text-xs">
{$t('app.retention_policies.conditions_description')}
</p>
{#each rules as rule, i (i)}
<div class="bg-muted/40 flex items-center gap-2 rounded-md border p-3">
<!-- Field selector -->
<Select.Root
type="single"
value={rule.field}
onValueChange={(v) => updateRuleField(i, v as ConditionField)}
>
<Select.Trigger class="h-8 flex-1 text-xs">
{fieldOptions.find((f) => f.value === rule.field)?.label ?? rule.field}
</Select.Trigger>
<Select.Content>
{#each fieldOptions as opt}
<Select.Item value={opt.value}>{opt.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<!-- Operator selector -->
<Select.Root
type="single"
value={rule.operator}
onValueChange={(v) => updateRuleOperator(i, v as ConditionOperator)}
>
<Select.Trigger class="h-8 flex-1 text-xs">
{operatorOptions.find((o) => o.value === rule.operator)?.label ?? rule.operator}
</Select.Trigger>
<Select.Content>
{#each operatorOptions as opt}
<Select.Item value={opt.value}>{opt.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<!-- Value input -->
<Input
class="h-8 flex-1 text-xs"
value={rule.value}
oninput={(e) => updateRuleValue(i, (e.target as HTMLInputElement).value)}
placeholder={$t('app.retention_policies.value_placeholder')}
required
/>
<!-- Remove rule -->
<Button
type="button"
variant="ghost"
size="icon"
class="h-8 w-8 shrink-0"
onclick={() => removeRule(i)}
aria-label={$t('app.retention_policies.remove_rule')}
>
<Trash2 class="h-4 w-4" />
</Button>
</div>
{/each}
<Button type="button" variant="outline" size="sm" onclick={addRule}>
<Plus class="mr-1.5 h-4 w-4" />
{$t('app.retention_policies.add_rule')}
</Button>
</div>
<!-- Actions -->
<div class="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onclick={onCancel} disabled={isLoading}>
{$t('app.retention_policies.cancel')}
</Button>
<Button type="submit" disabled={isLoading}>
{#if isLoading}
{$t('app.components.common.submitting')}
{:else if policy}
{$t('app.retention_policies.save')}
{:else}
{$t('app.retention_policies.create')}
{/if}
</Button>
</div>
</form>

View File

@@ -8,15 +8,17 @@
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button variant="ghost" size="icon">
<Sun
class="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0"
/>
<Moon
class="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100"
/>
<span class="sr-only">{$t('app.components.theme_switcher.toggle_theme')}</span>
</Button>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="icon">
<Sun
class="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0"
/>
<Moon
class="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100"
/>
<span class="sr-only">{$t('app.components.theme_switcher.toggle_theme')}</span>
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item onclick={() => ($theme = 'light')}>

View File

@@ -0,0 +1,292 @@
{
"app": {
"auth": {
"login": "Вход",
"login_tip": "Въведете имейл адреса си по-долу, за да влезете в профила си.",
"email": "Имейл",
"password": "Парола"
},
"common": {
"working": "Работата е в ход"
},
"archive": {
"title": "Архив",
"no_subject": "Без тема",
"from": "От",
"sent": "Изпратени",
"recipients": "Получатели",
"to": "До",
"meta_data": "Метаданни",
"folder": "Папка",
"tags": "Етикети",
"size": "Размер",
"email_preview": "Преглед на имейла",
"attachments": "Прикачени файлове",
"download": "Изтегляне",
"actions": "Действия",
"download_eml": "Изтегляне на съобщения (.eml)",
"delete_email": "Изтриване на съобщения",
"email_thread": "Нишка от съобщения",
"delete_confirmation_title": "Сигурни ли сте, че искате да изтриете това съобщение?",
"delete_confirmation_description": "Това действие не може да бъде отменено и ще премахне имейла и прикачените към него файлове за постоянно.",
"deleting": "Изтриване",
"confirm": "Потвърдете",
"cancel": "Отказ",
"not_found": "Съобщението не е открито."
},
"ingestions": {
"title": "Източници за приемане",
"ingestion_sources": "Източници за приемане",
"bulk_actions": "Групови действия",
"force_sync": "Принудително синхронизиране",
"delete": "Изтрийте",
"create_new": "Създайте нов",
"name": "Име",
"provider": "Доставчик",
"status": "Статус",
"active": "Активно",
"created_at": "Създадено в",
"actions": "Действия",
"last_sync_message": "Последно синхронизирано съобщение",
"empty": "Празно",
"open_menu": "Отворете менюто",
"edit": "Редактиране",
"create": "Създайте",
"ingestion_source": "Източници за приемане",
"edit_description": "Направете промени в източника си за приемане тук.",
"create_description": "Добавете нов източник за приемане, за да започнете да архивирате съобщения.",
"read": "Прочетете",
"docs_here": "тук се намира документацията",
"delete_confirmation_title": "Наистина ли искате да изтриете това приемане?",
"delete_confirmation_description": "Това ще изтрие всички архивирани съобщения, прикачени файлове, индексирания и файлове, свързани с това приемане. Ако искате да спрете само синхронизирането на нови съобщения, можете вместо това да поставите приемането на пауза.",
"deleting": "Изтриване",
"confirm": "Потвърдете",
"cancel": "Отказ",
"bulk_delete_confirmation_title": "Сигурни ли сте, че искате да изтриете {{count}} избраните приемания?",
"bulk_delete_confirmation_description": "Това ще изтрие всички архивирани съобщения, прикачени файлове, индексирания и файлове, свързани с тези приемания. Ако искате да спрете само синхронизирането на нови съобщения, можете вместо това да поставите приеманията на пауза."
},
"search": {
"title": "Търсене",
"description": "Търсене на архивирани съобщения.",
"email_search": "Претърсване на имейл",
"placeholder": "Търсене по ключова дума, подател, получател...",
"search_button": "Търсене",
"search_options": "Опции за търсене",
"strategy_fuzzy": "Размито",
"strategy_verbatim": "Дословно",
"strategy_frequency": "Честота",
"select_strategy": "Изберете стратегия",
"error": "Грешка",
"found_results_in": "Намерени {{total}} резултати за {{seconds}}и",
"found_results": "Намерени {{total}} резултати",
"from": "От",
"to": "До",
"in_email_body": "В съдържанието на имейла",
"in_attachment": "В прикачения файл: {{filename}}",
"prev": "Предишен",
"next": "Следващ"
},
"roles": {
"title": "Управление на ролите",
"role_management": "Управление на ролите",
"create_new": "Създаване на нова",
"name": "Име",
"created_at": "Създадено в",
"actions": "Действия",
"open_menu": "Отваряне на менюто",
"view_policy": "Преглед на политиката",
"edit": "Редактиране",
"delete": "Изтриване",
"no_roles_found": "Няма намерени роли.",
"role_policy": "Политика за ролите",
"viewing_policy_for_role": "Преглед на политиката за роля: {{name}}",
"create": "Създаване",
"role": "Роля",
"edit_description": "Направете промени в ролята тук.",
"create_description": "Добавете нова роля към системата.",
"delete_confirmation_title": "Сигурни ли сте, че искате да изтриете тази роля?",
"delete_confirmation_description": "Това действие не може да бъде отменено. Това ще изтрие ролята за постоянно.",
"deleting": "Изтриване",
"confirm": "Потвърдете",
"cancel": "Отказ"
},
"system_settings": {
"title": "Системни настройки",
"system_settings": "Системни настройки",
"description": "Управление на глобалните настройки на приложението.",
"language": "Език",
"default_theme": "Тема по подразбиране",
"light": "Светла",
"dark": "Тъмна",
"system": "Система",
"support_email": "Имейл за поддръжка",
"saving": "Съхранява се",
"save_changes": "Съхранете промените"
},
"users": {
"title": "Управление на потребителите",
"user_management": "Управление на потребителите",
"create_new": "Създаване на нов",
"name": "Име",
"email": "Имейл",
"role": "Роля",
"created_at": "Създадено в",
"actions": "Действия",
"open_menu": "Отваряне на меню",
"edit": "Редактиране",
"delete": "Изтриване",
"no_users_found": "Няма открити потребители.",
"create": "Създаване",
"user": "Потребител",
"edit_description": "Направете промени на потребителя тук.",
"create_description": "Добавете нов потребител към системата.",
"delete_confirmation_title": "Сигурни ли сте, че искате да изтриете този потребител?",
"delete_confirmation_description": "Това действие не може да бъде отменено. Това ще изтрие потребителя за постоянно и ще премахне данните му от нашите сървъри.",
"deleting": "Изтриване",
"confirm": "Потвърдете",
"cancel": "Отказ"
},
"components": {
"charts": {
"emails_ingested": "Приети съобщения",
"storage_used": "Използвано пространство",
"emails": "Съобщения"
},
"common": {
"submitting": "Изпращане...",
"submit": "Изпратете",
"save": "Съхранете"
},
"email_preview": {
"loading": "Зареждане на визуализацията на имейла...",
"render_error": "Не можа да се изобрази визуализация на имейла.",
"not_available": "Необработеният .eml файл не е наличен за този имейл."
},
"footer": {
"all_rights_reserved": "Всички права запазени.",
"new_version_available": "Налична е нова версия"
},
"ingestion_source_form": {
"provider_generic_imap": "Общ IMAP",
"provider_google_workspace": "Google Workspace",
"provider_microsoft_365": "Microsoft 365",
"provider_pst_import": "PST Импортиране",
"provider_eml_import": "EML Импортиране",
"provider_mbox_import": "Mbox Импортиране",
"select_provider": "Изберете доставчик",
"service_account_key": "Ключ за сервизен акаунт (JSON)",
"service_account_key_placeholder": "Поставете JSON съдържанието на ключа на вашия сервизен акаунт",
"impersonated_admin_email": "Имейл адрес на администратор, използван като идентификатор",
"client_id": "Приложение (Клиент) ID",
"client_secret": "Клиентски таен ключ",
"client_secret_placeholder": "Въведете клиентския таен ключ като стойност, а не ID тайната",
"tenant_id": "Директория (Наемател) ID",
"host": "Хост",
"port": "Порт",
"username": "Потребителско им",
"use_tls": "Използвайте TLS",
"allow_insecure_cert": "Разрешаване на несигурен сертификат",
"pst_file": "PST Файл",
"eml_file": "EML Файл",
"mbox_file": "Mbox файл",
"heads_up": "Внимание!",
"org_wide_warning": "Моля, обърнете внимание, че това е операция за цялата организация. Този вид приемане ще импортира и индексира <b>всички</b> имейл входящи кутии във вашата организация. Ако искате да импортирате само конкретни имейл входящи кутии, използвайте IMAP инструмента за свързване.",
"upload_failed": "Качването не бе успешно, моля, опитайте отново"
},
"role_form": {
"policies_json": "Политики (JSON)",
"invalid_json": "Невалиден JSON формат за политики."
},
"theme_switcher": {
"toggle_theme": "Превключване на тема"
},
"user_form": {
"select_role": "Изберете роля"
}
},
"setup": {
"title": "Настройка",
"description": "Настройте първоначалния администраторски акаунт за Open Archiver.",
"welcome": "Добре дошли",
"create_admin_account": "Създайте първия администраторски акаунт, за да започнете.",
"first_name": "Име",
"last_name": "Фамилия",
"email": "Имейл",
"password": "Парола",
"creating_account": "Създаване на акаунт",
"create_account": "Създаване на акаунт"
},
"layout": {
"dashboard": "Табло за управление",
"ingestions": "Приети",
"archived_emails": "Архивирани съобщения",
"search": "Търсене",
"settings": "Настройки",
"system": "Система",
"users": "Потребители",
"roles": "Роли",
"api_keys": "API ключове",
"logout": "Изход"
},
"api_keys_page": {
"title": "API ключове",
"header": "API ключове",
"generate_new_key": "Генериране на нов ключ",
"name": "Име",
"key": "Ключ",
"expires_at": "Изтича на",
"created_at": "Създаден на",
"actions": "Действия",
"delete": "Изтриване",
"no_keys_found": "Няма намерени API ключове.",
"generate_modal_title": "Генериране на нов API ключ",
"generate_modal_description": "Моля, посочете име и срок на валидност за новия си API ключ.",
"expires_in": "Изтича след",
"select_expiration": "Изберете срок на валидност",
"30_days": "30 дни",
"60_days": "60 дни",
"6_months": "6 месеца",
"12_months": "12 месеца",
"24_months": "24 месеца",
"generate": "Генериране",
"new_api_key": "Нов API ключ",
"failed_to_delete": "Изтриването на API ключ е неуспешно",
"api_key_deleted": "API ключът е изтрит",
"generated_title": "API ключът е генериран",
"generated_message": "Вашият API ключ е генериран, моля, копирайте го и го запазете на сигурно място. Този ключ ще бъде показан само веднъж."
},
"archived_emails_page": {
"title": "Архивирани съобщения",
"header": "Архивирани съобщения",
"select_ingestion_source": "Изберете източник за приемане",
"date": "Дата",
"subject": "Тема",
"sender": "Подател",
"inbox": "Входяща поща",
"path": "Път",
"actions": "Действия",
"view": "Преглед",
"no_emails_found": "Няма намерени архивирани съобщения.",
"prev": "Предишен",
"next": "Следващ"
},
"dashboard_page": {
"title": "Табло за управление",
"meta_description": "Общ преглед на вашия имейл архив.",
"header": "Табло за управление",
"create_ingestion": "Създаване на приемане",
"no_ingestion_header": "Нямате настроен източник за приемане.",
"no_ingestion_text": "Добавете източник за приемане, за да започнете да архивирате входящите си кутии.",
"total_emails_archived": "Общо архивирани съобщения",
"total_storage_used": "Общо използвано място за съхранение",
"failed_ingestions": "Неуспешни приемания (последните 7 дни)",
"ingestion_history": "История на приеманията",
"no_ingestion_history": "Няма налична история на приеманията.",
"storage_by_source": "Съхранение по източник на приемане",
"no_ingestion_sources": "Няма налични източници за приемане.",
"indexed_insights": "Индексирани данни",
"top_10_senders": "Топ 10 податели",
"no_indexed_insights": "Няма налични индексирани данни."
}
}
}

View File

@@ -40,7 +40,16 @@
"invalid": "Invalid",
"integrity_check_failed_title": "Integrity Check Failed",
"integrity_check_failed_message": "Could not verify the integrity of the email and its attachments.",
"integrity_report_description": "This report verifies that the content of your archived emails has not been altered."
"integrity_report_description": "This report verifies that the content of your archived emails has not been altered.",
"retention_policy": "Retention Policy",
"retention_policy_description": "Shows which retention policy governs this email and when it is scheduled for deletion.",
"retention_no_policy": "No policy applies — this email will not be automatically deleted.",
"retention_period": "Retention Period",
"retention_action": "Action on Expiry",
"retention_matching_policies": "Matching Policies",
"retention_delete_permanently": "Permanent Deletion",
"retention_scheduled_deletion": "Scheduled Deletion",
"retention_policy_overridden_by_label": "This policy is overridden by retention label "
},
"ingestions": {
"title": "Ingestion Sources",
@@ -199,6 +208,10 @@
"provider_eml_import": "EML Import",
"provider_mbox_import": "Mbox Import",
"select_provider": "Select a provider",
"import_method": "Import Method",
"upload_file": "Upload File",
"local_path": "Local Path (Recommended for large files)",
"local_file_path": "Local File Path",
"service_account_key": "Service Account Key (JSON)",
"service_account_key_placeholder": "Paste your service account key JSON content",
"impersonated_admin_email": "Impersonated Admin Email",
@@ -315,6 +328,230 @@
"top_10_senders": "Top 10 Senders",
"no_indexed_insights": "No indexed insights available."
},
"retention_policies": {
"title": "Retention Policies",
"header": "Retention Policies",
"meta_description": "Manage data retention policies to automate email lifecycle and compliance.",
"create_new": "Create New Policy",
"no_policies_found": "No retention policies found.",
"name": "Name",
"description": "Description",
"priority": "Priority",
"retention_period": "Retention Period",
"retention_period_days": "Retention Period (days)",
"action_on_expiry": "Action on Expiry",
"delete_permanently": "Delete Permanently",
"status": "Status",
"active": "Active",
"inactive": "Inactive",
"conditions": "Conditions",
"conditions_description": "Define rules to match emails. If no conditions are set, the policy applies to all emails.",
"logical_operator": "Logical Operator",
"and": "AND",
"or": "OR",
"add_rule": "Add Rule",
"remove_rule": "Remove Rule",
"field": "Field",
"field_sender": "Sender",
"field_recipient": "Recipient",
"field_subject": "Subject",
"field_attachment_type": "Attachment Type",
"operator": "Operator",
"operator_equals": "Equals",
"operator_not_equals": "Not Equals",
"operator_contains": "Contains",
"operator_not_contains": "Not Contains",
"operator_starts_with": "Starts With",
"operator_ends_with": "Ends With",
"operator_domain_match": "Domain Match",
"operator_regex_match": "Regex Match",
"value": "Value",
"value_placeholder": "e.g. user@example.com",
"edit": "Edit",
"delete": "Delete",
"create": "Create",
"save": "Save Changes",
"cancel": "Cancel",
"create_description": "Create a new retention policy to manage the lifecycle of archived emails.",
"edit_description": "Update the settings for this retention policy.",
"delete_confirmation_title": "Delete this retention policy?",
"delete_confirmation_description": "This action cannot be undone. Emails matched by this policy will no longer be subject to automatic deletion.",
"deleting": "Deleting",
"confirm": "Confirm",
"days": "days",
"no_conditions": "All emails (no filter)",
"rules": "rules",
"simulator_title": "Policy Simulator",
"simulator_description": "Test an email's metadata against all active policies to see which retention period would apply.",
"simulator_sender": "Sender Email",
"simulator_sender_placeholder": "e.g. john@finance.company.de",
"simulator_recipients": "Recipients",
"simulator_recipients_placeholder": "Comma-separated, e.g. jane@company.de, bob@company.de",
"simulator_subject": "Subject",
"simulator_subject_placeholder": "e.g. Q4 Tax Report",
"simulator_attachment_types": "Attachment Types",
"simulator_attachment_types_placeholder": "Comma-separated, e.g. .pdf, .xlsx",
"simulator_run": "Run Simulation",
"simulator_running": "Running...",
"simulator_result_title": "Simulation Result",
"simulator_no_match": "No active policy matched this email. It will not be subject to automated deletion.",
"simulator_matched": "Matched — retention period of {{days}} days applies.",
"simulator_matching_policies": "Matching Policy IDs",
"simulator_no_result": "Run a simulation to see which policies apply to a given email.",
"simulator_ingestion_source": "Simulate for Ingestion Source",
"simulator_ingestion_source_description": "Select an ingestion source to test scoped policies. Leave blank to evaluate against all policies regardless of scope.",
"simulator_ingestion_all": "All sources (ignore scope)",
"ingestion_scope": "Ingestion Scope",
"ingestion_scope_description": "Restrict this policy to specific ingestion sources. Leave all unchecked to apply to all sources.",
"ingestion_scope_all": "All ingestion sources",
"ingestion_scope_selected": "{{count}} source(s) selected — this policy will only apply to emails from those sources.",
"create_success": "Retention policy created successfully.",
"update_success": "Retention policy updated successfully.",
"delete_success": "Retention policy deleted successfully.",
"delete_error": "Failed to delete retention policy."
},
"retention_labels": {
"title": "Retention Labels",
"header": "Retention Labels",
"meta_description": "Manage retention labels for item-level compliance overrides on individual archived emails.",
"create_new": "Create Label",
"no_labels_found": "No retention labels found.",
"name": "Name",
"description": "Description",
"retention_period": "Retention Period",
"retention_period_days": "Retention Period (days)",
"applied_count": "Applied Emails",
"status": "Status",
"enabled": "Enabled",
"disabled": "Disabled",
"created_at": "Created At",
"actions": "Actions",
"create": "Create",
"edit": "Edit",
"delete": "Delete",
"disable": "Disable",
"save": "Save Changes",
"cancel": "Cancel",
"days": "days",
"create_description": "Create a new retention label. Once applied to emails, the label's retention period cannot be changed.",
"edit_description": "Update this retention label's details.",
"delete_confirmation_title": "Delete this retention label?",
"delete_confirmation_description": "This action will permanently remove the label. It cannot be applied to new emails.",
"disable_confirmation_title": "Disable this retention label?",
"disable_confirmation_description": "This label is currently applied to archived emails and cannot be deleted. It will be disabled so it cannot be applied to new emails, but existing tagged emails will keep this label although it won't take effective.",
"force_delete_confirmation_title": "Permanently delete this disabled label?",
"force_delete_confirmation_description": "This label is disabled but still has email associations. Deleting it will remove all those associations and permanently delete the label. This action cannot be undone.",
"deleting": "Processing",
"confirm": "Confirm",
"create_success": "Retention label created successfully.",
"update_success": "Retention label updated successfully.",
"delete_success": "Retention label deleted successfully.",
"disable_success": "Retention label disabled successfully.",
"delete_error": "Failed to delete retention label.",
"create_error": "Failed to create retention label.",
"update_error": "Failed to update retention label.",
"retention_period_locked": "Retention period cannot be changed while the label is applied to emails.",
"name_placeholder": "e.g. Tax Record - 10 Years",
"description_placeholder": "e.g. Applied to tax-related documents requiring extended retention."
},
"archive_labels": {
"section_title": "Retention Label",
"section_description": "Override this email's retention schedule with a specific label.",
"current_label": "Current Label",
"no_label": "No label applied",
"select_label": "Select a label",
"select_label_placeholder": "Choose a retention label...",
"apply": "Apply Label",
"applying": "Applying...",
"remove": "Remove Label",
"removing": "Removing...",
"apply_success": "Retention label applied successfully.",
"remove_success": "Retention label removed successfully.",
"apply_error": "Failed to apply retention label.",
"remove_error": "Failed to remove retention label.",
"label_overrides_policy": "This label overrides general retention policies for this email.",
"no_labels_available": "No retention labels available. Create labels in Compliance settings.",
"label_inactive": "Inactive",
"label_inactive_note": "This label has been disabled. It no longer provides a retention override or a scheduled deletion date for this email. You may remove it and apply an active label if needed."
},
"legal_holds": {
"title": "Legal Holds",
"header": "Legal Holds",
"meta_description": "Manage legal holds to preserve emails from automated deletion during litigation or regulatory investigations.",
"header_description": "Legal holds suspend automated deletion for specific records relevant to litigation or regulatory investigations.",
"create_new": "Create Hold",
"no_holds_found": "No legal holds found.",
"name": "Name",
"reason": "Reason / Description",
"no_reason": "No reason provided",
"email_count": "Protected Emails",
"status": "Status",
"active": "Active",
"inactive": "Inactive",
"created_at": "Created At",
"actions": "Actions",
"create": "Create",
"edit": "Edit",
"delete": "Delete",
"activate": "Activate",
"deactivate": "Deactivate",
"bulk_apply": "Bulk Apply via Search",
"release_all": "Release All Emails",
"save": "Save Changes",
"cancel": "Cancel",
"confirm": "Confirm Delete",
"name_placeholder": "e.g. Project Titan Litigation - 2026",
"reason_placeholder": "e.g. Pending litigation related to Project Titan. All communications must be preserved.",
"create_description": "Create a new legal hold to prevent automated deletion of relevant emails.",
"edit_description": "Update this legal hold's name or description.",
"delete_confirmation_title": "Permanently delete this legal hold?",
"delete_confirmation_description": "This will permanently delete the hold and remove all email associations. Previously protected emails will be subject to normal retention rules at the next lifecycle worker run.",
"bulk_apply_title": "Bulk Apply Legal Hold via Search",
"bulk_apply_description": "Search for emails using full-text and metadata filters. All matching emails will be placed under this legal hold. The exact query is saved to the audit log as proof of scope.",
"bulk_query": "Search Keywords",
"bulk_query_placeholder": "e.g. Project Titan confidential",
"bulk_query_hint": "Searches email body, subject, and attachment content via the full-text index.",
"bulk_from": "From (Sender Email)",
"bulk_date_start": "Date From",
"bulk_date_end": "Date To",
"bulk_apply_warning": "This action will apply to ALL emails matching your search across the entire archive. The search query will be permanently recorded in the audit log.",
"bulk_apply_confirm": "Apply Hold to Matching Emails",
"release_all_title": "Release all emails from this hold?",
"release_all_description": "All emails will lose their legal hold immunity. They will be evaluated against standard retention policies at the next lifecycle worker run and may be permanently deleted.",
"release_all_confirm": "Release All Emails",
"create_success": "Legal hold created successfully.",
"update_success": "Legal hold updated successfully.",
"delete_success": "Legal hold deleted successfully.",
"activated_success": "Legal hold activated. Protected emails are now immune from deletion.",
"deactivated_success": "Legal hold deactivated. Emails are no longer protected by this hold.",
"bulk_apply_success": "Legal hold applied successfully.",
"release_all_success": "All emails released from hold.",
"create_error": "Failed to create legal hold.",
"update_error": "Failed to update legal hold.",
"delete_error": "Failed to delete legal hold.",
"bulk_apply_error": "Bulk apply failed.",
"release_all_error": "Failed to release emails from hold."
},
"archive_legal_holds": {
"section_title": "Legal Holds",
"section_description": "Suspend automated deletion for this email by placing it under a legal hold.",
"no_holds": "No legal holds applied to this email.",
"hold_name": "Hold Name",
"hold_status": "Status",
"applied_at": "Applied At",
"apply_hold": "Apply a Hold",
"apply_hold_placeholder": "Select a legal hold...",
"apply": "Apply Hold",
"applying": "Applying...",
"remove": "Remove",
"removing": "Removing...",
"apply_success": "Legal hold applied to this email.",
"remove_success": "Legal hold removed from this email.",
"apply_error": "Failed to apply legal hold.",
"remove_error": "Failed to remove legal hold.",
"immune_notice": "This email is protected by an active legal hold and cannot be deleted.",
"no_active_holds": "No active legal holds available. Create holds in Compliance settings."
},
"audit_log": {
"title": "Audit Log",
"header": "Audit Log",
@@ -371,20 +608,22 @@
"license_page": {
"title": "Enterprise License Status",
"meta_description": "View the current status of your Open Archiver Enterprise license.",
"revoked_title": "License Revoked",
"revoked_message": "Your license has been revoked by the license administrator. Enterprise features will be disabled {{grace_period}}. Please contact your account manager for assistance.",
"revoked_grace_period": "on {{date}}",
"revoked_immediately": "immediately",
"revoked_title": "License Invalid",
"revoked_message": "Your license has been revoked or your seat overage grace period has expired. All enterprise features are now disabled. Please contact your account manager for assistance.",
"notice_title": "Notice",
"seat_limit_exceeded_title": "Seat Limit Exceeded",
"seat_limit_exceeded_message": "Your license is for {{planSeats}} users, but you are currently using {{activeSeats}}. Please contact sales to adjust your subscription.",
"seat_limit_exceeded_message": "Your license covers {{planSeats}} seats but {{activeSeats}} are currently in use. Please reduce usage or upgrade your plan.",
"seat_limit_grace_deadline": "Enterprise features will be disabled on {{date}} unless the seat count is reduced.",
"customer": "Customer",
"license_details": "License Details",
"license_status": "License Status",
"active": "Active",
"expired": "Expired",
"revoked": "Revoked",
"overage": "Seat Overage",
"unknown": "Unknown",
"expires": "Expires",
"last_checked": "Last verified",
"seat_usage": "Seat Usage",
"seats_used": "{{activeSeats}} of {{planSeats}} seats used",
"enabled_features": "Enabled Features",
@@ -394,7 +633,10 @@
"enabled": "Enabled",
"disabled": "Disabled",
"could_not_load_title": "Could Not Load License",
"could_not_load_message": "An unexpected error occurred."
"could_not_load_message": "An unexpected error occurred.",
"revalidate": "Revalidate License",
"revalidating": "Revalidating...",
"revalidate_success": "License revalidated successfully."
}
}
}

View File

@@ -12,6 +12,7 @@ import nl from './nl.json';
import ja from './ja.json';
import et from './et.json';
import el from './el.json';
import bg from './bg.json'
// This is your config object.
// It defines the languages and how to load them.
const config: Config = {
@@ -77,6 +78,12 @@ const config: Config = {
key: 'app',
loader: async () => el.app,
},
// Bulgarian 🇧🇬
{
locale: 'bg',
key: 'app',
loader: async () => bg.app,
},
],
fallbackLocale: 'en',
};

View File

@@ -1,289 +1,400 @@
{
"app": {
"auth": {
"login": "Accedi",
"login_tip": "Inserisci la tua email qui sotto per accedere al tuo account.",
"email": "Email",
"password": "Password"
},
"common": {
"working": "In corso"
},
"archive": {
"title": "Archivio",
"no_subject": "Nessun Oggetto",
"from": "Da",
"sent": "Inviato",
"recipients": "Destinatari",
"to": "A",
"meta_data": "Metadati",
"folder": "Cartella",
"tags": "Tag",
"size": "Dimensione",
"email_preview": "Anteprima Email",
"attachments": "Allegati",
"download": "Scarica",
"actions": "Azioni",
"download_eml": "Scarica Email (.eml)",
"delete_email": "Elimina Email",
"email_thread": "Thread Email",
"delete_confirmation_title": "Sei sicuro di voler eliminare questa email?",
"delete_confirmation_description": "Questa azione non può essere annullata e rimuoverà permanentemente l'email e i suoi allegati.",
"deleting": "Eliminazione in corso",
"confirm": "Conferma",
"cancel": "Annulla",
"not_found": "Email non trovata."
},
"ingestions": {
"title": "Sorgenti di Ingestione",
"ingestion_sources": "Sorgenti di Ingestione",
"bulk_actions": "Azioni di Massa",
"force_sync": "Forza Sincronizzazione",
"delete": "Elimina",
"create_new": "Crea Nuovo",
"name": "Nome",
"provider": "Provider",
"status": "Stato",
"active": "Attivo",
"created_at": "Creato il",
"actions": "Azioni",
"last_sync_message": "Ultimo messaggio di sincronizzazione",
"empty": "Vuoto",
"open_menu": "Apri menu",
"edit": "Modifica",
"create": "Crea",
"ingestion_source": "Sorgente di Ingestione",
"edit_description": "Apporta modifiche alla tua sorgente di ingestione qui.",
"create_description": "Aggiungi una nuova sorgente di ingestione per iniziare ad archiviare le email.",
"read": "Leggi",
"docs_here": "documenti qui",
"delete_confirmation_title": "Sei sicuro di voler eliminare questa ingestione?",
"delete_confirmation_description": "Questo cancellerà tutte le email archiviate, gli allegati, l'indicizzazione e i file associati a questa ingestione. Se vuoi solo interrompere la sincronizzazione di nuove email, puoi mettere in pausa l'ingestione.",
"deleting": "Eliminazione in corso",
"confirm": "Conferma",
"cancel": "Annulla",
"bulk_delete_confirmation_title": "Sei sicuro di voler eliminare {{count}} ingestioni selezionate?",
"bulk_delete_confirmation_description": "Questo cancellerà tutte le email archiviate, gli allegati, l'indicizzazione e i file associati a queste ingestioni. Se vuoi solo interrompere la sincronizzazione di nuove email, puoi mettere in pausa le ingestioni."
},
"search": {
"title": "Ricerca",
"description": "Ricerca email archiviate.",
"email_search": "Ricerca Email",
"placeholder": "Cerca per parola chiave, mittente, destinatario...",
"search_button": "Cerca",
"search_options": "Opzioni di ricerca",
"strategy_fuzzy": "Approssimativa",
"strategy_verbatim": "Esatta",
"strategy_frequency": "Frequenza",
"select_strategy": "Seleziona una strategia",
"error": "Errore",
"found_results_in": "Trovati {{total}} risultati in {{seconds}}s",
"found_results": "Trovati {{total}} risultati",
"from": "Da",
"to": "A",
"in_email_body": "Nel corpo dell'email",
"in_attachment": "Nell'allegato: {{filename}}",
"prev": "Prec",
"next": "Succ"
},
"roles": {
"title": "Gestione Ruoli",
"role_management": "Gestione Ruoli",
"create_new": "Crea Nuovo",
"name": "Nome",
"created_at": "Creato il",
"actions": "Azioni",
"open_menu": "Apri menu",
"view_policy": "Visualizza Policy",
"edit": "Modifica",
"delete": "Elimina",
"no_roles_found": "Nessun ruolo trovato.",
"role_policy": "Policy Ruolo",
"viewing_policy_for_role": "Visualizzazione policy per il ruolo: {{name}}",
"create": "Crea",
"role": "Ruolo",
"edit_description": "Apporta modifiche al ruolo qui.",
"create_description": "Aggiungi un nuovo ruolo al sistema.",
"delete_confirmation_title": "Sei sicuro di voler eliminare questo ruolo?",
"delete_confirmation_description": "Questa azione non può essere annullata. Questo eliminerà permanentemente il ruolo.",
"deleting": "Eliminazione in corso",
"confirm": "Conferma",
"cancel": "Annulla"
},
"system_settings": {
"title": "Impostazioni di Sistema",
"system_settings": "Impostazioni di Sistema",
"description": "Gestisci le impostazioni globali dell'applicazione.",
"language": "Lingua",
"default_theme": "Tema predefinito",
"light": "Chiaro",
"dark": "Scuro",
"system": "Sistema",
"support_email": "Email di Supporto",
"saving": "Salvataggio in corso",
"save_changes": "Salva Modifiche"
},
"users": {
"title": "Gestione Utenti",
"user_management": "Gestione Utenti",
"create_new": "Crea Nuovo",
"name": "Nome",
"email": "Email",
"role": "Ruolo",
"created_at": "Creato il",
"actions": "Azioni",
"open_menu": "Apri menu",
"edit": "Modifica",
"delete": "Elimina",
"no_users_found": "Nessun utente trovato.",
"create": "Crea",
"user": "Utente",
"edit_description": "Apporta modifiche all'utente qui.",
"create_description": "Aggiungi un nuovo utente al sistema.",
"delete_confirmation_title": "Sei sicuro di voler eliminare questo utente?",
"delete_confirmation_description": "Questa azione non può essere annullata. Questo eliminerà permanentemente l'utente e rimuoverà i suoi dati dai nostri server.",
"deleting": "Eliminazione in corso",
"confirm": "Conferma",
"cancel": "Annulla"
},
"components": {
"charts": {
"emails_ingested": "Email Acquisite",
"storage_used": "Spazio di Archiviazione Utilizzato",
"emails": "Email"
},
"common": {
"submitting": "Invio in corso...",
"submit": "Invia",
"save": "Salva"
},
"email_preview": {
"loading": "Caricamento anteprima email...",
"render_error": "Impossibile renderizzare l'anteprima dell'email.",
"not_available": "File .eml grezzo non disponibile per questa email."
},
"footer": {
"all_rights_reserved": "Tutti i diritti riservati."
},
"ingestion_source_form": {
"provider_generic_imap": "IMAP Generico",
"provider_google_workspace": "Google Workspace",
"provider_microsoft_365": "Microsoft 365",
"provider_pst_import": "Importazione PST",
"provider_eml_import": "Importazione EML",
"select_provider": "Seleziona un provider",
"service_account_key": "Chiave Account di Servizio (JSON)",
"service_account_key_placeholder": "Incolla il contenuto JSON della chiave del tuo account di servizio",
"impersonated_admin_email": "Email dell'Amministratore Impersonato",
"client_id": "ID Applicazione (Client)",
"client_secret": "Valore Segreto Client",
"client_secret_placeholder": "Inserisci il Valore segreto, non l'ID Segreto",
"tenant_id": "ID Directory (Tenant)",
"host": "Host",
"port": "Porta",
"username": "Nome Utente",
"use_tls": "Usa TLS",
"allow_insecure_cert": "Consenti certificato non sicuro",
"pst_file": "File PST",
"eml_file": "File EML",
"heads_up": "Attenzione!",
"org_wide_warning": "Si prega di notare che questa è un'operazione a livello di organizzazione. Questo tipo di ingestione importerà e indicizzerà <b>tutte</b> le caselle di posta elettronica nella tua organizzazione. Se vuoi importare solo caselle di posta elettronica specifiche, usa il connettore IMAP.",
"upload_failed": "Caricamento Fallito, riprova"
},
"role_form": {
"policies_json": "Policy (JSON)",
"invalid_json": "Formato JSON non valido per le policy."
},
"theme_switcher": {
"toggle_theme": "Cambia tema"
},
"user_form": {
"select_role": "Seleziona un ruolo"
}
},
"setup": {
"title": "Configurazione",
"description": "Configura l'account amministratore iniziale per Open Archiver.",
"welcome": "Benvenuto",
"create_admin_account": "Crea il primo account amministratore per iniziare.",
"first_name": "Nome",
"last_name": "Cognome",
"email": "Email",
"password": "Password",
"creating_account": "Creazione Account",
"create_account": "Crea Account"
},
"layout": {
"dashboard": "Dashboard",
"ingestions": "Ingestioni",
"archived_emails": "Email archiviate",
"search": "Ricerca",
"settings": "Impostazioni",
"system": "Sistema",
"users": "Utenti",
"roles": "Ruoli",
"api_keys": "Chiavi API",
"logout": "Esci"
},
"api_keys_page": {
"title": "Chiavi API",
"header": "Chiavi API",
"generate_new_key": "Genera Nuova Chiave",
"name": "Nome",
"key": "Chiave",
"expires_at": "Scade il",
"created_at": "Creato il",
"actions": "Azioni",
"delete": "Elimina",
"no_keys_found": "Nessuna chiave API trovata.",
"generate_modal_title": "Genera Nuova Chiave API",
"generate_modal_description": "Fornisci un nome e una scadenza per la tua nuova chiave API.",
"expires_in": "Scade Tra",
"select_expiration": "Seleziona una scadenza",
"30_days": "30 Giorni",
"60_days": "60 Giorni",
"6_months": "6 Mesi",
"12_months": "12 Mesi",
"24_months": "24 Mesi",
"generate": "Genera",
"new_api_key": "Nuova Chiave API",
"failed_to_delete": "Impossibile eliminare la chiave API",
"api_key_deleted": "Chiave API eliminata",
"generated_title": "Chiave API Generata",
"generated_message": "La tua chiave API è stata generata, per favore copiala e salvala in un luogo sicuro. Questa chiave verrà mostrata solo una volta."
},
"archived_emails_page": {
"title": "Email archiviate",
"header": "Email Archiviate",
"select_ingestion_source": "Seleziona una sorgente di ingestione",
"date": "Data",
"subject": "Oggetto",
"sender": "Mittente",
"inbox": "Posta in arrivo",
"path": "Percorso",
"actions": "Azioni",
"view": "Visualizza",
"no_emails_found": "Nessuna email archiviata trovata.",
"prev": "Prec",
"next": "Succ"
},
"dashboard_page": {
"title": "Dashboard",
"meta_description": "Panoramica del tuo archivio email.",
"header": "Dashboard",
"create_ingestion": "Crea un'ingestione",
"no_ingestion_header": "Non hai impostato nessuna sorgente di ingestione.",
"no_ingestion_text": "Aggiungi una sorgente di ingestione per iniziare ad archiviare le tue caselle di posta.",
"total_emails_archived": "Totale Email Archiviate",
"total_storage_used": "Spazio di Archiviazione Totale Utilizzato",
"failed_ingestions": "Ingestioni Fallite (Ultimi 7 Giorni)",
"ingestion_history": "Cronologia Ingestioni",
"no_ingestion_history": "Nessuna cronologia delle ingestioni disponibile.",
"storage_by_source": "Spazio di Archiviazione per Sorgente di Ingestione",
"no_ingestion_sources": "Nessuna sorgente di ingestione disponibile.",
"indexed_insights": "Approfondimenti indicizzati",
"top_10_senders": "I 10 Mittenti Principali",
"no_indexed_insights": "Nessun approfondimento indicizzato disponibile."
}
}
}
{
"app": {
"auth": {
"login": "Accedi",
"login_tip": "Inserisci la tua email qui sotto per accedere al tuo account.",
"email": "Email",
"password": "Password"
},
"common": {
"working": "In corso",
"read_docs": "Leggi la documentazione"
},
"archive": {
"title": "Archivio",
"no_subject": "Nessun oggetto",
"from": "Da",
"sent": "Inviato",
"recipients": "Destinatari",
"to": "A",
"meta_data": "Metadati",
"folder": "Cartella",
"tags": "Tag",
"size": "Dimensione",
"email_preview": "Anteprima email",
"attachments": "Allegati",
"download": "Scarica",
"actions": "Azioni",
"download_eml": "Scarica Email (.eml)",
"delete_email": "Elimina Email",
"email_thread": "Thread Email",
"delete_confirmation_title": "Sei sicuro di voler eliminare questa email?",
"delete_confirmation_description": "Questa azione non può essere annullata e rimuoverà definitivamente l'email e i suoi allegati.",
"deleting": "Eliminazione in corso",
"confirm": "Conferma",
"cancel": "Annulla",
"not_found": "Email non trovata.",
"integrity_report": "Rapporto di integrità",
"email_eml": "Email (.eml)",
"valid": "Valido",
"invalid": "Non valido",
"integrity_check_failed_title": "Controllo di integrità non riuscito",
"integrity_check_failed_message": "Impossibile verificare l'integrità dell'email e dei suoi allegati.",
"integrity_report_description": "Questo rapporto verifica che il contenuto delle tue email archiviate non sia stato alterato."
},
"ingestions": {
"title": "Fonti di acquisizione",
"ingestion_sources": "Fonti di acquisizione",
"bulk_actions": "Azioni di massa",
"force_sync": "Forza sincronizzazione",
"delete": "Elimina",
"create_new": "Crea nuovo",
"name": "Nome",
"provider": "Provider",
"status": "Stato",
"active": "Attivo",
"created_at": "Creato il",
"actions": "Azioni",
"last_sync_message": "Ultimo messaggio di sincronizzazione",
"empty": "Vuoto",
"open_menu": "Apri menu",
"edit": "Modifica",
"create": "Crea",
"ingestion_source": "Fonte di acquisizione",
"edit_description": "Apporta modifiche alla tua fonte di acquisizione qui.",
"create_description": "Aggiungi una nuova fonte di acquisizione per iniziare ad archiviare le email.",
"read": "Leggi",
"docs_here": "documentazione qui",
"delete_confirmation_title": "Sei sicuro di voler eliminare questa acquisizione?",
"delete_confirmation_description": "Questo cancellerà tutte le email archiviate, gli allegati, l'indicizzazione e i file associati a questa acquisizione. Se vuoi solo interrompere la sincronizzazione di nuove email, puoi invece mettere in pausa l'acquisizione.",
"deleting": "Eliminazione in corso",
"confirm": "Conferma",
"cancel": "Annulla",
"bulk_delete_confirmation_title": "Sei sicuro di voler eliminare {{count}} acquisizioni selezionate?",
"bulk_delete_confirmation_description": "Questo cancellerà tutte le email archiviate, gli allegati, l'indicizzazione e i file associati a queste acquisizioni. Se vuoi solo interrompere la sincronizzazione di nuove email, puoi invece mettere in pausa le acquisizioni."
},
"search": {
"title": "Cerca",
"description": "Cerca email archiviate.",
"email_search": "Ricerca email",
"placeholder": "Cerca per parola chiave, mittente, destinatario...",
"search_button": "Cerca",
"search_options": "Opzioni di ricerca",
"strategy_fuzzy": "Approssimativa",
"strategy_verbatim": "Testuale",
"strategy_frequency": "Frequenza",
"select_strategy": "Seleziona una strategia",
"error": "Errore",
"found_results_in": "Trovati {{total}} risultati in {{seconds}}s",
"found_results": "Trovati {{total}} risultati",
"from": "Da",
"to": "A",
"in_email_body": "Nel corpo dell'email",
"in_attachment": "Nell'allegato: {{filename}}",
"prev": "Prec",
"next": "Succ"
},
"roles": {
"title": "Gestione ruoli",
"role_management": "Gestione ruoli",
"create_new": "Crea nuovo",
"name": "Nome",
"created_at": "Creato il",
"actions": "Azioni",
"open_menu": "Apri menu",
"view_policy": "Visualizza Policy",
"edit": "Modifica",
"delete": "Elimina",
"no_roles_found": "Nessun ruolo trovato.",
"role_policy": "Policy del ruolo",
"viewing_policy_for_role": "Visualizzazione della policy per il ruolo: {{name}}",
"create": "Crea",
"role": "Ruolo",
"edit_description": "Apporta modifiche al ruolo qui.",
"create_description": "Aggiungi un nuovo ruolo al sistema.",
"delete_confirmation_title": "Sei sicuro di voler eliminare questo ruolo?",
"delete_confirmation_description": "Questa azione non può essere annullata. Questo cancellerà definitivamente il ruolo.",
"deleting": "Eliminazione in corso",
"confirm": "Conferma",
"cancel": "Annulla"
},
"account": {
"title": "Impostazioni account",
"description": "Gestisci il tuo profilo e le impostazioni di sicurezza.",
"personal_info": "Informazioni personali",
"personal_info_desc": "Aggiorna i tuoi dati personali.",
"security": "Sicurezza",
"security_desc": "Gestisci la tua password e le preferenze di sicurezza.",
"edit_profile": "Modifica profilo",
"change_password": "Cambia password",
"edit_profile_desc": "Apporta modifiche al tuo profilo qui.",
"change_password_desc": "Cambia la tua password. Dovrai inserire la tua password attuale.",
"current_password": "Password attuale",
"new_password": "Nuova password",
"confirm_new_password": "Conferma nuova password",
"operation_successful": "Operazione riuscita",
"passwords_do_not_match": "Le password non corrispondono"
},
"system_settings": {
"title": "Impostazioni di sistema",
"system_settings": "Impostazioni di sistema",
"description": "Gestisci le impostazioni globali dell'applicazione.",
"language": "Lingua",
"default_theme": "Tema predefinito",
"light": "Chiaro",
"dark": "Scuro",
"system": "Sistema",
"support_email": "Email di supporto",
"saving": "Salvataggio in corso",
"save_changes": "Salva modifiche"
},
"users": {
"title": "Gestione utenti",
"user_management": "Gestione utenti",
"create_new": "Crea nuovo",
"name": "Nome",
"email": "Email",
"role": "Ruolo",
"created_at": "Creato il",
"actions": "Azioni",
"open_menu": "Apri menu",
"edit": "Modifica",
"delete": "Elimina",
"no_users_found": "Nessun utente trovato.",
"create": "Crea",
"user": "Utente",
"edit_description": "Apporta modifiche all'utente qui.",
"create_description": "Aggiungi un nuovo utente al sistema.",
"delete_confirmation_title": "Sei sicuro di voler eliminare questo utente?",
"delete_confirmation_description": "Questa azione non può essere annullata. Questo cancellerà definitivamente l'utente e rimuoverà i suoi dati dai nostri server.",
"deleting": "Eliminazione in corso",
"confirm": "Conferma",
"cancel": "Annulla"
},
"components": {
"charts": {
"emails_ingested": "Email acquisite",
"storage_used": "Spazio di archiviazione utilizzato",
"emails": "Email"
},
"common": {
"submitting": "Invio in corso...",
"submit": "Invia",
"save": "Salva"
},
"email_preview": {
"loading": "Caricamento anteprima email...",
"render_error": "Impossibile visualizzare l'anteprima dell'email.",
"not_available": "File .eml grezzo non disponibile per questa email."
},
"footer": {
"all_rights_reserved": "Tutti i diritti riservati.",
"new_version_available": "Nuova versione disponibile"
},
"ingestion_source_form": {
"provider_generic_imap": "IMAP generico",
"provider_google_workspace": "Google Workspace",
"provider_microsoft_365": "Microsoft 365",
"provider_pst_import": "Importazione PST",
"provider_eml_import": "Importazione EML",
"provider_mbox_import": "Importazione Mbox",
"select_provider": "Seleziona un provider",
"service_account_key": "Chiave dell'account di servizio (JSON)",
"service_account_key_placeholder": "Incolla il contenuto JSON della chiave del tuo account di servizio",
"impersonated_admin_email": "Email dell'amministratore impersonato",
"client_id": "ID applicazione (client)",
"client_secret": "Valore del segreto client",
"client_secret_placeholder": "Inserisci il valore segreto, non l'ID segreto",
"tenant_id": "ID directory (tenant)",
"host": "Host",
"port": "Porta",
"username": "Nome utente",
"use_tls": "Usa TLS",
"allow_insecure_cert": "Consenti certificato non sicuro",
"pst_file": "File PST",
"eml_file": "File EML",
"mbox_file": "File Mbox",
"heads_up": "Attenzione!",
"org_wide_warning": "Tieni presente che questa è un'operazione a livello di organizzazione. Questo tipo di acquisizione importerà e indicizzerà <b>tutte</b> le caselle di posta nella tua organizzazione. Se vuoi importare solo caselle di posta specifiche, usa il connettore IMAP.",
"upload_failed": "Caricamento non riuscito, riprova"
},
"role_form": {
"policies_json": "Policy (JSON)",
"invalid_json": "Formato JSON non valido per le policy."
},
"theme_switcher": {
"toggle_theme": "Attiva/disattiva tema"
},
"user_form": {
"select_role": "Seleziona un ruolo"
}
},
"setup": {
"title": "Configurazione",
"description": "Configura l'account amministratore iniziale per Open Archiver.",
"welcome": "Benvenuto",
"create_admin_account": "Crea il primo account amministratore per iniziare.",
"first_name": "Nome",
"last_name": "Cognome",
"email": "Email",
"password": "Password",
"creating_account": "Creazione account",
"create_account": "Crea account"
},
"layout": {
"dashboard": "Dashboard",
"ingestions": "Acquisizioni",
"archived_emails": "Email archiviate",
"search": "Cerca",
"settings": "Impostazioni",
"system": "Sistema",
"users": "Utenti",
"roles": "Ruoli",
"api_keys": "Chiavi API",
"account": "Account",
"logout": "Disconnetti",
"admin": "Amministratore"
},
"api_keys_page": {
"title": "Chiavi API",
"header": "Chiavi API",
"generate_new_key": "Genera nuova chiave",
"name": "Nome",
"key": "Chiave",
"expires_at": "Scade il",
"created_at": "Creato il",
"actions": "Azioni",
"delete": "Elimina",
"no_keys_found": "Nessuna chiave API trovata.",
"generate_modal_title": "Genera nuova chiave API",
"generate_modal_description": "Fornisci un nome e una scadenza per la tua nuova chiave API.",
"expires_in": "Scade tra",
"select_expiration": "Seleziona una scadenza",
"30_days": "30 giorni",
"60_days": "60 giorni",
"6_months": "6 mesi",
"12_months": "12 mesi",
"24_months": "24 mesi",
"generate": "Genera",
"new_api_key": "Nuova chiave API",
"failed_to_delete": "Impossibile eliminare la chiave API",
"api_key_deleted": "Chiave API eliminata",
"generated_title": "Chiave API generata",
"generated_message": "La tua chiave API è stata generata, copiala e salvala in un luogo sicuro. Questa chiave verrà mostrata solo una volta."
},
"archived_emails_page": {
"title": "Email archiviate",
"header": "Email archiviate",
"select_ingestion_source": "Seleziona una fonte di acquisizione",
"date": "Data",
"subject": "Oggetto",
"sender": "Mittente",
"inbox": "Posta in arrivo",
"path": "Percorso",
"actions": "Azioni",
"view": "Visualizza",
"no_emails_found": "Nessuna email archiviata trovata.",
"prev": "Prec",
"next": "Succ"
},
"dashboard_page": {
"title": "Dashboard",
"meta_description": "Panoramica del tuo archivio email.",
"header": "Dashboard",
"create_ingestion": "Crea un'acquisizione",
"no_ingestion_header": "Non hai configurato alcuna fonte di acquisizione.",
"no_ingestion_text": "Aggiungi una fonte di acquisizione per iniziare ad archiviare le tue caselle di posta.",
"total_emails_archived": "Email totali archiviate",
"total_storage_used": "Spazio di archiviazione totale utilizzato",
"failed_ingestions": "Acquisizioni non riuscite (ultimi 7 giorni)",
"ingestion_history": "Cronologia acquisizioni",
"no_ingestion_history": "Nessuna cronologia acquisizioni disponibile.",
"storage_by_source": "Spazio di archiviazione per fonte di acquisizione",
"no_ingestion_sources": "Nessuna fonte di acquisizione disponibile.",
"indexed_insights": "Informazioni indicizzate",
"top_10_senders": "I 10 mittenti principali",
"no_indexed_insights": "Nessuna informazione indicizzata disponibile."
},
"audit_log": {
"title": "Registro di audit",
"header": "Registro di audit",
"verify_integrity": "Verifica l'integrità del registro",
"log_entries": "Voci di registro",
"timestamp": "Timestamp",
"actor": "Attore",
"action": "Azione",
"target": "Obiettivo",
"details": "Dettagli",
"ip_address": "Indirizzo IP",
"target_type": "Tipo di obiettivo",
"target_id": "ID obiettivo",
"no_logs_found": "Nessun registro di audit trovato.",
"prev": "Prec",
"next": "Succ",
"log_entry_details": "Dettagli della voce di registro",
"viewing_details_for": "Visualizzazione dei dettagli completi per la voce di registro #",
"actor_id": "ID attore",
"previous_hash": "Hash precedente",
"current_hash": "Hash corrente",
"close": "Chiudi",
"verification_successful_title": "Verifica riuscita",
"verification_successful_message": "Integrità del registro di audit verificata con successo.",
"verification_failed_title": "Verifica non riuscita",
"verification_failed_message": "Il controllo di integrità del registro di audit non è riuscito. Controlla i registri di sistema per maggiori dettagli.",
"verification_error_message": "Si è verificato un errore inatteso durante la verifica. Riprova."
},
"jobs": {
"title": "Code dei lavori",
"queues": "Code dei lavori",
"active": "Attivo",
"completed": "Completato",
"failed": "Fallito",
"delayed": "Ritardato",
"waiting": "In attesa",
"paused": "In pausa",
"back_to_queues": "Torna alle code",
"queue_overview": "Panoramica della coda",
"jobs": "Lavori",
"id": "ID",
"name": "Nome",
"state": "Stato",
"created_at": "Creato il",
"processed_at": "Elaborato il",
"finished_at": "Terminato il",
"showing": "Visualizzazione di",
"of": "di",
"previous": "Precedente",
"next": "Successivo",
"ingestion_source": "Fonte di acquisizione"
},
"license_page": {
"title": "Stato della licenza Enterprise",
"meta_description": "Visualizza lo stato attuale della tua licenza Open Archiver Enterprise.",
"revoked_title": "Licenza revocata",
"revoked_message": "La tua licenza è stata revocata dall'amministratore della licenza. Le funzionalità Enterprise verranno disabilitate {{grace_period}}. Contatta il tuo account manager per assistenza.",
"revoked_grace_period": "il {{date}}",
"revoked_immediately": "immediatamente",
"seat_limit_exceeded_title": "Limite di posti superato",
"seat_limit_exceeded_message": "La tua licenza è per {{planSeats}} utenti, ma ne stai attualmente utilizzando {{activeSeats}}. Contatta il reparto vendite per modificare il tuo abbonamento.",
"customer": "Cliente",
"license_details": "Dettagli licenza",
"license_status": "Stato licenza",
"active": "Attivo",
"expired": "Scaduto",
"revoked": "Revocato",
"unknown": "Sconosciuto",
"expires": "Scade",
"seat_usage": "Utilizzo posti",
"seats_used": "{{activeSeats}} di {{planSeats}} posti utilizzati",
"enabled_features": "Funzionalità abilitate",
"enabled_features_description": "Le seguenti funzionalità enterprise sono attualmente abilitate.",
"feature": "Funzionalità",
"status": "Stato",
"enabled": "Abilitato",
"disabled": "Disabilitato",
"could_not_load_title": "Impossibile caricare la licenza",
"could_not_load_message": "Si è verificato un errore inatteso."
}
}
}

View File

@@ -8,6 +8,7 @@
import { page } from '$app/state';
import ThemeSwitcher from '$lib/components/custom/ThemeSwitcher.svelte';
import { t } from '$lib/translations';
import Badge from '$lib/components/ui/badge/badge.svelte';
let { data, children } = $props();
interface NavItem {
@@ -76,7 +77,21 @@
const enterpriseNavItems: NavItem[] = [
{
label: 'Compliance',
subMenu: [{ href: '/dashboard/compliance/audit-log', label: 'Audit Log' }],
subMenu: [
{ href: '/dashboard/compliance/audit-log', label: $t('app.audit_log.title') },
{
href: '/dashboard/compliance/retention-policies',
label: $t('app.retention_policies.title'),
},
{
href: '/dashboard/compliance/retention-labels',
label: $t('app.retention_labels.title'),
},
{
href: '/dashboard/compliance/legal-holds',
label: $t('app.legal_holds.title'),
},
],
position: 3,
},
{
@@ -130,6 +145,9 @@
<a href="/dashboard" class="flex flex-row items-center gap-2 font-bold">
<img src="/logos/logo-sq.svg" alt="OpenArchiver Logo" class="h-8 w-8" />
<span class="hidden sm:inline-block">Open Archiver</span>
{#if data.enterpriseMode}
<Badge class="text-[8px] font-bold px-1 py-0.5">Enterprise</Badge>
{/if}
</a>
<!-- Desktop Navigation -->
@@ -151,7 +169,7 @@
{item.label}
</NavigationMenu.Trigger>
<NavigationMenu.Content>
<ul class="grid w-fit min-w-32 gap-1 p-1">
<ul class="grid w-fit min-w-40 gap-1 p-1">
{#each item.subMenu as subItem}
<li>
<NavigationMenu.Link href={subItem.href}>

View File

@@ -133,7 +133,7 @@
<Table.Row id={`error-${job.id}`} class="hidden">
<Table.Cell colspan={7} class="p-0">
<pre
class="max-w-full text-wrap rounded-md bg-gray-100 p-4 text-xs">{job.error}</pre>
class="bg-muted max-w-full text-wrap rounded-md p-4 text-xs">{job.error}</pre>
</Table.Cell>
</Table.Row>
{/if}

View File

@@ -1,7 +1,15 @@
import { api } from '$lib/server/api';
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import type { ArchivedEmail, IntegrityCheckResult } from '@open-archiver/types';
import type { Actions, PageServerLoad } from './$types';
import type {
ArchivedEmail,
IntegrityCheckResult,
PolicyEvaluationResult,
RetentionLabel,
EmailRetentionLabelInfo,
LegalHold,
EmailLegalHoldInfo,
} from '@open-archiver/types';
export const load: PageServerLoad = async (event) => {
try {
@@ -31,16 +39,148 @@ export const load: PageServerLoad = async (event) => {
const email: ArchivedEmail = await emailResponse.json();
const integrityReport: IntegrityCheckResult[] = await integrityResponse.json();
// Enterprise-only: fetch retention policy evaluation separately
// to keep the OSS code path completely untouched.
let retentionPolicy: PolicyEvaluationResult | null = null;
let retentionLabels: RetentionLabel[] = [];
let emailRetentionLabel: EmailRetentionLabelInfo | null = null;
let legalHolds: LegalHold[] = [];
let emailLegalHolds: EmailLegalHoldInfo[] = [];
if (event.locals.enterpriseMode) {
// Fetch all enterprise compliance data in parallel — all best-effort
const [retentionRes, labelsRes, emailLabelRes, holdsRes, emailHoldsRes] =
await Promise.all([
api(`/enterprise/retention-policy/email/${id}`, event).catch(() => null),
api('/enterprise/retention-policy/labels', event).catch(() => null),
api(`/enterprise/retention-policy/email/${id}/label`, event).catch(() => null),
api('/enterprise/legal-holds/holds', event).catch(() => null),
api(`/enterprise/legal-holds/email/${id}/holds`, event).catch(() => null),
]);
if (retentionRes?.ok) {
retentionPolicy = await retentionRes.json();
}
if (labelsRes?.ok) {
const labelsJson: RetentionLabel[] = await labelsRes.json();
// Only show enabled labels in the dropdown
retentionLabels = labelsJson.filter((l) => !l.isDisabled);
}
if (emailLabelRes?.ok) {
emailRetentionLabel = await emailLabelRes.json();
}
if (holdsRes?.ok) {
const holdsJson: LegalHold[] = await holdsRes.json();
// Only show active holds in the apply dropdown
legalHolds = holdsJson.filter((h) => h.isActive);
}
if (emailHoldsRes?.ok) {
emailLegalHolds = await emailHoldsRes.json();
}
}
return {
email,
integrityReport,
retentionPolicy,
retentionLabels,
emailRetentionLabel,
legalHolds,
emailLegalHolds,
};
} catch (e) {
console.error('Failed to load archived email:', e);
return {
email: null,
integrityReport: [],
retentionPolicy: null,
retentionLabels: [],
emailRetentionLabel: null,
legalHolds: [],
emailLegalHolds: [],
error: 'Failed to load email',
};
}
};
export const actions: Actions = {
applyLabel: async (event) => {
const data = await event.request.formData();
const emailId = event.params.id;
const labelId = data.get('labelId') as string;
const response = await api(`/enterprise/retention-policy/email/${emailId}/label`, event, {
method: 'POST',
body: JSON.stringify({ labelId }),
});
if (!response.ok) {
const res = await response.json().catch(() => ({}));
return { success: false, message: (res as { message?: string }).message || 'Failed to apply label' };
}
return { success: true, action: 'applied' };
},
removeLabel: async (event) => {
const emailId = event.params.id;
const response = await api(`/enterprise/retention-policy/email/${emailId}/label`, event, {
method: 'DELETE',
});
if (!response.ok) {
const res = await response.json().catch(() => ({}));
return { success: false, message: (res as { message?: string }).message || 'Failed to remove label' };
}
return { success: true, action: 'removed' };
},
applyHold: async (event) => {
const data = await event.request.formData();
const emailId = event.params.id;
const holdId = data.get('holdId') as string;
const response = await api(`/enterprise/legal-holds/email/${emailId}/holds`, event, {
method: 'POST',
body: JSON.stringify({ holdId }),
});
if (!response.ok) {
const res = await response.json().catch(() => ({}));
return {
success: false,
message: (res as { message?: string }).message || 'Failed to apply legal hold.',
};
}
return { success: true, action: 'holdApplied' };
},
removeHold: async (event) => {
const data = await event.request.formData();
const emailId = event.params.id;
const holdId = data.get('holdId') as string;
const response = await api(
`/enterprise/legal-holds/email/${emailId}/holds/${holdId}`,
event,
{ method: 'DELETE' }
);
if (!response.ok) {
const res = await response.json().catch(() => ({}));
return {
success: false,
message: (res as { message?: string }).message || 'Failed to remove legal hold.',
};
}
return { success: true, action: 'holdRemoved' };
},
};

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import type { PageData } from './$types';
import type { ActionData, PageData } from './$types';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import EmailPreview from '$lib/components/custom/EmailPreview.svelte';
@@ -9,19 +9,116 @@
import { formatBytes } from '$lib/utils';
import { goto } from '$app/navigation';
import * as Dialog from '$lib/components/ui/dialog';
import * as Select from '$lib/components/ui/select/index.js';
import { setAlert } from '$lib/components/custom/alert/alert-state.svelte';
import { t } from '$lib/translations';
import { ShieldCheck, ShieldAlert, AlertTriangle } from 'lucide-svelte';
import * as Alert from '$lib/components/ui/alert';
import { Badge } from '$lib/components/ui/badge';
import * as HoverCard from '$lib/components/ui/hover-card';
import { Clock, Trash2, CalendarClock, AlertCircle, Shield, CircleAlert, Tag } from 'lucide-svelte';
import { page } from '$app/state';
import { enhance } from '$app/forms';
import type { LegalHold, EmailLegalHoldInfo } from '@open-archiver/types';
let { data }: { data: PageData } = $props();
let { data, form }: { data: PageData; form: ActionData } = $props();
let email = $derived(data.email);
let integrityReport = $derived(data.integrityReport);
let retentionPolicy = $derived(data.retentionPolicy);
let retentionLabels = $derived(data.retentionLabels);
let emailRetentionLabel = $derived(data.emailRetentionLabel);
let legalHolds = $derived(data.legalHolds as LegalHold[]);
let emailLegalHolds = $derived(data.emailLegalHolds as EmailLegalHoldInfo[]);
let enterpriseMode = $derived(page.data.enterpriseMode);
/** Scheduled deletion date from the matching retention policy: sentAt + appliedRetentionDays */
let scheduledDeletionDate = $derived.by(() => {
if (!email || !retentionPolicy || retentionPolicy.appliedRetentionDays === 0) return null;
const sentDate = new Date(email.sentAt);
const deletionDate = new Date(sentDate);
deletionDate.setDate(deletionDate.getDate() + retentionPolicy.appliedRetentionDays);
return deletionDate;
});
/**
* Scheduled deletion date derived from the applied retention label.
* Only computed when a label is active (not disabled).
*/
let scheduledDeletionDateByLabel = $derived.by(() => {
if (!email || !emailRetentionLabel || emailRetentionLabel.isLabelDisabled) return null;
const sentDate = new Date(email.sentAt);
const deletionDate = new Date(sentDate);
deletionDate.setDate(deletionDate.getDate() + emailRetentionLabel.retentionPeriodDays);
return deletionDate;
});
let isDeleteDialogOpen = $state(false);
let isDeleting = $state(false);
// --- Label state ---
let selectedLabelId = $state('');
let isApplyingLabel = $state(false);
let isRemovingLabel = $state(false);
// --- Legal hold state (enterprise only) ---
let selectedHoldId = $state('');
let isApplyingHold = $state(false);
let isRemovingHoldId = $state<string | null>(null);
// React to form results for label and hold actions
$effect(() => {
if (form) {
if (form.success === false && form.message) {
setAlert({
type: 'error',
title: $t('app.archive_labels.apply_error'),
message: String(form.message),
duration: 5000,
show: true,
});
}
if (form.success && form.action === 'applied') {
setAlert({
type: 'success',
title: $t('app.archive_labels.apply_success'),
message: '',
duration: 3000,
show: true,
});
selectedLabelId = '';
}
if (form.success && form.action === 'removed') {
setAlert({
type: 'success',
title: $t('app.archive_labels.remove_success'),
message: '',
duration: 3000,
show: true,
});
selectedLabelId = '';
}
if (form.success && form.action === 'holdApplied') {
setAlert({
type: 'success',
title: $t('app.archive_legal_holds.apply_success'),
message: '',
duration: 3000,
show: true,
});
selectedHoldId = '';
}
if (form.success && form.action === 'holdRemoved') {
setAlert({
type: 'success',
title: $t('app.archive_legal_holds.remove_success'),
message: '',
duration: 3000,
show: true,
});
}
}
});
async function download(path: string, filename: string) {
if (!browser) return;
@@ -43,7 +140,6 @@
a.remove();
} catch (error) {
console.error('Download failed:', error);
// Optionally, show an error message to the user
}
}
@@ -156,6 +252,7 @@
<Button
variant="outline"
size="sm"
class="text-xs"
onclick={() =>
download(
attachment.storagePath,
@@ -180,21 +277,22 @@
</Card.Header>
<Card.Content class="space-y-2">
<Button
class="text-xs"
onclick={() =>
download(email.storagePath, `${email.subject || 'email'}.eml`)}
>{$t('app.archive.download_eml')}</Button
>
<Button variant="destructive" onclick={() => (isDeleteDialogOpen = true)}>
<Button variant="destructive" class="text-xs" onclick={() => (isDeleteDialogOpen = true)}>
{$t('app.archive.delete_email')}
</Button>
</Card.Content>
</Card.Root>
{#if integrityReport && integrityReport.length > 0}
<Card.Root>
<Card.Header>
<Card.Title>{$t('app.archive.integrity_report')}</Card.Title>
<Card.Description>
<Card.Description class="text-xs">
<span class="mt-1">
{$t('app.archive.integrity_report_description')}
<a
@@ -221,7 +319,7 @@
/>
{/if}
<div class="min-w-0 max-w-64">
<p class="truncate text-sm font-medium">
<p class="truncate text-xs font-medium">
{#if item.type === 'email'}
{$t('app.archive.email_eml')}
{:else}
@@ -260,6 +358,439 @@
</Alert.Description>
</Alert.Root>
{/if}
<!-- Legal Holds card (Enterprise only) -->
{#if enterpriseMode}
<Card.Root>
<Card.Header>
<Card.Title class="flex items-center gap-2">
{$t('app.archive_legal_holds.section_title')}
</Card.Title>
<Card.Description class="text-xs">
<span class="mt-1">
{$t('app.archive_legal_holds.section_description')}
<a
href="https://docs.openarchiver.com/enterprise/legal-holds/guide.html"
target="_blank"
class="text-primary underline underline-offset-2"
>{$t('app.common.read_docs')}</a
>.
</span>
</Card.Description>
</Card.Header>
<Card.Content class="space-y-3">
<!-- List of holds already applied to this email -->
{#if emailLegalHolds && emailLegalHolds.length > 0}
<div class="space-y-2">
{#each emailLegalHolds as holdInfo (holdInfo.legalHoldId)}
<div class="flex items-center justify-between rounded-md border p-2">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="truncate text-xs font-medium">
{holdInfo.holdName}
</span>
{#if holdInfo.isActive}
<Badge class="bg-destructive text-white text-xs">
{$t('app.legal_holds.active')}
</Badge>
{:else}
<Badge variant="secondary" class="text-xs">
{$t('app.legal_holds.inactive')}
</Badge>
{/if}
</div>
<p class="text-muted-foreground mt-0.5 text-xs">
{$t('app.archive_legal_holds.applied_at')}:
{new Date(holdInfo.appliedAt).toLocaleDateString()}
</p>
</div>
<form
method="POST"
action="?/removeHold"
use:enhance={() => {
isRemovingHoldId = holdInfo.legalHoldId;
return async ({ update }) => {
isRemovingHoldId = null;
await update();
};
}}
>
<input
type="hidden"
name="holdId"
value={holdInfo.legalHoldId}
/>
<Button
type="submit"
variant="ghost"
size="sm"
class="text-muted-foreground hover:text-destructive ml-2 shrink-0 text-xs"
disabled={isRemovingHoldId === holdInfo.legalHoldId}
>
{#if isRemovingHoldId === holdInfo.legalHoldId}
{$t('app.archive_legal_holds.removing')}
{:else}
{$t('app.archive_legal_holds.remove')}
{/if}
</Button>
</form>
</div>
{/each}
</div>
{:else}
<p class="text-muted-foreground text-xs">
{$t('app.archive_legal_holds.no_holds')}
</p>
{/if}
<!-- Apply an additional hold to this email -->
{#if legalHolds && legalHolds.length > 0}
<form
method="POST"
action="?/applyHold"
class="space-y-2"
use:enhance={() => {
isApplyingHold = true;
return async ({ update }) => {
isApplyingHold = false;
await update();
};
}}
>
<input type="hidden" name="holdId" value={selectedHoldId} />
<Select.Root
type="single"
value={selectedHoldId}
onValueChange={(v) => (selectedHoldId = v)}
>
<Select.Trigger class="w-full text-xs">
{#if selectedHoldId}
{legalHolds.find((h) => h.id === selectedHoldId)?.name ??
$t('app.archive_legal_holds.apply_hold_placeholder')}
{:else}
{$t('app.archive_legal_holds.apply_hold_placeholder')}
{/if}
</Select.Trigger>
<Select.Content class="text-xs">
{#each legalHolds as hold (hold.id)}
<Select.Item value={hold.id} class="text-xs">{hold.name}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<Button
type="submit"
variant="outline"
size="sm"
class="w-full text-xs"
disabled={isApplyingHold || !selectedHoldId}
>
{#if isApplyingHold}
{$t('app.archive_legal_holds.applying')}
{:else}
{$t('app.archive_legal_holds.apply')}
{/if}
</Button>
</form>
{:else if !emailLegalHolds?.length}
<p class="text-muted-foreground text-xs">
{$t('app.archive_legal_holds.no_active_holds')}
</p>
{/if}
</Card.Content>
</Card.Root>
{/if}
{#if enterpriseMode && retentionPolicy}
<Card.Root>
<Card.Header>
<Card.Title>{$t('app.archive.retention_policy')}</Card.Title>
<Card.Description class="text-xs">
<span class="mt-1">
{$t('app.archive.retention_policy_description')}
<a
href="https://docs.openarchiver.com/enterprise/retention-policy/guide.html"
target="_blank"
class="text-primary underline underline-offset-2"
>{$t('app.common.read_docs')}</a
>.
</span>
</Card.Description>
</Card.Header>
<Card.Content class="space-y-3">
<!-- Override notice: shown when an active retention label is applied -->
{#if emailRetentionLabel && !emailRetentionLabel.isLabelDisabled}
<div class="flex items-start align-middle gap-2 rounded-md px-2 py-1.5 bg-muted-foreground text-muted">
<CircleAlert class=" h-4 w-4 flex-shrink-0" />
<div class=" text-xs">
{$t('app.archive.retention_policy_overridden_by_label')}
<span class="font-medium">{emailRetentionLabel.labelName}</span>.
</div>
</div>
{/if}
{#if retentionPolicy.appliedRetentionDays === 0}
<p class="text-muted-foreground text-xs">
{$t('app.archive.retention_no_policy')}
</p>
{:else}
<div class="space-y-2">
<div class="flex items-center gap-2">
<Clock class="text-muted-foreground h-4 w-4 flex-shrink-0" />
<span class="text-xs font-medium"
>{$t('app.archive.retention_period')}:</span
>
<Badge variant="secondary">
{retentionPolicy.appliedRetentionDays}
{$t('app.retention_policies.days')}
</Badge>
</div>
{#if scheduledDeletionDate}
<div class="flex items-center gap-2">
<CalendarClock
class="text-muted-foreground h-4 w-4 flex-shrink-0"
/>
<span class="text-xs font-medium"
>{$t('app.archive.retention_scheduled_deletion')}:</span
>
<Badge
variant={scheduledDeletionDate <= new Date()
? 'destructive'
: 'secondary'}
>
{scheduledDeletionDate.toLocaleDateString()}
</Badge>
</div>
{/if}
<div class="flex items-center gap-2">
<Trash2 class="text-muted-foreground h-4 w-4 flex-shrink-0" />
<span class="text-xs font-medium"
>{$t('app.archive.retention_action')}:</span
>
<Badge variant="outline">
{$t('app.archive.retention_delete_permanently')}
</Badge>
</div>
{#if retentionPolicy.matchingPolicyIds.length > 0}
<div class="space-y-2">
<div class="text-xs font-medium">
{$t('app.archive.retention_matching_policies')}:
</div>
<div class="flex flex-wrap gap-1">
{#each retentionPolicy.matchingPolicyIds as policyId}
<Badge variant="outline" class="text-xs font-mono">
{policyId}
</Badge>
{/each}
</div>
</div>
{/if}
</div>
{/if}
</Card.Content>
</Card.Root>
{/if}
<!-- Retention Label section (Enterprise only) -->
{#if enterpriseMode}
<Card.Root>
<Card.Header>
<Card.Title class="flex items-center gap-2">
{$t('app.archive_labels.section_title')}
</Card.Title>
<Card.Description class="text-xs">
<span class="mt-1">
{$t('app.archive_labels.section_description')}
<a
href="https://docs.openarchiver.com/enterprise/retention-labels/guide.html"
target="_blank"
class="text-primary underline underline-offset-2"
>{$t('app.common.read_docs')}</a
>.
</span>
</Card.Description>
</Card.Header>
<Card.Content class="space-y-3">
{#if emailRetentionLabel}
<!-- A label is applied to this email -->
{#if emailRetentionLabel.isLabelDisabled}
<!-- Label exists but has been disabled — show inactive state -->
<div class="space-y-2">
<div class="flex items-center gap-2">
<span class="text-muted-foreground text-xs">
{$t('app.archive_labels.current_label')}:
</span>
<Badge variant="secondary">
{emailRetentionLabel.labelName}
</Badge>
<Badge variant="outline" class="text-muted-foreground text-xs">
{$t('app.archive_labels.label_inactive')}
</Badge>
</div>
<div class="flex items-start gap-2 rounded-md border border-dashed p-2">
<AlertCircle class="text-muted-foreground mt-0.5 h-4 w-4 flex-shrink-0" />
<p class="text-muted-foreground text-xs">
{$t('app.archive_labels.label_inactive_note')}
</p>
</div>
<!-- Still allow removal of the inactive label -->
<form
method="POST"
action="?/removeLabel"
use:enhance={() => {
isRemovingLabel = true;
return async ({ update }) => {
isRemovingLabel = false;
await update();
};
}}
>
<Button
type="submit"
variant="outline"
size="sm"
class="w-full text-xs"
disabled={isRemovingLabel}
>
{#if isRemovingLabel}
{$t('app.archive_labels.removing')}
{:else}
{$t('app.archive_labels.remove')}
{/if}
</Button>
</form>
</div>
{:else}
<!-- Active label applied — show full details including scheduled deletion -->
<div class="space-y-2">
<div class="flex items-center gap-2">
<span class="text-muted-foreground text-xs">
{$t('app.archive_labels.current_label')}:
</span>
<Badge variant="default">
{emailRetentionLabel.labelName}
</Badge>
</div>
<div class="flex items-center gap-2">
<Clock class="text-muted-foreground h-4 w-4 flex-shrink-0" />
<span class="text-xs font-medium">
{$t('app.archive.retention_period')}:
</span>
<Badge variant="secondary">
{emailRetentionLabel.retentionPeriodDays}
{$t('app.retention_labels.days')}
</Badge>
</div>
{#if scheduledDeletionDateByLabel}
<div class="flex items-center gap-2">
<CalendarClock
class="text-muted-foreground h-4 w-4 flex-shrink-0"
/>
<span class="text-xs font-medium">
{$t('app.archive.retention_scheduled_deletion')}:
</span>
<Badge
variant={scheduledDeletionDateByLabel <= new Date()
? 'destructive'
: 'secondary'}
>
{scheduledDeletionDateByLabel.toLocaleDateString()}
</Badge>
</div>
{/if}
<p class="text-muted-foreground text-xs">
{$t('app.archive_labels.label_overrides_policy')}
</p>
<form
method="POST"
action="?/removeLabel"
use:enhance={() => {
isRemovingLabel = true;
return async ({ update }) => {
isRemovingLabel = false;
await update();
};
}}
>
<Button
type="submit"
variant="outline"
size="sm"
class="w-full text-xs"
disabled={isRemovingLabel}
>
{#if isRemovingLabel}
{$t('app.archive_labels.removing')}
{:else}
{$t('app.archive_labels.remove')}
{/if}
</Button>
</form>
</div>
{/if}
{:else if retentionLabels.length > 0}
<!-- No label applied — show selector -->
<p class="text-muted-foreground text-xs">
{$t('app.archive_labels.no_label')}
</p>
<form
method="POST"
action="?/applyLabel"
class="space-y-2"
use:enhance={() => {
isApplyingLabel = true;
return async ({ update }) => {
isApplyingLabel = false;
await update();
};
}}
>
<input type="hidden" name="labelId" value={selectedLabelId} />
<Select.Root
type="single"
value={selectedLabelId}
onValueChange={(v) => (selectedLabelId = v)}
>
<Select.Trigger class="w-full text-xs">
{#if selectedLabelId}
{retentionLabels.find((l) => l.id === selectedLabelId)?.name ??
$t('app.archive_labels.select_label_placeholder')}
{:else}
{$t('app.archive_labels.select_label_placeholder')}
{/if}
</Select.Trigger>
<Select.Content class="text-xs">
{#each retentionLabels as label (label.id)}
<Select.Item value={label.id}>
{label.name}
<span class="text-muted-foreground ml-1 text-xs">
({label.retentionPeriodDays} {$t('app.retention_labels.days')})
</span>
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<Button
type="submit"
variant="outline"
size="sm"
class="w-full text-xs"
disabled={isApplyingLabel || !selectedLabelId}
>
{#if isApplyingLabel}
{$t('app.archive_labels.applying')}
{:else}
{$t('app.archive_labels.apply')}
{/if}
</Button>
</form>
{:else}
<p class="text-muted-foreground text-xs">
{$t('app.archive_labels.no_labels_available')}
</p>
{/if}
</Card.Content>
</Card.Root>
{/if}
{#if email.thread && email.thread.length > 1}
<Card.Root>

View File

@@ -0,0 +1,157 @@
import { api } from '$lib/server/api';
import { error } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import type { LegalHold, SearchQuery } from '@open-archiver/types';
export const load: PageServerLoad = async (event) => {
if (!event.locals.enterpriseMode) {
throw error(
403,
'This feature is only available in the Enterprise Edition. Please contact Open Archiver to upgrade.'
);
}
const holdsRes = await api('/enterprise/legal-holds/holds', event);
const holdsJson = await holdsRes.json();
if (!holdsRes.ok) {
throw error(holdsRes.status, holdsJson.message || JSON.stringify(holdsJson));
}
const holds: LegalHold[] = holdsJson;
return { holds };
};
export const actions: Actions = {
create: async (event) => {
const data = await event.request.formData();
const body = {
name: data.get('name') as string,
reason: (data.get('reason') as string) || undefined,
};
const response = await api('/enterprise/legal-holds/holds', event, {
method: 'POST',
body: JSON.stringify(body),
});
const res = await response.json();
if (!response.ok) {
return { success: false, message: res.message || 'Failed to create legal hold.' };
}
return { success: true };
},
update: async (event) => {
const data = await event.request.formData();
const id = data.get('id') as string;
const body: Record<string, string | undefined> = {
name: data.get('name') as string,
reason: (data.get('reason') as string) || undefined,
};
const response = await api(`/enterprise/legal-holds/holds/${id}`, event, {
method: 'PUT',
body: JSON.stringify(body),
});
const res = await response.json();
if (!response.ok) {
return { success: false, message: res.message || 'Failed to update legal hold.' };
}
return { success: true };
},
toggleActive: async (event) => {
const data = await event.request.formData();
const id = data.get('id') as string;
const isActive = data.get('isActive') === 'true';
const response = await api(`/enterprise/legal-holds/holds/${id}`, event, {
method: 'PUT',
body: JSON.stringify({ isActive }),
});
const res = await response.json();
if (!response.ok) {
return { success: false, message: res.message || 'Failed to update legal hold.' };
}
return { success: true, isActive };
},
delete: async (event) => {
const data = await event.request.formData();
const id = data.get('id') as string;
const response = await api(`/enterprise/legal-holds/holds/${id}`, event, {
method: 'DELETE',
});
if (!response.ok) {
const res = await response.json().catch(() => ({}));
return { success: false, message: (res as { message?: string }).message || 'Failed to delete legal hold.' };
}
return { success: true };
},
bulkApply: async (event) => {
const data = await event.request.formData();
const holdId = data.get('holdId') as string;
const rawQuery = data.get('searchQuery') as string;
let searchQuery: SearchQuery;
try {
searchQuery = JSON.parse(rawQuery) as SearchQuery;
} catch {
return { success: false, message: 'Invalid search query format.' };
}
const response = await api(`/enterprise/legal-holds/holds/${holdId}/bulk-apply`, event, {
method: 'POST',
body: JSON.stringify({ searchQuery }),
});
const res = await response.json();
if (!response.ok) {
return {
success: false,
message: (res as { message?: string }).message || 'Bulk apply failed.',
};
}
const result = res as { emailsLinked: number };
return { success: true, emailsLinked: result.emailsLinked };
},
releaseAll: async (event) => {
const data = await event.request.formData();
const id = data.get('id') as string;
const response = await api(`/enterprise/legal-holds/holds/${id}/release-all`, event, {
method: 'POST',
});
const res = await response.json();
if (!response.ok) {
return {
success: false,
message: (res as { message?: string }).message || 'Failed to release emails from hold.',
};
}
const result = res as { emailsReleased: number };
return { success: true, emailsReleased: result.emailsReleased };
},
};

View File

@@ -0,0 +1,626 @@
<script lang="ts">
import type { ActionData, PageData } from './$types';
import { t } from '$lib/translations';
import { Button } from '$lib/components/ui/button';
import { Badge } from '$lib/components/ui/badge';
import * as Table from '$lib/components/ui/table';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Textarea } from '$lib/components/ui/textarea';
import { enhance } from '$app/forms';
import { MoreHorizontal, Plus, Users } from 'lucide-svelte';
import { setAlert } from '$lib/components/custom/alert/alert-state.svelte';
import type { LegalHold } from '@open-archiver/types';
let { data, form }: { data: PageData; form: ActionData } = $props();
let holds = $derived(data.holds);
// --- Dialog state ---
let isCreateOpen = $state(false);
let isEditOpen = $state(false);
let isDeleteOpen = $state(false);
let isBulkApplyOpen = $state(false);
let isReleaseAllOpen = $state(false);
let selectedHold = $state<LegalHold | null>(null);
let isFormLoading = $state(false);
// Bulk apply search query fields
let bulkQuery = $state('');
let bulkFiltersFrom = $state('');
let bulkFiltersDateStart = $state('');
let bulkFiltersDateEnd = $state('');
function openEdit(hold: LegalHold) {
selectedHold = hold;
isEditOpen = true;
}
function openDelete(hold: LegalHold) {
selectedHold = hold;
isDeleteOpen = true;
}
function openBulkApply(hold: LegalHold) {
selectedHold = hold;
bulkQuery = '';
bulkFiltersFrom = '';
bulkFiltersDateStart = '';
bulkFiltersDateEnd = '';
isBulkApplyOpen = true;
}
function openReleaseAll(hold: LegalHold) {
selectedHold = hold;
isReleaseAllOpen = true;
}
/** Builds a SearchQuery JSON string from the bulk apply form fields. */
function buildSearchQuery(): string {
const filters: Record<string, string> = {};
if (bulkFiltersFrom) filters['from'] = bulkFiltersFrom;
if (bulkFiltersDateStart) filters['startDate'] = bulkFiltersDateStart;
if (bulkFiltersDateEnd) filters['endDate'] = bulkFiltersDateEnd;
return JSON.stringify({
query: bulkQuery,
filters: Object.keys(filters).length > 0 ? filters : undefined,
matchingStrategy: 'all',
});
}
</script>
<svelte:head>
<title>{$t('app.legal_holds.title')} - Open Archiver</title>
<meta name="description" content={$t('app.legal_holds.meta_description')} />
<meta
name="keywords"
content="legal hold, eDiscovery, compliance, litigation hold, evidence preservation, spoliation prevention"
/>
</svelte:head>
<div class="mb-6 flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold">{$t('app.legal_holds.header')}</h1>
<p class="text-muted-foreground mt-1 text-sm">
{$t('app.legal_holds.header_description')}
</p>
</div>
<Button onclick={() => (isCreateOpen = true)}>
<Plus class="mr-1.5 h-4 w-4" />
{$t('app.legal_holds.create_new')}
</Button>
</div>
<div class="rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>{$t('app.legal_holds.name')}</Table.Head>
<Table.Head>{$t('app.legal_holds.reason')}</Table.Head>
<Table.Head>{$t('app.legal_holds.email_count')}</Table.Head>
<Table.Head>{$t('app.legal_holds.status')}</Table.Head>
<Table.Head>{$t('app.legal_holds.created_at')}</Table.Head>
<Table.Head class="text-right">{$t('app.legal_holds.actions')}</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if holds && holds.length > 0}
{#each holds as hold (hold.id)}
<Table.Row>
<Table.Cell class="font-medium">
<div class="flex items-center gap-2">
<div>
<div>{hold.name}</div>
<div class="mt-0.5 font-mono text-[10px] text-muted-foreground">
{hold.id}
</div>
</div>
</div>
</Table.Cell>
<Table.Cell class="max-w-[300px]">
{#if hold.reason}
<span class="text-muted-foreground line-clamp-2 text-xs">{hold.reason}</span>
{:else}
<span class="text-muted-foreground text-xs italic">
{$t('app.legal_holds.no_reason')}
</span>
{/if}
</Table.Cell>
<Table.Cell>
<div class="flex items-center gap-1.5">
<Users class="text-muted-foreground h-3.5 w-3.5" />
<Badge variant={hold.emailCount > 0 ? 'secondary' : 'outline'}>
{hold.emailCount}
</Badge>
</div>
</Table.Cell>
<Table.Cell>
{#if hold.isActive}
<Badge class="bg-destructive text-white">
{$t('app.legal_holds.active')}
</Badge>
{:else}
<Badge variant="secondary">
{$t('app.legal_holds.inactive')}
</Badge>
{/if}
</Table.Cell>
<Table.Cell>
{new Date(hold.createdAt).toLocaleDateString()}
</Table.Cell>
<Table.Cell class="text-right">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button
{...props}
variant="ghost"
size="icon"
class="h-8 w-8"
aria-label={$t('app.ingestions.open_menu')}
>
<MoreHorizontal class="h-4 w-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item onclick={() => openEdit(hold)}>
{$t('app.legal_holds.edit')}
</DropdownMenu.Item>
{#if hold.isActive}
<DropdownMenu.Item onclick={() => openBulkApply(hold)}>
{$t('app.legal_holds.bulk_apply')}
</DropdownMenu.Item>
{/if}
{#if hold.emailCount > 0}
<DropdownMenu.Item onclick={() => openReleaseAll(hold)}>
{$t('app.legal_holds.release_all')}
</DropdownMenu.Item>
{/if}
<!-- Toggle active/inactive -->
<form method="POST" action="?/toggleActive" use:enhance={() => {
return async ({ result, update }) => {
if (result.type === 'success' && result.data?.success !== false) {
const newState = result.data?.isActive as boolean;
setAlert({
type: 'success',
title: newState
? $t('app.legal_holds.activated_success')
: $t('app.legal_holds.deactivated_success'),
message: '',
duration: 3000,
show: true,
});
} else if (result.type === 'success' && result.data?.success === false) {
setAlert({
type: 'error',
title: $t('app.legal_holds.update_error'),
message: String(result.data?.message ?? ''),
duration: 5000,
show: true,
});
}
await update();
};
}}>
<input type="hidden" name="id" value={hold.id} />
<input type="hidden" name="isActive" value={String(!hold.isActive)} />
<DropdownMenu.Item>
<button type="submit" class="w-full text-left">
{hold.isActive
? $t('app.legal_holds.deactivate')
: $t('app.legal_holds.activate')}
</button>
</DropdownMenu.Item>
</form>
<DropdownMenu.Separator />
<DropdownMenu.Item
class="text-destructive focus:text-destructive"
onclick={() => openDelete(hold)}
>
{$t('app.legal_holds.delete')}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
</Table.Row>
{/each}
{:else}
<Table.Row>
<Table.Cell colspan={6} class="h-24 text-center">
{$t('app.legal_holds.no_holds_found')}
</Table.Cell>
</Table.Row>
{/if}
</Table.Body>
</Table.Root>
</div>
<!-- Create dialog -->
<Dialog.Root bind:open={isCreateOpen}>
<Dialog.Content class="sm:max-w-[500px]">
<Dialog.Header>
<Dialog.Title>{$t('app.legal_holds.create')}</Dialog.Title>
<Dialog.Description>
{$t('app.legal_holds.create_description')}
</Dialog.Description>
</Dialog.Header>
<form
method="POST"
action="?/create"
class="space-y-4"
use:enhance={() => {
isFormLoading = true;
return async ({ result, update }) => {
isFormLoading = false;
if (result.type === 'success' && result.data?.success !== false) {
isCreateOpen = false;
setAlert({
type: 'success',
title: $t('app.legal_holds.create_success'),
message: '',
duration: 3000,
show: true,
});
} else if (result.type === 'success' && result.data?.success === false) {
setAlert({
type: 'error',
title: $t('app.legal_holds.create_error'),
message: String(result.data?.message ?? ''),
duration: 5000,
show: true,
});
}
await update();
};
}}
>
<div class="space-y-1.5">
<Label for="create-name">{$t('app.legal_holds.name')}</Label>
<Input
id="create-name"
name="name"
required
placeholder={$t('app.legal_holds.name_placeholder')}
/>
</div>
<div class="space-y-1.5">
<Label for="create-reason">{$t('app.legal_holds.reason')}</Label>
<Textarea
id="create-reason"
name="reason"
placeholder={$t('app.legal_holds.reason_placeholder')}
/>
</div>
<div class="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onclick={() => (isCreateOpen = false)}
disabled={isFormLoading}
>
{$t('app.legal_holds.cancel')}
</Button>
<Button type="submit" disabled={isFormLoading}>
{#if isFormLoading}
{$t('app.common.working')}
{:else}
{$t('app.legal_holds.create')}
{/if}
</Button>
</div>
</form>
</Dialog.Content>
</Dialog.Root>
<!-- Edit dialog -->
<Dialog.Root bind:open={isEditOpen}>
<Dialog.Content class="sm:max-w-[500px]">
<Dialog.Header>
<Dialog.Title>{$t('app.legal_holds.edit')}</Dialog.Title>
<Dialog.Description>
{$t('app.legal_holds.edit_description')}
</Dialog.Description>
</Dialog.Header>
{#if selectedHold}
<form
method="POST"
action="?/update"
class="space-y-4"
use:enhance={() => {
isFormLoading = true;
return async ({ result, update }) => {
isFormLoading = false;
if (result.type === 'success' && result.data?.success !== false) {
isEditOpen = false;
selectedHold = null;
setAlert({
type: 'success',
title: $t('app.legal_holds.update_success'),
message: '',
duration: 3000,
show: true,
});
} else if (result.type === 'success' && result.data?.success === false) {
setAlert({
type: 'error',
title: $t('app.legal_holds.update_error'),
message: String(result.data?.message ?? ''),
duration: 5000,
show: true,
});
}
await update();
};
}}
>
<input type="hidden" name="id" value={selectedHold.id} />
<div class="space-y-1.5">
<Label for="edit-name">{$t('app.legal_holds.name')}</Label>
<Input id="edit-name" name="name" required value={selectedHold.name} />
</div>
<div class="space-y-1.5">
<Label for="edit-reason">{$t('app.legal_holds.reason')}</Label>
<Textarea
id="edit-reason"
name="reason"
value={selectedHold.reason ?? ''}
/>
</div>
<div class="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onclick={() => (isEditOpen = false)}
disabled={isFormLoading}
>
{$t('app.legal_holds.cancel')}
</Button>
<Button type="submit" disabled={isFormLoading}>
{#if isFormLoading}
{$t('app.common.working')}
{:else}
{$t('app.legal_holds.save')}
{/if}
</Button>
</div>
</form>
{/if}
</Dialog.Content>
</Dialog.Root>
<!-- Bulk Apply dialog -->
<Dialog.Root bind:open={isBulkApplyOpen}>
<Dialog.Content class="sm:max-w-[560px]">
<Dialog.Header>
<Dialog.Title>{$t('app.legal_holds.bulk_apply_title')}</Dialog.Title>
<Dialog.Description>
{$t('app.legal_holds.bulk_apply_description')}
</Dialog.Description>
</Dialog.Header>
{#if selectedHold}
<form
method="POST"
action="?/bulkApply"
class="space-y-4"
use:enhance={() => {
isFormLoading = true;
return async ({ result, update }) => {
isFormLoading = false;
if (result.type === 'success' && result.data?.success !== false) {
isBulkApplyOpen = false;
const count = result.data?.emailsLinked as number;
setAlert({
type: 'success',
title: $t('app.legal_holds.bulk_apply_success'),
message: `${count} email(s) placed under legal hold.`,
duration: 5000,
show: true,
});
} else if (result.type === 'success' && result.data?.success === false) {
setAlert({
type: 'error',
title: $t('app.legal_holds.bulk_apply_error'),
message: String(result.data?.message ?? ''),
duration: 5000,
show: true,
});
}
await update();
};
}}
>
<input type="hidden" name="holdId" value={selectedHold.id} />
<!-- Hidden input built from the reactive fields -->
<input type="hidden" name="searchQuery" value={buildSearchQuery()} />
<div class="space-y-1.5">
<Label for="bulk-query">{$t('app.legal_holds.bulk_query')}</Label>
<Input
id="bulk-query"
bind:value={bulkQuery}
placeholder={$t('app.legal_holds.bulk_query_placeholder')}
/>
<p class="text-muted-foreground text-xs">
{$t('app.legal_holds.bulk_query_hint')}
</p>
</div>
<div class="space-y-1.5">
<Label for="bulk-from">{$t('app.legal_holds.bulk_from')}</Label>
<Input
id="bulk-from"
bind:value={bulkFiltersFrom}
placeholder="e.g. john@company.com"
/>
</div>
<div class="grid grid-cols-2 gap-3">
<div class="space-y-1.5">
<Label for="bulk-start">{$t('app.legal_holds.bulk_date_start')}</Label>
<Input id="bulk-start" type="date" bind:value={bulkFiltersDateStart} />
</div>
<div class="space-y-1.5">
<Label for="bulk-end">{$t('app.legal_holds.bulk_date_end')}</Label>
<Input id="bulk-end" type="date" bind:value={bulkFiltersDateEnd} />
</div>
</div>
<div class="rounded-md border border-amber-200 bg-amber-50 p-3 dark:border-amber-900 dark:bg-amber-950">
<p class="text-xs text-amber-800 dark:text-amber-200">
{$t('app.legal_holds.bulk_apply_warning')}
</p>
</div>
<div class="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onclick={() => (isBulkApplyOpen = false)}
disabled={isFormLoading}
>
{$t('app.legal_holds.cancel')}
</Button>
<Button type="submit" disabled={isFormLoading || (!bulkQuery && !bulkFiltersFrom && !bulkFiltersDateStart)}>
{#if isFormLoading}
{$t('app.common.working')}
{:else}
{$t('app.legal_holds.bulk_apply_confirm')}
{/if}
</Button>
</div>
</form>
{/if}
</Dialog.Content>
</Dialog.Root>
<!-- Release All dialog -->
<Dialog.Root bind:open={isReleaseAllOpen}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>{$t('app.legal_holds.release_all_title')}</Dialog.Title>
<Dialog.Description>
{$t('app.legal_holds.release_all_description')}
</Dialog.Description>
</Dialog.Header>
<Dialog.Footer>
<Button
variant="outline"
onclick={() => (isReleaseAllOpen = false)}
disabled={isFormLoading}
>
{$t('app.legal_holds.cancel')}
</Button>
{#if selectedHold}
<form
method="POST"
action="?/releaseAll"
use:enhance={() => {
isFormLoading = true;
return async ({ result, update }) => {
isFormLoading = false;
if (result.type === 'success' && result.data?.success !== false) {
isReleaseAllOpen = false;
const count = result.data?.emailsReleased as number;
setAlert({
type: 'success',
title: $t('app.legal_holds.release_all_success'),
message: `${count} email(s) released from hold.`,
duration: 4000,
show: true,
});
selectedHold = null;
} else {
setAlert({
type: 'error',
title: $t('app.legal_holds.release_all_error'),
message:
result.type === 'success'
? String(result.data?.message ?? '')
: '',
duration: 5000,
show: true,
});
}
await update();
};
}}
>
<input type="hidden" name="id" value={selectedHold.id} />
<Button type="submit" variant="destructive" disabled={isFormLoading}>
{#if isFormLoading}
{$t('app.common.working')}
{:else}
{$t('app.legal_holds.release_all_confirm')}
{/if}
</Button>
</form>
{/if}
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<!-- Delete confirmation dialog -->
<Dialog.Root bind:open={isDeleteOpen}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>{$t('app.legal_holds.delete_confirmation_title')}</Dialog.Title>
<Dialog.Description>
{$t('app.legal_holds.delete_confirmation_description')}
</Dialog.Description>
</Dialog.Header>
<Dialog.Footer>
<Button
variant="outline"
onclick={() => (isDeleteOpen = false)}
disabled={isFormLoading}
>
{$t('app.legal_holds.cancel')}
</Button>
{#if selectedHold}
<form
method="POST"
action="?/delete"
use:enhance={() => {
isFormLoading = true;
return async ({ result, update }) => {
isFormLoading = false;
if (result.type === 'success' && result.data?.success !== false) {
isDeleteOpen = false;
setAlert({
type: 'success',
title: $t('app.legal_holds.delete_success'),
message: '',
duration: 3000,
show: true,
});
selectedHold = null;
} else {
setAlert({
type: 'error',
title: $t('app.legal_holds.delete_error'),
message:
result.type === 'success'
? String(result.data?.message ?? '')
: '',
duration: 5000,
show: true,
});
}
await update();
};
}}
>
<input type="hidden" name="id" value={selectedHold.id} />
<Button type="submit" variant="destructive" disabled={isFormLoading}>
{#if isFormLoading}
{$t('app.common.working')}
{:else}
{$t('app.legal_holds.confirm')}
{/if}
</Button>
</form>
{/if}
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,96 @@
import { api } from '$lib/server/api';
import { error } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import type { RetentionLabel } from '@open-archiver/types';
export const load: PageServerLoad = async (event) => {
if (!event.locals.enterpriseMode) {
throw error(
403,
'This feature is only available in the Enterprise Edition. Please contact Open Archiver to upgrade.'
);
}
const labelsRes = await api('/enterprise/retention-policy/labels', event);
const labelsJson = await labelsRes.json();
if (!labelsRes.ok) {
throw error(labelsRes.status, labelsJson.message || JSON.stringify(labelsJson));
}
const labels: RetentionLabel[] = labelsJson;
return { labels };
};
export const actions: Actions = {
create: async (event) => {
const data = await event.request.formData();
const body = {
name: data.get('name') as string,
description: (data.get('description') as string) || undefined,
retentionPeriodDays: Number(data.get('retentionPeriodDays')),
};
const response = await api('/enterprise/retention-policy/labels', event, {
method: 'POST',
body: JSON.stringify(body),
});
const res = await response.json();
if (!response.ok) {
return { success: false, message: res.message || 'Failed to create label' };
}
return { success: true };
},
update: async (event) => {
const data = await event.request.formData();
const id = data.get('id') as string;
const body: Record<string, string | number | undefined> = {
name: data.get('name') as string,
description: (data.get('description') as string) || undefined,
};
// Only include retentionPeriodDays if provided (it may be locked)
const retentionDays = data.get('retentionPeriodDays');
if (retentionDays) {
body.retentionPeriodDays = Number(retentionDays);
}
const response = await api(`/enterprise/retention-policy/labels/${id}`, event, {
method: 'PUT',
body: JSON.stringify(body),
});
const res = await response.json();
if (!response.ok) {
return { success: false, message: res.message || 'Failed to update label' };
}
return { success: true };
},
delete: async (event) => {
const data = await event.request.formData();
const id = data.get('id') as string;
const response = await api(`/enterprise/retention-policy/labels/${id}`, event, {
method: 'DELETE',
});
if (!response.ok) {
const res = await response.json().catch(() => ({}));
return { success: false, message: res.message || 'Failed to delete label' };
}
const result = await response.json();
return { success: true, action: result.action as 'deleted' | 'disabled' };
},
};

View File

@@ -0,0 +1,426 @@
<script lang="ts">
import type { ActionData, PageData } from './$types';
import { t } from '$lib/translations';
import { Button } from '$lib/components/ui/button';
import { Badge } from '$lib/components/ui/badge';
import * as Table from '$lib/components/ui/table';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Textarea } from '$lib/components/ui/textarea';
import { enhance } from '$app/forms';
import { MoreHorizontal, Plus } from 'lucide-svelte';
import { setAlert } from '$lib/components/custom/alert/alert-state.svelte';
import type { RetentionLabel } from '@open-archiver/types';
let { data, form }: { data: PageData; form: ActionData } = $props();
let labels = $derived(data.labels);
// --- Dialog state ---
let isCreateOpen = $state(false);
let isEditOpen = $state(false);
let isDeleteOpen = $state(false);
let selectedLabel = $state<RetentionLabel | null>(null);
let isFormLoading = $state(false);
let isDeleting = $state(false);
function openEdit(label: RetentionLabel) {
selectedLabel = label;
isEditOpen = true;
}
function openDelete(label: RetentionLabel) {
selectedLabel = label;
isDeleteOpen = true;
}
</script>
<svelte:head>
<title>{$t('app.retention_labels.title')} - Open Archiver</title>
<meta name="description" content={$t('app.retention_labels.meta_description')} />
<meta
name="keywords"
content="retention labels, data retention, email compliance, item-level retention, GDPR"
/>
</svelte:head>
<div class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-bold">{$t('app.retention_labels.header')}</h1>
<Button onclick={() => (isCreateOpen = true)}>
<Plus class="mr-1.5 h-4 w-4" />
{$t('app.retention_labels.create_new')}
</Button>
</div>
<div class="rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>{$t('app.retention_labels.name')}</Table.Head>
<Table.Head>{$t('app.retention_labels.retention_period')}</Table.Head>
<Table.Head>{$t('app.retention_labels.applied_count')}</Table.Head>
<Table.Head>{$t('app.retention_labels.status')}</Table.Head>
<Table.Head>{$t('app.retention_labels.created_at')}</Table.Head>
<Table.Head class="text-right">{$t('app.retention_labels.actions')}</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if labels && labels.length > 0}
{#each labels as label (label.id)}
<Table.Row>
<Table.Cell class="font-medium">
<div>{label.name}</div>
<div class="mt-0.5 font-mono text-[10px] text-muted-foreground">
{label.id}
</div>
{#if label.description}
<div class="text-muted-foreground mt-0.5 text-xs">
{label.description}
</div>
{/if}
</Table.Cell>
<Table.Cell>
{label.retentionPeriodDays}
{$t('app.retention_labels.days')}
</Table.Cell>
<!-- Applied email count — shows a subtle badge with the number -->
<Table.Cell>
<Badge variant={label.appliedEmailCount > 0 ? 'secondary' : 'outline'}>
{label.appliedEmailCount}
</Badge>
</Table.Cell>
<Table.Cell>
{#if label.isDisabled}
<Badge variant="secondary">
{$t('app.retention_labels.disabled')}
</Badge>
{:else}
<Badge variant="default" class="bg-green-500 text-white">
{$t('app.retention_labels.enabled')}
</Badge>
{/if}
</Table.Cell>
<Table.Cell>
{new Date(label.createdAt).toLocaleDateString()}
</Table.Cell>
<Table.Cell class="text-right">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button
{...props}
variant="ghost"
size="icon"
class="h-8 w-8"
aria-label={$t('app.ingestions.open_menu')}
>
<MoreHorizontal class="h-4 w-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item onclick={() => openEdit(label)}>
{$t('app.retention_labels.edit')}
</DropdownMenu.Item>
<DropdownMenu.Item
class="text-destructive focus:text-destructive"
onclick={() => openDelete(label)}
>
{label.appliedEmailCount > 0 && !label.isDisabled
? $t('app.retention_labels.disable')
: $t('app.retention_labels.delete')}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
</Table.Row>
{/each}
{:else}
<Table.Row>
<Table.Cell colspan={6} class="h-24 text-center">
{$t('app.retention_labels.no_labels_found')}
</Table.Cell>
</Table.Row>
{/if}
</Table.Body>
</Table.Root>
</div>
<!-- Create dialog -->
<Dialog.Root bind:open={isCreateOpen}>
<Dialog.Content class="sm:max-w-[500px]">
<Dialog.Header>
<Dialog.Title>{$t('app.retention_labels.create')}</Dialog.Title>
<Dialog.Description>
{$t('app.retention_labels.create_description')}
</Dialog.Description>
</Dialog.Header>
<form
method="POST"
action="?/create"
class="space-y-4"
use:enhance={() => {
isFormLoading = true;
return async ({ result, update }) => {
isFormLoading = false;
if (result.type === 'success' && result.data?.success !== false) {
isCreateOpen = false;
setAlert({
type: 'success',
title: $t('app.retention_labels.create_success'),
message: '',
duration: 3000,
show: true,
});
} else if (result.type === 'success' && result.data?.success === false) {
setAlert({
type: 'error',
title: $t('app.retention_labels.create_error'),
message: String(result.data?.message ?? ''),
duration: 5000,
show: true,
});
}
await update();
};
}}
>
<div class="space-y-1.5">
<Label for="create-name">{$t('app.retention_labels.name')}</Label>
<Input
id="create-name"
name="name"
required
placeholder={$t('app.retention_labels.name_placeholder')}
/>
</div>
<div class="space-y-1.5">
<Label for="create-description">{$t('app.retention_labels.description')}</Label>
<Textarea
id="create-description"
name="description"
placeholder={$t('app.retention_labels.description_placeholder')}
/>
</div>
<div class="space-y-1.5">
<Label for="create-retention">
{$t('app.retention_labels.retention_period_days')}
</Label>
<Input
id="create-retention"
name="retentionPeriodDays"
type="number"
min="1"
required
/>
</div>
<div class="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onclick={() => (isCreateOpen = false)}
disabled={isFormLoading}
>
{$t('app.retention_labels.cancel')}
</Button>
<Button type="submit" disabled={isFormLoading}>
{#if isFormLoading}
{$t('app.common.working')}
{:else}
{$t('app.retention_labels.create')}
{/if}
</Button>
</div>
</form>
</Dialog.Content>
</Dialog.Root>
<!-- Edit dialog -->
<Dialog.Root bind:open={isEditOpen}>
<Dialog.Content class="sm:max-w-[500px]">
<Dialog.Header>
<Dialog.Title>{$t('app.retention_labels.edit')}</Dialog.Title>
<Dialog.Description>
{$t('app.retention_labels.edit_description')}
</Dialog.Description>
</Dialog.Header>
{#if selectedLabel}
<form
method="POST"
action="?/update"
class="space-y-4"
use:enhance={() => {
isFormLoading = true;
return async ({ result, update }) => {
isFormLoading = false;
if (result.type === 'success' && result.data?.success !== false) {
isEditOpen = false;
selectedLabel = null;
setAlert({
type: 'success',
title: $t('app.retention_labels.update_success'),
message: '',
duration: 3000,
show: true,
});
} else if (result.type === 'success' && result.data?.success === false) {
setAlert({
type: 'error',
title: $t('app.retention_labels.update_error'),
message: String(result.data?.message ?? ''),
duration: 5000,
show: true,
});
}
await update();
};
}}
>
<input type="hidden" name="id" value={selectedLabel.id} />
<div class="space-y-1.5">
<Label for="edit-name">{$t('app.retention_labels.name')}</Label>
<Input
id="edit-name"
name="name"
required
value={selectedLabel.name}
/>
</div>
<div class="space-y-1.5">
<Label for="edit-description">{$t('app.retention_labels.description')}</Label>
<Textarea
id="edit-description"
name="description"
value={selectedLabel.description ?? ''}
/>
</div>
<div class="space-y-1.5">
<Label for="edit-retention">
{$t('app.retention_labels.retention_period_days')}
</Label>
<Input
id="edit-retention"
name="retentionPeriodDays"
type="number"
min="1"
required
value={selectedLabel.retentionPeriodDays}
/>
<p class="text-muted-foreground text-xs">
{$t('app.retention_labels.retention_period_locked')}
</p>
</div>
<div class="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onclick={() => (isEditOpen = false)}
disabled={isFormLoading}
>
{$t('app.retention_labels.cancel')}
</Button>
<Button type="submit" disabled={isFormLoading}>
{#if isFormLoading}
{$t('app.common.working')}
{:else}
{$t('app.retention_labels.save')}
{/if}
</Button>
</div>
</form>
{/if}
</Dialog.Content>
</Dialog.Root>
<!--
Delete / Disable / Force-delete confirmation dialog.
Three cases driven by (isDisabled, appliedEmailCount):
1. appliedEmailCount === 0 → hard-delete (no emails, safe to remove)
2. appliedEmailCount > 0, enabled → soft-disable (keep email retention clocks)
3. appliedEmailCount > 0, disabled → force hard-delete (remove relations + label)
-->
<Dialog.Root bind:open={isDeleteOpen}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>
{#if (selectedLabel?.appliedEmailCount ?? 0) > 0 && !selectedLabel?.isDisabled}
{$t('app.retention_labels.disable_confirmation_title')}
{:else if (selectedLabel?.appliedEmailCount ?? 0) > 0 && selectedLabel?.isDisabled}
{$t('app.retention_labels.force_delete_confirmation_title')}
{:else}
{$t('app.retention_labels.delete_confirmation_title')}
{/if}
</Dialog.Title>
<Dialog.Description>
{#if (selectedLabel?.appliedEmailCount ?? 0) > 0 && !selectedLabel?.isDisabled}
{$t('app.retention_labels.disable_confirmation_description')}
{:else if (selectedLabel?.appliedEmailCount ?? 0) > 0 && selectedLabel?.isDisabled}
{$t('app.retention_labels.force_delete_confirmation_description')}
{:else}
{$t('app.retention_labels.delete_confirmation_description')}
{/if}
</Dialog.Description>
</Dialog.Header>
<Dialog.Footer>
<Button
variant="outline"
onclick={() => (isDeleteOpen = false)}
disabled={isDeleting}
>
{$t('app.retention_labels.cancel')}
</Button>
{#if selectedLabel}
<form
method="POST"
action="?/delete"
use:enhance={() => {
isDeleting = true;
return async ({ result, update }) => {
isDeleting = false;
if (result.type === 'success' && result.data?.success !== false) {
isDeleteOpen = false;
const action = result.data?.action;
setAlert({
type: 'success',
title:
action === 'disabled'
? $t('app.retention_labels.disable_success')
: $t('app.retention_labels.delete_success'),
message: '',
duration: 3000,
show: true,
});
selectedLabel = null;
} else {
setAlert({
type: 'error',
title: $t('app.retention_labels.delete_error'),
message:
result.type === 'success'
? String(result.data?.message ?? '')
: '',
duration: 5000,
show: true,
});
}
await update();
};
}}
>
<input type="hidden" name="id" value={selectedLabel.id} />
<Button type="submit" variant="destructive" disabled={isDeleting}>
{#if isDeleting}
{$t('app.retention_labels.deleting')}
{:else if (selectedLabel.appliedEmailCount ?? 0) > 0 && !selectedLabel.isDisabled}
{$t('app.retention_labels.disable')}
{:else}
{$t('app.retention_labels.confirm')}
{/if}
</Button>
</form>
{/if}
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,184 @@
import { api } from '$lib/server/api';
import { error } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import type { RetentionPolicy, PolicyEvaluationResult, SafeIngestionSource } from '@open-archiver/types';
export const load: PageServerLoad = async (event) => {
if (!event.locals.enterpriseMode) {
throw error(
403,
'This feature is only available in the Enterprise Edition. Please contact Open Archiver to upgrade.'
);
}
// Fetch policies and ingestion sources in parallel
const [policiesRes, ingestionsRes] = await Promise.all([
api('/enterprise/retention-policy/policies', event),
api('/ingestion-sources', event),
]);
const policiesJson = await policiesRes.json();
if (!policiesRes.ok) {
throw error(policiesRes.status, policiesJson.message || JSON.stringify(policiesJson));
}
// Ingestion sources are best-effort — don't hard-fail if unavailable
let ingestionSources: SafeIngestionSource[] = [];
if (ingestionsRes.ok) {
const ingestionsJson = await ingestionsRes.json();
ingestionSources = Array.isArray(ingestionsJson) ? ingestionsJson : [];
}
const policies: RetentionPolicy[] = policiesJson;
return { policies, ingestionSources };
};
export const actions: Actions = {
create: async (event) => {
const data = await event.request.formData();
const conditionsRaw = JSON.parse(
(data.get('conditions') as string) || '{"logicalOperator":"AND","rules":[]}'
);
// Parse ingestionScope: comma-separated UUIDs, or empty = null (all sources)
const ingestionScopeRaw = (data.get('ingestionScope') as string) || '';
const ingestionScope =
ingestionScopeRaw.trim().length > 0
? ingestionScopeRaw
.split(',')
.map((s) => s.trim())
.filter(Boolean)
: null;
const body = {
name: data.get('name') as string,
description: (data.get('description') as string) || undefined,
priority: Number(data.get('priority')),
retentionPeriodDays: Number(data.get('retentionPeriodDays')),
actionOnExpiry: 'delete_permanently' as const,
isEnabled: data.get('isEnabled') === 'true',
// Send null when no rules — means "apply to all emails"
conditions: conditionsRaw.rules.length > 0 ? conditionsRaw : null,
ingestionScope,
};
const response = await api('/enterprise/retention-policy/policies', event, {
method: 'POST',
body: JSON.stringify(body),
});
const res = await response.json();
if (!response.ok) {
return { success: false, message: res.message || 'Failed to create policy' };
}
return { success: true };
},
update: async (event) => {
const data = await event.request.formData();
const id = data.get('id') as string;
const conditionsRaw = JSON.parse(
(data.get('conditions') as string) || '{"logicalOperator":"AND","rules":[]}'
);
const ingestionScopeRaw = (data.get('ingestionScope') as string) || '';
const ingestionScope =
ingestionScopeRaw.trim().length > 0
? ingestionScopeRaw
.split(',')
.map((s) => s.trim())
.filter(Boolean)
: null;
const body = {
name: data.get('name') as string,
description: (data.get('description') as string) || undefined,
priority: Number(data.get('priority')),
retentionPeriodDays: Number(data.get('retentionPeriodDays')),
actionOnExpiry: 'delete_permanently' as const,
isEnabled: data.get('isEnabled') === 'true',
conditions: conditionsRaw.rules.length > 0 ? conditionsRaw : null,
ingestionScope,
};
const response = await api(`/enterprise/retention-policy/policies/${id}`, event, {
method: 'PUT',
body: JSON.stringify(body),
});
const res = await response.json();
if (!response.ok) {
return { success: false, message: res.message || 'Failed to update policy' };
}
return { success: true };
},
delete: async (event) => {
const data = await event.request.formData();
const id = data.get('id') as string;
const response = await api(`/enterprise/retention-policy/policies/${id}`, event, {
method: 'DELETE',
});
if (!response.ok) {
const res = await response.json().catch(() => ({}));
return { success: false, message: res.message || 'Failed to delete policy' };
}
return { success: true };
},
evaluate: async (event) => {
const data = await event.request.formData();
// Parse recipients and attachment types from comma-separated strings
const recipientsRaw = (data.get('recipients') as string) || '';
const attachmentTypesRaw = (data.get('attachmentTypes') as string) || '';
const ingestionSourceId = (data.get('ingestionSourceId') as string) || undefined;
const body = {
emailMetadata: {
sender: (data.get('sender') as string) || '',
recipients: recipientsRaw
.split(',')
.map((s) => s.trim())
.filter(Boolean),
subject: (data.get('subject') as string) || '',
attachmentTypes: attachmentTypesRaw
.split(',')
.map((s) => s.trim())
.filter(Boolean),
// Only include ingestionSourceId if a non-empty value was provided
...(ingestionSourceId ? { ingestionSourceId } : {}),
},
};
const response = await api('/enterprise/retention-policy/policies/evaluate', event, {
method: 'POST',
body: JSON.stringify(body),
});
const res = await response.json();
if (!response.ok) {
return {
success: false,
message: res.message || 'Failed to evaluate policies',
evaluationResult: null as PolicyEvaluationResult | null,
};
}
return {
success: true,
evaluationResult: res as PolicyEvaluationResult,
};
},
};

View File

@@ -0,0 +1,460 @@
<script lang="ts">
import type { ActionData, PageData } from './$types';
import { t } from '$lib/translations';
import { Button } from '$lib/components/ui/button';
import { Badge } from '$lib/components/ui/badge';
import * as Table from '$lib/components/ui/table';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import * as Select from '$lib/components/ui/select/index.js';
import { enhance } from '$app/forms';
import { MoreHorizontal, Plus, FlaskConical } from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import RetentionPolicyForm from '$lib/components/custom/RetentionPolicyForm.svelte';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import type { RetentionPolicy, PolicyEvaluationResult } from '@open-archiver/types';
let { data, form }: { data: PageData; form: ActionData } = $props();
let policies = $derived(data.policies);
let ingestionSources = $derived(data.ingestionSources);
// --- Dialog state ---
let isCreateOpen = $state(false);
let isEditOpen = $state(false);
let isDeleteOpen = $state(false);
let selectedPolicy = $state<RetentionPolicy | null>(null);
let isFormLoading = $state(false);
let isDeleting = $state(false);
// --- Simulator state ---
let isSimulating = $state(false);
let evaluationResult = $state<PolicyEvaluationResult | null>(null);
/** The ingestion source ID selected for the simulator (empty string = all sources / no filter) */
let simIngestionSourceId = $state('');
function openEdit(policy: RetentionPolicy) {
selectedPolicy = policy;
isEditOpen = true;
}
function openDelete(policy: RetentionPolicy) {
selectedPolicy = policy;
isDeleteOpen = true;
}
// React to form results (errors and evaluation results)
$effect(() => {
if (form && form.success === false && form.message) {
toast.error(form.message);
}
if (form && 'evaluationResult' in form) {
evaluationResult = form.evaluationResult ?? null;
}
});
/** Returns a human-readable summary of the conditions on a policy. */
function conditionsSummary(policy: RetentionPolicy): string {
if (!policy.conditions || policy.conditions.rules.length === 0) {
return $t('app.retention_policies.no_conditions');
}
const count = policy.conditions.rules.length;
const op = policy.conditions.logicalOperator;
return `${count} ${$t('app.retention_policies.rules')} (${op})`;
}
</script>
<svelte:head>
<title>{$t('app.retention_policies.title')} - Open Archiver</title>
<meta name="description" content={$t('app.retention_policies.meta_description')} />
<meta
name="keywords"
content="retention policies, data retention, email lifecycle, compliance, GDPR"
/>
</svelte:head>
<div class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-bold">{$t('app.retention_policies.header')}</h1>
<Button onclick={() => (isCreateOpen = true)}>
<Plus class="mr-1.5 h-4 w-4" />
{$t('app.retention_policies.create_new')}
</Button>
</div>
<div class="rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>{$t('app.retention_policies.name')}</Table.Head>
<Table.Head>{$t('app.retention_policies.priority')}</Table.Head>
<Table.Head>{$t('app.retention_policies.retention_period')}</Table.Head>
<Table.Head>{$t('app.retention_policies.ingestion_scope')}</Table.Head>
<Table.Head>{$t('app.retention_policies.conditions')}</Table.Head>
<Table.Head>{$t('app.retention_policies.status')}</Table.Head>
<Table.Head class="text-right">{$t('app.ingestions.actions')}</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if policies && policies.length > 0}
{#each policies as policy (policy.id)}
<Table.Row>
<Table.Cell class="font-medium">
<div>{policy.name}</div>
<div class="mt-0.5 font-mono text-[10px] text-muted-foreground">
{policy.id}
</div>
{#if policy.description}
<div class="text-muted-foreground mt-0.5 text-xs">{policy.description}</div>
{/if}
</Table.Cell>
<Table.Cell>{policy.priority}</Table.Cell>
<Table.Cell>
{policy.retentionPeriodDays}
{$t('app.retention_policies.days')}
</Table.Cell>
<Table.Cell>
{#if !policy.ingestionScope || policy.ingestionScope.length === 0}
<span class="text-muted-foreground text-sm italic">
{$t('app.retention_policies.ingestion_scope_all')}
</span>
{:else}
<div class="flex flex-wrap gap-1">
{#each policy.ingestionScope as sourceId (sourceId)}
{@const source = ingestionSources.find((s) => s.id === sourceId)}
<Badge variant="outline" class="text-xs">
{source?.name ?? sourceId.slice(0, 8) + '…'}
</Badge>
{/each}
</div>
{/if}
</Table.Cell>
<Table.Cell>
<span class="text-muted-foreground text-sm">{conditionsSummary(policy)}</span>
</Table.Cell>
<Table.Cell>
{#if policy.isActive}
<Badge variant="default" class="bg-green-500 text-white">
{$t('app.retention_policies.active')}
</Badge>
{:else}
<Badge variant="secondary">
{$t('app.retention_policies.inactive')}
</Badge>
{/if}
</Table.Cell>
<Table.Cell class="text-right">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button
{...props}
variant="ghost"
size="icon"
class="h-8 w-8"
aria-label={$t('app.ingestions.open_menu')}
>
<MoreHorizontal class="h-4 w-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item onclick={() => openEdit(policy)}>
{$t('app.retention_policies.edit')}
</DropdownMenu.Item>
<DropdownMenu.Item
class="text-destructive focus:text-destructive"
onclick={() => openDelete(policy)}
>
{$t('app.retention_policies.delete')}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
</Table.Row>
{/each}
{:else}
<Table.Row>
<Table.Cell colspan={7} class="h-24 text-center">
{$t('app.retention_policies.no_policies_found')}
</Table.Cell>
</Table.Row>
{/if}
</Table.Body>
</Table.Root>
</div>
<!-- Create dialog -->
<Dialog.Root bind:open={isCreateOpen}>
<Dialog.Content class="sm:max-w-[600px]">
<Dialog.Header>
<Dialog.Title>{$t('app.retention_policies.create')}</Dialog.Title>
<Dialog.Description>
{$t('app.retention_policies.create_description')}
</Dialog.Description>
</Dialog.Header>
<div class="max-h-[70vh] overflow-y-auto pr-1">
<RetentionPolicyForm
action="?/create"
{ingestionSources}
bind:isLoading={isFormLoading}
onCancel={() => (isCreateOpen = false)}
onSuccess={() => {
isCreateOpen = false;
toast.success($t('app.retention_policies.create_success'));
}}
/>
</div>
</Dialog.Content>
</Dialog.Root>
<!-- Edit dialog -->
<Dialog.Root bind:open={isEditOpen}>
<Dialog.Content class="sm:max-w-[600px]">
<Dialog.Header>
<Dialog.Title>{$t('app.retention_policies.edit')}</Dialog.Title>
<Dialog.Description>
{$t('app.retention_policies.edit_description')}
</Dialog.Description>
</Dialog.Header>
{#if selectedPolicy}
<div class="max-h-[70vh] overflow-y-auto pr-1">
<RetentionPolicyForm
policy={selectedPolicy}
action="?/update"
{ingestionSources}
bind:isLoading={isFormLoading}
onCancel={() => (isEditOpen = false)}
onSuccess={() => {
isEditOpen = false;
selectedPolicy = null;
toast.success($t('app.retention_policies.update_success'));
}}
/>
</div>
{/if}
</Dialog.Content>
</Dialog.Root>
<!-- Policy Simulator -->
<div class="mt-8 rounded-md border">
<div class="flex items-center gap-2 border-b px-6 py-4">
<FlaskConical class="text-muted-foreground h-5 w-5" />
<div>
<h2 class="text-base font-semibold">{$t('app.retention_policies.simulator_title')}</h2>
<p class="text-muted-foreground text-sm">
{$t('app.retention_policies.simulator_description')}
</p>
</div>
</div>
<form
method="POST"
action="?/evaluate"
class="grid gap-6 p-6 md:grid-cols-2"
use:enhance={() => {
isSimulating = true;
evaluationResult = null;
return async ({ update }) => {
isSimulating = false;
await update({ reset: false });
};
}}
>
<!-- Hidden field for selected ingestion source -->
<input type="hidden" name="ingestionSourceId" value={simIngestionSourceId} />
<!-- Sender -->
<div class="space-y-1.5">
<Label for="sim-sender">{$t('app.retention_policies.simulator_sender')}</Label>
<Input
id="sim-sender"
name="sender"
type="email"
placeholder={$t('app.retention_policies.simulator_sender_placeholder')}
/>
</div>
<!-- Subject -->
<div class="space-y-1.5">
<Label for="sim-subject">{$t('app.retention_policies.simulator_subject')}</Label>
<Input
id="sim-subject"
name="subject"
placeholder={$t('app.retention_policies.simulator_subject_placeholder')}
/>
</div>
<!-- Recipients -->
<div class="space-y-1.5">
<Label for="sim-recipients">{$t('app.retention_policies.simulator_recipients')}</Label>
<Input
id="sim-recipients"
name="recipients"
placeholder={$t('app.retention_policies.simulator_recipients_placeholder')}
/>
</div>
<!-- Attachment Types -->
<div class="space-y-1.5">
<Label for="sim-attachment-types">
{$t('app.retention_policies.simulator_attachment_types')}
</Label>
<Input
id="sim-attachment-types"
name="attachmentTypes"
placeholder={$t('app.retention_policies.simulator_attachment_types_placeholder')}
/>
</div>
<!-- Ingestion Source filter (only shown when sources are available) -->
{#if ingestionSources.length > 0}
<div class="space-y-1.5 md:col-span-2">
<Label>{$t('app.retention_policies.simulator_ingestion_source')}</Label>
<p class="text-muted-foreground text-xs">
{$t('app.retention_policies.simulator_ingestion_source_description')}
</p>
<Select.Root
type="single"
value={simIngestionSourceId}
onValueChange={(v) => (simIngestionSourceId = v)}
>
<Select.Trigger class="w-full">
{#if simIngestionSourceId}
{ingestionSources.find((s) => s.id === simIngestionSourceId)?.name ??
$t('app.retention_policies.simulator_ingestion_all')}
{:else}
{$t('app.retention_policies.simulator_ingestion_all')}
{/if}
</Select.Trigger>
<Select.Content>
<Select.Item value="">
<span class="italic">{$t('app.retention_policies.simulator_ingestion_all')}</span>
</Select.Item>
{#each ingestionSources as source (source.id)}
<Select.Item value={source.id}>
{source.name}
<span class="text-muted-foreground ml-1 text-xs">
({source.provider.replace(/_/g, ' ')})
</span>
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
{/if}
<!-- Submit spans full width on md -->
<div class="flex items-end md:col-span-2">
<Button type="submit" disabled={isSimulating} class="w-full md:w-auto">
<FlaskConical class="mr-1.5 h-4 w-4" />
{#if isSimulating}
{$t('app.retention_policies.simulator_running')}
{:else}
{$t('app.retention_policies.simulator_run')}
{/if}
</Button>
</div>
</form>
<!-- Result panel — shown only after a simulation has been run -->
{#if evaluationResult !== null}
<div class="border-t px-6 py-4">
<h3 class="mb-3 text-sm font-semibold">
{$t('app.retention_policies.simulator_result_title')}
</h3>
{#if evaluationResult.appliedRetentionDays === 0}
<div class="bg-muted rounded-md p-4 text-sm">
{$t('app.retention_policies.simulator_no_match')}
</div>
{:else}
<div class="space-y-3">
<div class="rounded-md border border-green-200 bg-green-50 p-4 dark:border-green-800 dark:bg-green-950">
<p class="text-sm font-medium text-green-800 dark:text-green-200">
{($t as any)('app.retention_policies.simulator_matched', {
days: evaluationResult.appliedRetentionDays,
})}
</p>
</div>
{#if evaluationResult.matchingPolicyIds.length > 0}
<div class="space-y-1.5">
<p class="text-muted-foreground text-xs font-medium uppercase tracking-wide">
{$t('app.retention_policies.simulator_matching_policies')}
</p>
<div class="flex flex-wrap gap-2">
{#each evaluationResult.matchingPolicyIds as policyId (policyId)}
{@const matchedPolicy = policies.find((p) => p.id === policyId)}
<div class="flex items-center gap-1.5">
<code class="bg-muted rounded px-2 py-0.5 font-mono text-xs">
{policyId}
</code>
{#if matchedPolicy}
<span class="text-muted-foreground text-xs">({matchedPolicy.name})</span>
{/if}
</div>
{/each}
</div>
</div>
{/if}
</div>
{/if}
</div>
{:else if !isSimulating}
<div class="border-t px-6 py-4">
<p class="text-muted-foreground text-sm">
{$t('app.retention_policies.simulator_no_result')}
</p>
</div>
{/if}
</div>
<!-- Delete confirmation dialog -->
<Dialog.Root bind:open={isDeleteOpen}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>{$t('app.retention_policies.delete_confirmation_title')}</Dialog.Title>
<Dialog.Description>
{$t('app.retention_policies.delete_confirmation_description')}
</Dialog.Description>
</Dialog.Header>
<Dialog.Footer>
<Button
variant="outline"
onclick={() => (isDeleteOpen = false)}
disabled={isDeleting}
>
{$t('app.retention_policies.cancel')}
</Button>
{#if selectedPolicy}
<form
method="POST"
action="?/delete"
use:enhance={() => {
isDeleting = true;
return async ({ result, update }) => {
isDeleting = false;
if (result.type === 'success') {
isDeleteOpen = false;
selectedPolicy = null;
toast.success($t('app.retention_policies.delete_success'));
} else {
toast.error($t('app.retention_policies.delete_error'));
}
await update();
};
}}
>
<input type="hidden" name="id" value={selectedPolicy.id} />
<Button type="submit" variant="destructive" disabled={isDeleting}>
{#if isDeleting}
{$t('app.retention_policies.deleting')}
{:else}
{$t('app.retention_policies.confirm')}
{/if}
</Button>
</form>
{/if}
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View File

@@ -265,10 +265,12 @@
{#if selectedIds.length > 0}
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button variant="outline">
{$t('app.ingestions.bulk_actions')} ({selectedIds.length})
<MoreHorizontal class="ml-2 h-4 w-4" />
</Button>
{#snippet child({ props })}
<Button {...props} variant="outline">
{$t('app.ingestions.bulk_actions')} ({selectedIds.length})
<MoreHorizontal class="ml-2 h-4 w-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Item onclick={handleBulkForceSync}>
@@ -382,12 +384,14 @@
<Table.Cell class="text-right">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button variant="ghost" class="h-8 w-8 p-0">
<span class="sr-only"
>{$t('app.ingestions.open_menu')}</span
>
<MoreHorizontal class="h-4 w-4" />
</Button>
{#snippet child({ props })}
<Button {...props} variant="ghost" class="h-8 w-8 p-0">
<span class="sr-only"
>{$t('app.ingestions.open_menu')}</span
>
<MoreHorizontal class="h-4 w-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Label

View File

@@ -111,7 +111,9 @@
<h1 class="text-2xl font-bold">{$t('app.api_keys_page.title')}</h1>
<Dialog.Root bind:open={newAPIKeyDialogOpen}>
<Dialog.Trigger>
<Button>{$t('app.api_keys_page.generate_new_key')}</Button>
{#snippet child({ props })}
<Button {...props}>{$t('app.api_keys_page.generate_new_key')}</Button>
{/snippet}
</Dialog.Trigger>
<Dialog.Content>
<Dialog.Header>
@@ -203,10 +205,12 @@
<Table.Cell class="text-right">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button variant="ghost" class="h-8 w-8 p-0">
<span class="sr-only">{$t('app.users.open_menu')}</span>
<MoreHorizontal class="h-4 w-4" />
</Button>
{#snippet child({ props })}
<Button {...props} variant="ghost" class="h-8 w-8 p-0">
<span class="sr-only">{$t('app.users.open_menu')}</span>
<MoreHorizontal class="h-4 w-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Label

View File

@@ -136,10 +136,12 @@
<Table.Cell class="text-right">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button variant="ghost" class="h-8 w-8 p-0">
<span class="sr-only">{$t('app.roles.open_menu')}</span>
<MoreHorizontal class="h-4 w-4" />
</Button>
{#snippet child({ props })}
<Button {...props} variant="ghost" class="h-8 w-8 p-0">
<span class="sr-only">{$t('app.roles.open_menu')}</span>
<MoreHorizontal class="h-4 w-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Label

View File

@@ -24,6 +24,7 @@
{ value: 'pt', label: '🇵🇹 Português' },
{ value: 'nl', label: '🇳🇱 Nederlands' },
{ value: 'el', label: '🇬🇷 Ελληνικά' },
{ value: 'bg', label: '🇧🇬 български' },
{ value: 'ja', label: '🇯🇵 日本語' },
];

View File

@@ -135,10 +135,12 @@
<Table.Cell class="text-right">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button variant="ghost" class="h-8 w-8 p-0">
<span class="sr-only">{$t('app.users.open_menu')}</span>
<MoreHorizontal class="h-4 w-4" />
</Button>
{#snippet child({ props })}
<Button {...props} variant="ghost" class="h-8 w-8 p-0">
<span class="sr-only">{$t('app.users.open_menu')}</span>
<MoreHorizontal class="h-4 w-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Label

9
packages/types/LICENSE Normal file
View File

@@ -0,0 +1,9 @@
MIT License
Copyright (c) 2026 Open Archiver
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

17
packages/types/README.md Normal file
View File

@@ -0,0 +1,17 @@
# @open-archiver/types
This package contains shared TypeScript type definitions for the Open Archiver project.
## Installation
```bash
npm install @open-archiver/types
```
## Usage
Import the types you need in your TypeScript files:
```typescript
import { User, Email } from '@open-archiver/types';
```

View File

@@ -1,10 +1,15 @@
{
"name": "@open-archiver/types",
"version": "0.1.0",
"private": true,
"license": "SEE LICENSE IN LICENSE file",
"version": "0.1.4",
"license": "MIT License",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist"
],
"publishConfig": {
"access": "public"
},
"scripts": {
"build": "tsc",
"dev": "tsc --watch"

View File

@@ -27,7 +27,11 @@ export const AuditLogTargetTypes = [
'ArchivedEmail',
'Dashboard',
'IngestionSource',
'RetentionPolicy',
'RetentionLabel',
'LegalHold',
'Role',
'SystemEvent',
'SystemSettings',
'User',
'File', // For uploads and downloads

View File

@@ -13,3 +13,4 @@ export * from './audit-log.enums';
export * from './integrity.types';
export * from './jobs.types';
export * from './license.types';
export * from './retention.types';

View File

@@ -72,20 +72,23 @@ export interface Microsoft365Credentials extends BaseIngestionCredentials {
export interface PSTImportCredentials extends BaseIngestionCredentials {
type: 'pst_import';
uploadedFileName: string;
uploadedFilePath: string;
uploadedFileName?: string;
uploadedFilePath?: string;
localFilePath?: string;
}
export interface EMLImportCredentials extends BaseIngestionCredentials {
type: 'eml_import';
uploadedFileName: string;
uploadedFilePath: string;
uploadedFileName?: string;
uploadedFilePath?: string;
localFilePath?: string;
}
export interface MboxImportCredentials extends BaseIngestionCredentials {
type: 'mbox_import';
uploadedFileName: string;
uploadedFilePath: string;
uploadedFileName?: string;
uploadedFilePath?: string;
localFilePath?: string;
}
// Discriminated union for all possible credential types

View File

@@ -4,6 +4,7 @@
export enum OpenArchiverFeature {
AUDIT_LOG = 'audit-log',
RETENTION_POLICY = 'retention-policy',
LEGAL_HOLDS = 'legal-holds',
SSO = 'sso',
STATUS = 'status',
ALL = 'all',
@@ -22,15 +23,56 @@ export interface LicenseFilePayload {
}
/**
* The structure of the cached response from the License Server.
* Request body sent to the license server's POST /api/v1/ping endpoint.
*/
export interface LicenseStatusPayload {
status: 'VALID' | 'REVOKED';
gracePeriodEnds?: string; // ISO 8601, only present if REVOKED
export interface LicensePingRequest {
/** UUID of the license, taken from the license.jwt payload. */
licenseId: string;
/** Current number of unique archived mailboxes on this instance. */
activeSeats: number;
/** Version string of the running Open Archiver instance. */
version: string;
}
/**
* The consolidated license status object returned by the API.
* Successful response body from the license server's POST /api/v1/ping endpoint.
*
* - `"VALID"` — license is active. If `gracePeriodEnds` is present, seats exceed
* the plan limit and the grace period deadline is included.
* - `"INVALID"` — license is revoked, not found, or the overage grace period has
* expired. All enterprise features must be disabled immediately.
*/
export interface LicensePingResponse {
status: 'VALID' | 'INVALID';
// ISO 8601 UTC timestamp.
expirationDate: string;
/** ISO 8601 UTC timestamp. Present only when status is "VALID" and activeSeats > planSeats. */
gracePeriodEnds?: string;
/** The current plan seat limit from the license server. */
planSeats?: number;
message?: string;
}
/**
* The structure of the locally cached license-status.json file.
* Written after each successful phone-home call.
*/
export interface LicenseStatusPayload {
status: 'VALID' | 'INVALID';
/** ISO 8601 UTC timestamp. Present when the instance is in a seat-overage grace period. */
gracePeriodEnds?: string;
/** ISO 8601 UTC timestamp of when this status was last successfully fetched. */
lastCheckedAt?: string;
/** The current plan seat limit from the license server. */
planSeats: number;
/** ISO 8601 UTC timestamp of the license expiration date. */
expirationDate?: string;
/** Optional message from the license server (e.g. regarding account status). */
message?: string;
}
/**
* The consolidated license status object returned by the GET /enterprise/status/license-status API.
*/
export interface ConsolidatedLicenseStatus {
// From the license.jwt file
@@ -38,8 +80,10 @@ export interface ConsolidatedLicenseStatus {
planSeats: number;
expiresAt: string;
// From the cached license-status.json
remoteStatus: 'VALID' | 'REVOKED' | 'UNKNOWN';
remoteStatus: 'VALID' | 'INVALID' | 'UNKNOWN';
gracePeriodEnds?: string;
lastCheckedAt?: string;
message?: string;
// Calculated values
activeSeats: number;
isExpired: boolean;

View File

@@ -0,0 +1,136 @@
// --- Condition Builder Types ---
export type ConditionField = 'sender' | 'recipient' | 'subject' | 'attachment_type';
/**
* All supported string-matching operators for retention rule conditions.
* - equals / not_equals: exact case-insensitive match
* - contains / not_contains: substring match
* - starts_with: prefix match
* - ends_with: suffix match
* - domain_match: email address ends with @<domain>
* - regex_match: ECMAScript regex (server-side only, length-limited for safety)
*/
export type ConditionOperator =
| 'equals'
| 'not_equals'
| 'contains'
| 'not_contains'
| 'starts_with'
| 'ends_with'
| 'domain_match'
| 'regex_match';
export type LogicalOperator = 'AND' | 'OR';
export interface RetentionRule {
field: ConditionField;
operator: ConditionOperator;
value: string;
}
export interface RetentionRuleGroup {
logicalOperator: LogicalOperator;
rules: RetentionRule[];
}
// --- Policy Evaluation Types ---
export interface PolicyEvaluationRequest {
emailMetadata: {
sender: string;
recipients: string[];
subject: string;
attachmentTypes: string[]; // e.g. ['.pdf', '.xml']
/** Optional ingestion source ID to scope the evaluation. */
ingestionSourceId?: string;
};
}
export interface PolicyEvaluationResult {
appliedRetentionDays: number;
actionOnExpiry: 'delete_permanently';
matchingPolicyIds: string[];
}
// --- Entity Types ---
export interface RetentionPolicy {
id: string;
name: string;
description?: string;
priority: number;
conditions: RetentionRuleGroup | null;
/**
* Restricts the policy to specific ingestion sources.
* null means the policy applies to all ingestion sources.
*/
ingestionScope: string[] | null;
retentionPeriodDays: number;
isActive: boolean;
createdAt: string; // ISO Date string
updatedAt: string; // ISO Date string
}
export interface RetentionLabel {
id: string;
name: string;
retentionPeriodDays: number;
description?: string;
isDisabled: boolean;
/**
* Number of archived emails that currently have this label applied.
* Used by the management UI to show usage and decide whether deletion
* is a hard-delete (0) or a soft-disable (> 0).
*/
appliedEmailCount: number;
createdAt: string; // ISO Date string
}
/** The retention label currently applied to an archived email. */
export interface EmailRetentionLabelInfo {
labelId: string;
labelName: string;
retentionPeriodDays: number;
appliedAt: string; // ISO Date string
appliedByUserId: string | null;
/** True when the label itself has been soft-disabled (isDisabled = true on the label row). */
isLabelDisabled: boolean;
}
export interface RetentionEvent {
id: string;
eventName: string;
eventType: string; // e.g., 'EMPLOYEE_EXIT'
eventTimestamp: string; // ISO Date string
targetCriteria: Record<string, unknown>; // JSON criteria
createdAt: string; // ISO Date string
}
export interface LegalHold {
id: string;
name: string;
reason?: string;
isActive: boolean;
caseId?: string | null;
/** Number of emails currently under this hold. */
emailCount: number;
createdAt: string; // ISO Date string
updatedAt: string; // ISO Date string
}
/** Info about a legal hold applied to a specific email. */
export interface EmailLegalHoldInfo {
legalHoldId: string;
holdName: string;
isActive: boolean;
appliedAt: string; // ISO Date string
appliedByUserId: string | null;
}
/** Result returned after applying a hold to emails via bulk query. */
export interface BulkApplyHoldResult {
legalHoldId: string;
emailsLinked: number;
queryUsed: Record<string, unknown>;
}