code formatting

This commit is contained in:
wayneshn
2026-03-19 21:48:46 +01:00
parent 0a614add58
commit a55a1ede07
48 changed files with 14106 additions and 14733 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -23,26 +23,26 @@ Retrieves all legal holds ordered by creation date ascending, each annotated wit
```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"
}
{
"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"
}
]
```
@@ -59,9 +59,9 @@ Retrieves a single legal hold by its UUID.
#### Path Parameters
| Parameter | Type | Description |
| --------- | ------ | ----------------------------- |
| `id` | `uuid` | The UUID of the hold to get. |
| Parameter | Type | Description |
| --------- | ------ | ---------------------------- |
| `id` | `uuid` | The UUID of the hold to get. |
#### Response
@@ -80,19 +80,19 @@ Creates a new legal hold. Holds are always created in the **active** state.
#### Request Body
| Field | Type | Required | Description |
| -------- | -------- | -------- | ----------------------------------------------------------- |
| `name` | `string` | Yes | Unique hold name. Max 255 characters. |
| 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. |
| `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
"name": "Project Titan Litigation — 2026",
"reason": "Preservation notice received from outside counsel on 2026-01-15 regarding IP dispute with ExCorp.",
"caseId": null
}
```
@@ -115,25 +115,25 @@ Updates the name, reason, or `isActive` state of a hold. Only the fields provide
#### Path Parameters
| Parameter | Type | Description |
| --------- | ------ | -------------------------------- |
| `id` | `uuid` | The UUID of the hold to update. |
| 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. |
| 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
"isActive": false
}
```
@@ -158,9 +158,9 @@ Permanently deletes a legal hold and (via database CASCADE) all associated `emai
#### Path Parameters
| Parameter | Type | Description |
| --------- | ------ | -------------------------------- |
| `id` | `uuid` | The UUID of the hold to delete. |
| Parameter | Type | Description |
| --------- | ------ | ------------------------------- |
| `id` | `uuid` | The UUID of the hold to delete. |
#### Response
@@ -185,37 +185,37 @@ Applies a legal hold to **all emails matching a Meilisearch query**. The operati
#### Path Parameters
| Parameter | Type | Description |
| --------- | ------ | ----------------------------------- |
| `id` | `uuid` | The UUID of the hold to apply. |
| 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). |
| 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" }`). |
| 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"
}
"searchQuery": {
"query": "Project Titan confidential",
"filters": {
"from": "john.doe@acme.com",
"startDate": "2023-01-01",
"endDate": "2025-12-31"
},
"matchingStrategy": "all"
}
}
```
@@ -223,17 +223,17 @@ Applies a legal hold to **all emails matching a Meilisearch query**. The operati
```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"
}
"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"
}
}
```
@@ -260,15 +260,15 @@ Removes all `email_legal_holds` associations for the given hold in a single oper
#### Path Parameters
| Parameter | Type | Description |
| --------- | ------ | ------------------------------------ |
| `id` | `uuid` | The UUID of the hold to release. |
| Parameter | Type | Description |
| --------- | ------ | -------------------------------- |
| `id` | `uuid` | The UUID of the hold to release. |
#### Response Body
```json
{
"emailsReleased": 4821
"emailsReleased": 4821
}
```
@@ -294,9 +294,9 @@ Returns all legal holds currently linked to a specific archived email, including
#### Path Parameters
| Parameter | Type | Description |
| --------- | ------ | ---------------------------------------- |
| `emailId` | `uuid` | The UUID of the archived email. |
| Parameter | Type | Description |
| --------- | ------ | ------------------------------- |
| `emailId` | `uuid` | The UUID of the archived email. |
#### Response Body
@@ -304,20 +304,20 @@ Returns an empty array `[]` if no holds are applied, or an array of hold-link ob
```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
}
{
"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
}
]
```
@@ -338,21 +338,21 @@ Links a single archived email to an active legal hold. The operation is idempote
#### Path Parameters
| Parameter | Type | Description |
| --------- | ------ | ---------------------------------------- |
| `emailId` | `uuid` | The UUID of the archived email. |
| 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. |
| Field | Type | Required | Description |
| -------- | ------ | -------- | ------------------------------ |
| `holdId` | `uuid` | Yes | The UUID of the hold to apply. |
#### Example Request
```json
{
"holdId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
"holdId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
```
@@ -362,11 +362,11 @@ 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"
"legalHoldId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"holdName": "Project Titan Litigation — 2026",
"isActive": true,
"appliedAt": "2026-01-16T14:22:00.000Z",
"appliedByUserId": "user-uuid-here"
}
```
@@ -390,16 +390,16 @@ Unlinks a specific legal hold from a specific archived email. The hold itself is
#### Path Parameters
| Parameter | Type | Description |
| --------- | ------ | ---------------------------------------- |
| `emailId` | `uuid` | The UUID of the archived email. |
| `holdId` | `uuid` | The UUID of the hold to remove. |
| 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."
"message": "Hold removed from email successfully."
}
```
@@ -416,10 +416,10 @@ 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
"status": "error",
"statusCode": 409,
"message": "Cannot delete an active legal hold. Deactivate it first to explicitly lift legal protection before deletion.",
"errors": null
}
```
@@ -427,15 +427,15 @@ For validation errors (`422 Unprocessable Entity`):
```json
{
"status": "error",
"statusCode": 422,
"message": "Invalid input provided.",
"errors": [
{
"field": "name",
"message": "Name is required."
}
]
"status": "error",
"statusCode": 422,
"message": "Invalid input provided.",
"errors": [
{
"field": "name",
"message": "Name is required."
}
]
}
```
@@ -443,12 +443,12 @@ For validation errors (`422 Unprocessable Entity`):
## 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"`. |
| 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

@@ -14,8 +14,8 @@ The main page displays a table of all legal holds with the following columns:
- **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.
- **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).
@@ -43,13 +43,14 @@ Click **Edit** from the actions dropdown to modify the hold's name or reason. Th
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.
> **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.
@@ -81,6 +82,7 @@ At least one of these fields must be filled before the **Apply Hold** button bec
### 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
@@ -99,6 +101,7 @@ A confirmation dialog is shown before the operation proceeds. On success, a noti
### 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
@@ -140,18 +143,22 @@ The **Delete Email** button on the email detail page is not disabled in the UI,
## 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

@@ -61,17 +61,17 @@ Holds can optionally be linked to an `ediscovery_cases` record (`caseId` field)
## 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 |
| 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
@@ -89,12 +89,12 @@ Holds can optionally be linked to an `ediscovery_cases` record (`caseId` field)
### `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). |
| 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.
@@ -112,14 +112,14 @@ The lifecycle worker calls `legalHoldService.isEmailUnderActiveHold(emailId)` as
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 |
| 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

@@ -23,22 +23,22 @@ Retrieves all retention labels, ordered by creation date ascending.
```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"
}
{
"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"
}
]
```
@@ -55,9 +55,9 @@ Retrieves a single retention label by its UUID.
#### Path Parameters
| Parameter | Type | Description |
| --------- | ------ | ------------------------------ |
| `id` | `uuid` | The UUID of the label to get. |
| Parameter | Type | Description |
| --------- | ------ | ----------------------------- |
| `id` | `uuid` | The UUID of the label to get. |
#### Response Body
@@ -76,19 +76,19 @@ Creates a new retention label. The label name must be unique across the system.
#### 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. |
| 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
"name": "Financial Records - Q4 2025",
"description": "Extended retention for Q4 2025 financial correspondence per regulatory requirements",
"retentionPeriodDays": 2555
}
```
@@ -111,9 +111,9 @@ Updates an existing retention label. Only the fields included in the request bod
#### Path Parameters
| Parameter | Type | Description |
| --------- | ------ | --------------------------------- |
| `id` | `uuid` | The UUID of the label to update. |
| Parameter | Type | Description |
| --------- | ------ | -------------------------------- |
| `id` | `uuid` | The UUID of the label to update. |
#### Request Body
@@ -125,8 +125,8 @@ All fields from the create endpoint are accepted, and all are optional. Only pro
```json
{
"name": "Financial Records - Q4 2025 (Updated)",
"description": "Updated description for Q4 2025 financial records retention"
"name": "Financial Records - Q4 2025 (Updated)",
"description": "Updated description for Q4 2025 financial records retention"
}
```
@@ -150,9 +150,9 @@ Deletes or disables a retention label depending on its usage status.
#### Path Parameters
| Parameter | Type | Description |
| --------- | ------ | --------------------------------- |
| `id` | `uuid` | The UUID of the label to delete. |
| Parameter | Type | Description |
| --------- | ------ | -------------------------------- |
| `id` | `uuid` | The UUID of the label to delete. |
#### Deletion Logic
@@ -163,7 +163,7 @@ Deletes or disables a retention label depending on its usage status.
```json
{
"action": "deleted"
"action": "deleted"
}
```
@@ -171,7 +171,7 @@ or
```json
{
"action": "disabled"
"action": "disabled"
}
```
@@ -195,9 +195,9 @@ Retrieves the retention label currently applied to a specific archived email.
#### Path Parameters
| Parameter | Type | Description |
| --------- | ------ | ------------------------------------- |
| `emailId` | `uuid` | The UUID of the archived email. |
| Parameter | Type | Description |
| --------- | ------ | ------------------------------- |
| `emailId` | `uuid` | The UUID of the archived email. |
#### Response Body
@@ -211,11 +211,11 @@ 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"
"labelId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"labelName": "Legal Hold - Litigation ABC",
"retentionPeriodDays": 2555,
"appliedAt": "2025-10-15T14:30:00.000Z",
"appliedByUserId": "user123"
}
```
@@ -237,21 +237,21 @@ Applies a retention label to an archived email. If the email already has a label
#### Path Parameters
| Parameter | Type | Description |
| --------- | ------ | ------------------------------------- |
| `emailId` | `uuid` | The UUID of the archived email. |
| 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. |
| Field | Type | Required | Description |
| --------- | ------ | -------- | ------------------------------- |
| `labelId` | `uuid` | Yes | The UUID of the label to apply. |
#### Example Request
```json
{
"labelId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
"labelId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
```
@@ -259,11 +259,11 @@ Applies a retention label to an archived email. If the email already has a label
```json
{
"labelId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"labelName": "Legal Hold - Litigation ABC",
"retentionPeriodDays": 2555,
"appliedAt": "2025-10-15T14:30:00.000Z",
"appliedByUserId": "user123"
"labelId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"labelName": "Legal Hold - Litigation ABC",
"retentionPeriodDays": 2555,
"appliedAt": "2025-10-15T14:30:00.000Z",
"appliedByUserId": "user123"
}
```
@@ -287,9 +287,9 @@ Removes the retention label from an archived email if one is applied.
#### Path Parameters
| Parameter | Type | Description |
| --------- | ------ | ------------------------------------- |
| `emailId` | `uuid` | The UUID of the archived email. |
| Parameter | Type | Description |
| --------- | ------ | ------------------------------- |
| `emailId` | `uuid` | The UUID of the archived email. |
#### Response Body
@@ -297,7 +297,7 @@ If a label was removed:
```json
{
"message": "Label removed successfully."
"message": "Label removed successfully."
}
```
@@ -305,7 +305,7 @@ If no label was applied:
```json
{
"message": "No label was applied to this email."
"message": "No label was applied to this email."
}
```
@@ -322,10 +322,10 @@ All endpoints use the standard error response format:
```json
{
"status": "error",
"statusCode": 404,
"message": "The requested resource could not be found.",
"errors": null
"status": "error",
"statusCode": 404,
"message": "The requested resource could not be found.",
"errors": null
}
```
@@ -333,28 +333,28 @@ 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."
}
]
"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. |
| 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

@@ -9,41 +9,51 @@ Automated retention label application allows external systems and services to pr
## 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**:
**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
- 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
@@ -51,29 +61,36 @@ Automated retention label application allows external systems and services to pr
## 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
```
@@ -81,9 +98,11 @@ 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
@@ -94,11 +113,13 @@ Content-Type: application/json
```
### 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:
```
@@ -108,6 +129,7 @@ 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
@@ -116,6 +138,7 @@ This returns all labels with their IDs, names, retention periods, and status (en
## 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)
@@ -124,6 +147,7 @@ Apply labels after emails have been fully ingested and indexed:
4. Apply the label via API
### Pattern 2: Batch Processing
Process emails in scheduled batches:
1. Query for unlabeled emails periodically (daily/weekly)
@@ -132,6 +156,7 @@ Process emails in scheduled batches:
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.)
@@ -142,13 +167,16 @@ React to specific events or triggers:
## 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
@@ -157,12 +185,14 @@ 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
@@ -171,11 +201,13 @@ Content-Type: application/json
## 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
@@ -184,12 +216,15 @@ Content-Type: application/json
## 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
@@ -199,18 +234,21 @@ 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
@@ -226,4 +264,4 @@ This ensures full traceability of automated retention decisions.
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.
This automated approach ensures consistent retention policy enforcement while reducing manual administrative overhead.

View File

@@ -13,8 +13,8 @@ The main page displays a table of all retention labels with the following column
- **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
- **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.
@@ -81,12 +81,16 @@ Click the **Delete** option from the actions dropdown to open the deletion confi
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
#### 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
@@ -96,6 +100,7 @@ If the label is **currently applied** to one or more emails:
### 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
@@ -111,8 +116,8 @@ Retention labels can be applied to individual archived emails through the email
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
- The current label name and retention period
- "Change Label" and "Remove Label" buttons
### Label Application Process
@@ -121,10 +126,10 @@ Retention labels can be applied to individual archived emails through the email
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
- 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
@@ -135,21 +140,25 @@ Each email can have at most one retention label. When you apply a new label to a
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
### 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
@@ -159,24 +168,28 @@ Different operations require different permission levels:
## 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)
- **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
@@ -184,23 +197,28 @@ Different operations require different permission levels:
## 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
- `delete:archive` for email label operations

View File

@@ -62,37 +62,37 @@ Apply custom retention periods to emails related to specific projects, contracts
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. |
| 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. |
| 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). |
| 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.
@@ -107,11 +107,11 @@ The lifecycle worker queries the `email_retention_labels` table during email eva
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 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.
Individual archived email pages display any applied retention label and provide controls for users with appropriate permissions to apply or remove labels.

View File

@@ -21,18 +21,18 @@ Retrieves all retention policies, ordered by priority ascending.
```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"
}
{
"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"
}
]
```
@@ -70,34 +70,34 @@ Creates a new retention policy. The policy name must be unique across the system
### 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. |
| 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"
}
]
"logicalOperator": "AND",
"rules": [
{
"field": "sender",
"operator": "domain_match",
"value": "example.com"
},
{
"field": "subject",
"operator": "contains",
"value": "invoice"
}
]
}
```
@@ -105,18 +105,19 @@ Creates a new retention policy. The policy name must be unique across the system
**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.|
| 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.
@@ -124,27 +125,27 @@ Creates a new retention policy. The policy name must be unique across the system
```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"]
"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"]
}
```
@@ -220,25 +221,25 @@ Evaluates a set of email metadata against all active policies and returns the ap
### 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.|
| 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"
}
"emailMetadata": {
"sender": "cfo@finance.acme.com",
"recipients": ["legal@acme.com"],
"subject": "Q4 Invoice Reconciliation",
"attachmentTypes": [".pdf", ".xlsx"],
"ingestionSourceId": "b2c3d4e5-f6a7-8901-bcde-f23456789012"
}
}
```
@@ -246,12 +247,12 @@ Evaluates a set of email metadata against all active policies and returns the ap
```json
{
"appliedRetentionDays": 3650,
"actionOnExpiry": "delete_permanently",
"matchingPolicyIds": [
"a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"c3d4e5f6-a7b8-9012-cdef-345678901234"
]
"appliedRetentionDays": 3650,
"actionOnExpiry": "delete_permanently",
"matchingPolicyIds": [
"a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"c3d4e5f6-a7b8-9012-cdef-345678901234"
]
}
```

View File

@@ -47,16 +47,16 @@ Conditions define which emails the policy targets. If no conditions are added, t
### 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). |
| 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

View File

@@ -43,13 +43,13 @@ The Retention Policy Engine requires:
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. |
| 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

@@ -13,7 +13,9 @@ The lifecycle worker is the automated enforcement component of the retention pol
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
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.
@@ -62,12 +64,12 @@ If the entire job fails, BullMQ records the failure and the job ID and error are
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` |
| 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.
@@ -85,18 +87,18 @@ This ensures that every automated deletion is fully traceable back to the specif
## Configuration
| Environment Variable | Description | Default |
| ------------------------- | ---------------------------------------------------- | ------- |
| `RETENTION_BATCH_SIZE` | Number of emails to process per batch iteration. | — |
| 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. |
| 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

View File

@@ -6,19 +6,19 @@ The backend implementation of the retention policy engine is handled by the `Ret
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. |
| 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
@@ -53,6 +53,7 @@ The evaluation engine is the core logic that determines which policies apply to
### `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.
@@ -68,6 +69,7 @@ The evaluation flow:
### `_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`.
@@ -76,27 +78,27 @@ Evaluates a `RetentionRuleGroup` using AND or OR logic:
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`). |
| 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) |
| 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
@@ -133,6 +135,7 @@ The `RetentionPolicyModule` (`retention-policy.module.ts`) implements the `Archi
```
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

@@ -31,12 +31,13 @@ archive.zip
3. Select **EML Import** as the provider.
4. Enter a name for the ingestion source.
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)
- **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.
>
> - **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.

View File

@@ -18,12 +18,13 @@ Once you have your `.mbox` file, you can upload it to OpenArchiver through the w
2. Click on the **New Ingestion** button.
3. Select **Mbox** as the source type.
4. **Choose Import Method:**
* **Upload File:** Upload your `.mbox` file.
* **Local Path:** Enter the path to the mbox file **inside the container**.
- **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.
>
> - **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

@@ -16,12 +16,13 @@ To ensure a successful import, you should prepare your PST file according to the
3. Select **PST Import** as the provider.
4. Enter a name for the ingestion source.
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)
- **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.
>
> - **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.

View File

@@ -24,11 +24,11 @@ Add the `MEILI_EXPERIMENTAL_DUMPLESS_UPGRADE` environment variable to your `dock
```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
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**
@@ -37,9 +37,9 @@ Alternatively, you can pass the `--experimental-dumpless-upgrade` flag in the co
```yaml
services:
meilisearch:
image: getmeili/meilisearch:v1.x # The new version you want to upgrade to
command: meilisearch --experimental-dumpless-upgrade
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:

View File

@@ -63,12 +63,9 @@ export class ArchivedEmailController {
try {
checkDeletionEnabled();
} catch (error) {
return res
.status(400)
.json({
message:
error instanceof Error ? error.message : req.t('errors.deletionDisabled'),
});
return res.status(400).json({
message: error instanceof Error ? error.message : req.t('errors.deletionDisabled'),
});
}
const { id } = req.params;

View File

@@ -1,216 +1,216 @@
{
"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
},
{
"idx": 27,
"version": "7",
"when": 1773768709477,
"tag": "0027_black_morph",
"breakpoints": true
},
{
"idx": 28,
"version": "7",
"when": 1773770326402,
"tag": "0028_youthful_kitty_pryde",
"breakpoints": true
},
{
"idx": 29,
"version": "7",
"when": 1773927678269,
"tag": "0029_lethal_brood",
"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
},
{
"idx": 27,
"version": "7",
"when": 1773768709477,
"tag": "0027_black_morph",
"breakpoints": true
},
{
"idx": 28,
"version": "7",
"when": 1773770326402,
"tag": "0028_youthful_kitty_pryde",
"breakpoints": true
},
{
"idx": 29,
"version": "7",
"when": 1773927678269,
"tag": "0029_lethal_brood",
"breakpoints": true
}
]
}

View File

@@ -50,18 +50,20 @@ export const retentionLabels = pgTable('retention_labels', {
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 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(),
@@ -105,9 +107,7 @@ export const emailLegalHolds = pgTable(
appliedAt: timestamp('applied_at', { withTimezone: true }).notNull().defaultNow(),
appliedByUserId: uuid('applied_by_user_id').references(() => users.id),
},
(t) => [
primaryKey({ columns: [t.emailId, t.legalHoldId] }),
],
(t) => [primaryKey({ columns: [t.emailId, t.legalHoldId] })]
);
export const exportJobs = pgTable('export_jobs', {

View File

@@ -8,7 +8,7 @@ export * from './api/middleware/requirePermission';
export { db } from './database';
export * from './database/schema';
export { AuditService } from './services/AuditService';
export * from './config'
export * from './jobs/queues'
export * from './config';
export * from './jobs/queues';
export { RetentionHook } from './hooks/RetentionHook';
export { IntegrityService } from './services/IntegrityService';

View File

@@ -31,16 +31,16 @@ export class ApiKeyService {
await this.auditService.createAuditLog({
actorIdentifier: actor.id,
actionType: 'GENERATE',
targetType: 'ApiKey',
targetId: name,
actorIp,
details: {
keyName: name,
},
});
actionType: 'GENERATE',
targetType: 'ApiKey',
targetId: name,
actorIp,
details: {
keyName: name,
},
});
return key;
return key;
} catch (error) {
throw error;
}

View File

@@ -96,7 +96,7 @@ export class IntegrityService {
isValid: false,
reason: 'Could not read attachment file from storage.',
storedHash: attachment.contentHashSha256,
computedHash: "",
computedHash: '',
});
}
}

View File

@@ -78,7 +78,9 @@ export class EMLConnector implements IEmailConnector {
if (!fileExist) {
if (this.credentials.localFilePath) {
throw Error(`EML Zip file not found at path: ${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.'
@@ -256,10 +258,7 @@ export class EMLConnector implements IEmailConnector {
}
}
private async parseMessage(
input: Buffer | Readable,
path: string
): Promise<EmailObject> {
private async parseMessage(input: Buffer | Readable, path: string): Promise<EmailObject> {
let emlBuffer: Buffer;
if (Buffer.isBuffer(input)) {
emlBuffer = input;

View File

@@ -221,7 +221,9 @@ export class ImapConnector implements IEmailConnector {
// Optimization: Verify existence using Message-ID from envelope before fetching full body
if (checkDuplicate && msg.envelope?.messageId) {
const isDuplicate = await checkDuplicate(msg.envelope.messageId);
const isDuplicate = await checkDuplicate(
msg.envelope.messageId
);
if (isDuplicate) {
logger.debug(
{

View File

@@ -93,10 +93,7 @@ export class MboxConnector implements IEmailConnector {
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;
}
}

View File

@@ -171,10 +171,7 @@ export class PSTConnector implements IEmailConnector {
}
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;
}
}

View File

@@ -47,18 +47,14 @@
let isEnabled = $state(policy?.isActive ?? true);
// Conditions state
let logicalOperator = $state<LogicalOperator>(
policy?.conditions?.logicalOperator ?? 'AND'
);
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 ?? [])
);
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 }));
@@ -202,11 +198,7 @@
<!-- 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)}
/>
<Switch id="rp-enabled" checked={isEnabled} onCheckedChange={(v) => (isEnabled = v)} />
<Label for="rp-enabled">{$t('app.retention_policies.active')}</Label>
</div>
@@ -310,7 +302,8 @@
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}
{operatorOptions.find((o) => o.value === rule.operator)?.label ??
rule.operator}
</Select.Trigger>
<Select.Content>
{#each operatorOptions as opt}

View File

@@ -12,7 +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'
import bg from './bg.json';
// This is your config object.
// It defines the languages and how to load them.
const config: Config = {

View File

@@ -1,400 +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",
"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."
}
}
}
{
"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

@@ -146,7 +146,7 @@
<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>
<Badge class="px-1 py-0.5 text-[8px] font-bold">Enterprise</Badge>
{/if}
</a>

View File

@@ -120,7 +120,10 @@ export const actions: Actions = {
if (!response.ok) {
const res = await response.json().catch(() => ({}));
return { success: false, message: (res as { message?: string }).message || 'Failed to apply label' };
return {
success: false,
message: (res as { message?: string }).message || 'Failed to apply label',
};
}
return { success: true, action: 'applied' };
@@ -135,7 +138,10 @@ export const actions: Actions = {
if (!response.ok) {
const res = await response.json().catch(() => ({}));
return { success: false, message: (res as { message?: string }).message || 'Failed to remove label' };
return {
success: false,
message: (res as { message?: string }).message || 'Failed to remove label',
};
}
return { success: true, action: 'removed' };

View File

@@ -16,7 +16,16 @@
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, FileDown } from 'lucide-svelte';
import {
Clock,
Trash2,
CalendarClock,
AlertCircle,
Shield,
CircleAlert,
Tag,
FileDown,
} from 'lucide-svelte';
import { page } from '$app/state';
import { enhance } from '$app/forms';
import type { LegalHold, EmailLegalHoldInfo } from '@open-archiver/types';
@@ -239,7 +248,7 @@
</div>
<div class="space-y-1">
<h3 class="font-semibold">{$t('app.archive.meta_data')}</h3>
<div class="text-muted-foreground text-sm space-y-2">
<div class="text-muted-foreground space-y-2 text-sm">
{#if email.path}
<div class="flex flex-wrap items-center gap-2">
<span>{$t('app.archive.folder')}:</span>
@@ -318,12 +327,16 @@
download(email.storagePath, `${email.subject || 'email'}.eml`)}
>{$t('app.archive.download_eml')}</Button
>
<Button variant="destructive" class="text-xs" 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>
@@ -445,14 +458,18 @@
{#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="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">
<Badge
class="bg-destructive text-xs text-white"
>
{$t('app.legal_holds.active')}
</Badge>
{:else}
@@ -527,15 +544,20 @@
>
<Select.Trigger class="w-full text-xs">
{#if selectedHoldId}
{legalHolds.find((h) => h.id === selectedHoldId)?.name ??
$t('app.archive_legal_holds.apply_hold_placeholder')}
{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>
<Select.Item value={hold.id} class="text-xs"
>{hold.name}</Select.Item
>
{/each}
</Select.Content>
</Select.Root>
@@ -580,11 +602,14 @@
<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">
<div
class="bg-muted-foreground text-muted flex items-start gap-2 rounded-md px-2 py-1.5 align-middle"
>
<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>.
<span class="font-medium">{emailRetentionLabel.labelName}</span
>.
</div>
</div>
{/if}
@@ -605,13 +630,13 @@
</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
>
<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'
@@ -637,7 +662,7 @@
</div>
<div class="flex flex-wrap gap-1">
{#each retentionPolicy.matchingPolicyIds as policyId}
<Badge variant="outline" class="text-xs font-mono">
<Badge variant="outline" class="font-mono text-xs">
{policyId}
</Badge>
{/each}
@@ -682,12 +707,19 @@
<Badge variant="secondary">
{emailRetentionLabel.labelName}
</Badge>
<Badge variant="outline" class="text-muted-foreground text-xs">
<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" />
<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>
@@ -731,7 +763,9 @@
</Badge>
</div>
<div class="flex items-center gap-2">
<Clock class="text-muted-foreground h-4 w-4 flex-shrink-0" />
<Clock
class="text-muted-foreground h-4 w-4 flex-shrink-0"
/>
<span class="text-xs font-medium">
{$t('app.archive.retention_period')}:
</span>
@@ -812,7 +846,8 @@
>
<Select.Trigger class="w-full text-xs">
{#if selectedLabelId}
{retentionLabels.find((l) => l.id === selectedLabelId)?.name ??
{retentionLabels.find((l) => l.id === selectedLabelId)
?.name ??
$t('app.archive_labels.select_label_placeholder')}
{:else}
{$t('app.archive_labels.select_label_placeholder')}
@@ -823,13 +858,14 @@
<Select.Item value={label.id}>
{label.name}
<span class="text-muted-foreground ml-1 text-xs">
({label.retentionPeriodDays} {$t('app.retention_labels.days')})
({label.retentionPeriodDays}
{$t('app.retention_labels.days')})
</span>
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<Button
type="submit"
variant="outline"
@@ -851,8 +887,6 @@
{/if}
</Card.Content>
</Card.Root>
{/if}
</div>
</div>

View File

@@ -98,7 +98,10 @@ export const actions: Actions = {
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: false,
message: (res as { message?: string }).message || 'Failed to delete legal hold.',
};
}
return { success: true };
@@ -147,7 +150,8 @@ export const actions: Actions = {
if (!response.ok) {
return {
success: false,
message: (res as { message?: string }).message || 'Failed to release emails from hold.',
message:
(res as { message?: string }).message || 'Failed to release emails from hold.',
};
}

View File

@@ -112,7 +112,7 @@
<div class="flex items-center gap-2">
<div>
<div>{hold.name}</div>
<div class="mt-0.5 font-mono text-[10px] text-muted-foreground">
<div class="text-muted-foreground mt-0.5 font-mono text-[10px]">
{hold.id}
</div>
</div>
@@ -120,7 +120,9 @@
</Table.Cell>
<Table.Cell class="max-w-[300px]">
{#if hold.reason}
<span class="text-muted-foreground line-clamp-2 text-xs">{hold.reason}</span>
<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')}
@@ -179,33 +181,52 @@
</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();
};
}}>
<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)} />
<input
type="hidden"
name="isActive"
value={String(!hold.isActive)}
/>
<DropdownMenu.Item>
<button type="submit" class="w-full text-left">
{hold.isActive
@@ -362,11 +383,7 @@
</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 ?? ''}
/>
<Textarea id="edit-reason" name="reason" value={selectedHold.reason ?? ''} />
</div>
<div class="flex justify-end gap-2">
<Button
@@ -464,7 +481,9 @@
<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">
<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>
@@ -478,7 +497,11 @@
>
{$t('app.legal_holds.cancel')}
</Button>
<Button type="submit" disabled={isFormLoading || (!bulkQuery && !bulkFiltersFrom && !bulkFiltersDateStart)}>
<Button
type="submit"
disabled={isFormLoading ||
(!bulkQuery && !bulkFiltersFrom && !bulkFiltersDateStart)}
>
{#if isFormLoading}
{$t('app.common.working')}
{:else}

View File

@@ -73,7 +73,7 @@
<Table.Row>
<Table.Cell class="font-medium">
<div>{label.name}</div>
<div class="mt-0.5 font-mono text-[10px] text-muted-foreground">
<div class="text-muted-foreground mt-0.5 font-mono text-[10px]">
{label.id}
</div>
{#if label.description}
@@ -88,13 +88,14 @@
</Table.Cell>
<!-- Applied email count — shows a subtle badge with the number -->
<Table.Cell>
<div class="flex items-center gap-1.5">
<div class="flex items-center gap-1.5">
<Tag class="text-muted-foreground h-3.5 w-3.5" />
<Badge variant={label.appliedEmailCount > 0 ? 'secondary' : 'outline'}>
{label.appliedEmailCount}
</Badge>
<Badge
variant={label.appliedEmailCount > 0 ? 'secondary' : 'outline'}
>
{label.appliedEmailCount}
</Badge>
</div>
</Table.Cell>
<Table.Cell>
{#if label.isDisabled}
@@ -286,12 +287,7 @@
<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}
/>
<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>
@@ -369,11 +365,7 @@
</Dialog.Description>
</Dialog.Header>
<Dialog.Footer>
<Button
variant="outline"
onclick={() => (isDeleteOpen = false)}
disabled={isDeleting}
>
<Button variant="outline" onclick={() => (isDeleteOpen = false)} disabled={isDeleting}>
{$t('app.retention_labels.cancel')}
</Button>
{#if selectedLabel}

View File

@@ -1,7 +1,11 @@
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';
import type {
RetentionPolicy,
PolicyEvaluationResult,
SafeIngestionSource,
} from '@open-archiver/types';
export const load: PageServerLoad = async (event) => {
if (!event.locals.enterpriseMode) {

View File

@@ -63,7 +63,6 @@
const op = policy.conditions.logicalOperator;
return `${count} ${$t('app.retention_policies.rules')} (${op})`;
}
</script>
<svelte:head>
@@ -102,11 +101,13 @@
<Table.Row>
<Table.Cell class="font-medium">
<div>{policy.name}</div>
<div class="mt-0.5 font-mono text-[10px] text-muted-foreground">
<div class="text-muted-foreground mt-0.5 font-mono text-[10px]">
{policy.id}
</div>
{#if policy.description}
<div class="text-muted-foreground mt-0.5 text-xs">{policy.description}</div>
<div class="text-muted-foreground mt-0.5 text-xs">
{policy.description}
</div>
{/if}
</Table.Cell>
<Table.Cell>{policy.priority}</Table.Cell>
@@ -122,7 +123,9 @@
{:else}
<div class="flex flex-wrap gap-1">
{#each policy.ingestionScope as sourceId (sourceId)}
{@const source = ingestionSources.find((s) => s.id === sourceId)}
{@const source = ingestionSources.find(
(s) => s.id === sourceId
)}
<Badge variant="outline" class="text-xs">
{source?.name ?? sourceId.slice(0, 8) + '…'}
</Badge>
@@ -131,7 +134,9 @@
{/if}
</Table.Cell>
<Table.Cell>
<span class="text-muted-foreground text-sm">{conditionsSummary(policy)}</span>
<span class="text-muted-foreground text-sm"
>{conditionsSummary(policy)}</span
>
</Table.Cell>
<Table.Cell>
{#if policy.isActive}
@@ -330,7 +335,9 @@
</Select.Trigger>
<Select.Content>
<Select.Item value="">
<span class="italic">{$t('app.retention_policies.simulator_ingestion_all')}</span>
<span class="italic"
>{$t('app.retention_policies.simulator_ingestion_all')}</span
>
</Select.Item>
{#each ingestionSources as source (source.id)}
<Select.Item value={source.id}>
@@ -370,7 +377,9 @@
</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">
<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,
@@ -379,18 +388,24 @@
</div>
{#if evaluationResult.matchingPolicyIds.length > 0}
<div class="space-y-1.5">
<p class="text-muted-foreground text-xs font-medium uppercase tracking-wide">
<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">
<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>
<span class="text-muted-foreground text-xs"
>({matchedPolicy.name})</span
>
{/if}
</div>
{/each}
@@ -419,11 +434,7 @@
</Dialog.Description>
</Dialog.Header>
<Dialog.Footer>
<Button
variant="outline"
onclick={() => (isDeleteOpen = false)}
disabled={isDeleting}
>
<Button variant="outline" onclick={() => (isDeleteOpen = false)} disabled={isDeleting}>
{$t('app.retention_policies.cancel')}
</Button>
{#if selectedPolicy}

View File

@@ -7,7 +7,7 @@
import { Label } from '$lib/components/ui/label';
import * as Dialog from '$lib/components/ui/dialog';
import { setAlert } from '$lib/components/custom/alert/alert-state.svelte';
let { data, form } = $props();
let user = $derived(data.user);
@@ -15,62 +15,61 @@
let isPasswordDialogOpen = $state(false);
let isSubmitting = $state(false);
// Profile form state
let profileFirstName = $state('');
let profileLastName = $state('');
let profileEmail = $state('');
// Profile form state
let profileFirstName = $state('');
let profileLastName = $state('');
let profileEmail = $state('');
// Password form state
let currentPassword = $state('');
let newPassword = $state('');
let confirmNewPassword = $state('');
// Password form state
let currentPassword = $state('');
let newPassword = $state('');
let confirmNewPassword = $state('');
// Preload profile form
$effect(() => {
if (user && isProfileDialogOpen) {
profileFirstName = user.first_name || '';
profileLastName = user.last_name || '';
profileEmail = user.email || '';
}
});
// Preload profile form
$effect(() => {
if (user && isProfileDialogOpen) {
profileFirstName = user.first_name || '';
profileLastName = user.last_name || '';
profileEmail = user.email || '';
}
});
// Handle form actions result
$effect(() => {
if (form) {
isSubmitting = false;
if (form.success) {
isProfileDialogOpen = false;
isPasswordDialogOpen = false;
setAlert({
type: 'success',
title: $t('app.account.operation_successful'),
message: $t('app.account.operation_successful'),
duration: 3000,
show: true
});
} else if (form.profileError || form.passwordError) {
setAlert({
type: 'error',
title: $t('app.search.error'),
message: form.message,
duration: 3000,
show: true
});
}
}
});
// Handle form actions result
$effect(() => {
if (form) {
isSubmitting = false;
if (form.success) {
isProfileDialogOpen = false;
isPasswordDialogOpen = false;
setAlert({
type: 'success',
title: $t('app.account.operation_successful'),
message: $t('app.account.operation_successful'),
duration: 3000,
show: true,
});
} else if (form.profileError || form.passwordError) {
setAlert({
type: 'error',
title: $t('app.search.error'),
message: form.message,
duration: 3000,
show: true,
});
}
}
});
function openProfileDialog() {
isProfileDialogOpen = true;
}
function openProfileDialog() {
isProfileDialogOpen = true;
}
function openPasswordDialog() {
currentPassword = '';
newPassword = '';
confirmNewPassword = '';
isPasswordDialogOpen = true;
}
function openPasswordDialog() {
currentPassword = '';
newPassword = '';
confirmNewPassword = '';
isPasswordDialogOpen = true;
}
</script>
<svelte:head>
@@ -78,141 +77,195 @@
</svelte:head>
<div class="space-y-6">
<div>
<h1 class="text-2xl font-bold">{$t('app.account.title')}</h1>
<p class="text-muted-foreground">{$t('app.account.description')}</p>
</div>
<div>
<h1 class="text-2xl font-bold">{$t('app.account.title')}</h1>
<p class="text-muted-foreground">{$t('app.account.description')}</p>
</div>
<!-- Personal Information -->
<Card.Root>
<Card.Header>
<Card.Title>{$t('app.account.personal_info')}</Card.Title>
</Card.Header>
<Card.Content class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<Label class="text-muted-foreground">{$t('app.users.name')}</Label>
<p class="text-sm font-medium">{user?.first_name} {user?.last_name}</p>
</div>
<div>
<Label class="text-muted-foreground">{$t('app.users.email')}</Label>
<p class="text-sm font-medium">{user?.email}</p>
</div>
<div>
<Label class="text-muted-foreground">{$t('app.users.role')}</Label>
<p class="text-sm font-medium">{user?.role?.name || '-'}</p>
</div>
</div>
</Card.Content>
<Card.Footer>
<Button variant="outline" onclick={openProfileDialog}>{$t('app.account.edit_profile')}</Button>
</Card.Footer>
</Card.Root>
<!-- Personal Information -->
<Card.Root>
<Card.Header>
<Card.Title>{$t('app.account.personal_info')}</Card.Title>
</Card.Header>
<Card.Content class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<Label class="text-muted-foreground">{$t('app.users.name')}</Label>
<p class="text-sm font-medium">{user?.first_name} {user?.last_name}</p>
</div>
<div>
<Label class="text-muted-foreground">{$t('app.users.email')}</Label>
<p class="text-sm font-medium">{user?.email}</p>
</div>
<div>
<Label class="text-muted-foreground">{$t('app.users.role')}</Label>
<p class="text-sm font-medium">{user?.role?.name || '-'}</p>
</div>
</div>
</Card.Content>
<Card.Footer>
<Button variant="outline" onclick={openProfileDialog}
>{$t('app.account.edit_profile')}</Button
>
</Card.Footer>
</Card.Root>
<!-- Security -->
<Card.Root>
<Card.Header>
<Card.Title>{$t('app.account.security')}</Card.Title>
</Card.Header>
<Card.Content>
<div class="flex items-center justify-between">
<div>
<Label class="text-muted-foreground">{$t('app.auth.password')}</Label>
<p class="text-sm">*************</p>
</div>
</div>
</Card.Content>
<Card.Footer>
<Button variant="outline" onclick={openPasswordDialog}>{$t('app.account.change_password')}</Button>
</Card.Footer>
</Card.Root>
<!-- Security -->
<Card.Root>
<Card.Header>
<Card.Title>{$t('app.account.security')}</Card.Title>
</Card.Header>
<Card.Content>
<div class="flex items-center justify-between">
<div>
<Label class="text-muted-foreground">{$t('app.auth.password')}</Label>
<p class="text-sm">*************</p>
</div>
</div>
</Card.Content>
<Card.Footer>
<Button variant="outline" onclick={openPasswordDialog}
>{$t('app.account.change_password')}</Button
>
</Card.Footer>
</Card.Root>
</div>
<!-- Profile Edit Dialog -->
<Dialog.Root bind:open={isProfileDialogOpen}>
<Dialog.Content class="sm:max-w-[425px]">
<Dialog.Header>
<Dialog.Title>{$t('app.account.edit_profile')}</Dialog.Title>
<Dialog.Description>{$t('app.account.edit_profile_desc')}</Dialog.Description>
</Dialog.Header>
<form method="POST" action="?/updateProfile" use:enhance={() => {
isSubmitting = true;
return async ({ update }) => {
await update();
isSubmitting = false;
};
}} class="grid gap-4 py-4">
<div class="grid grid-cols-4 items-center gap-4">
<Label for="first_name" class="text-right">{$t('app.setup.first_name')}</Label>
<Input id="first_name" name="first_name" bind:value={profileFirstName} class="col-span-3" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="last_name" class="text-right">{$t('app.setup.last_name')}</Label>
<Input id="last_name" name="last_name" bind:value={profileLastName} class="col-span-3" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="email" class="text-right">{$t('app.users.email')}</Label>
<Input id="email" name="email" type="email" bind:value={profileEmail} class="col-span-3" />
</div>
<Dialog.Footer>
<Button type="submit" disabled={isSubmitting}>
{#if isSubmitting}
{$t('app.components.common.submitting')}
{:else}
{$t('app.components.common.save')}
{/if}
</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
<Dialog.Content class="sm:max-w-[425px]">
<Dialog.Header>
<Dialog.Title>{$t('app.account.edit_profile')}</Dialog.Title>
<Dialog.Description>{$t('app.account.edit_profile_desc')}</Dialog.Description>
</Dialog.Header>
<form
method="POST"
action="?/updateProfile"
use:enhance={() => {
isSubmitting = true;
return async ({ update }) => {
await update();
isSubmitting = false;
};
}}
class="grid gap-4 py-4"
>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="first_name" class="text-right">{$t('app.setup.first_name')}</Label>
<Input
id="first_name"
name="first_name"
bind:value={profileFirstName}
class="col-span-3"
/>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="last_name" class="text-right">{$t('app.setup.last_name')}</Label>
<Input
id="last_name"
name="last_name"
bind:value={profileLastName}
class="col-span-3"
/>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="email" class="text-right">{$t('app.users.email')}</Label>
<Input
id="email"
name="email"
type="email"
bind:value={profileEmail}
class="col-span-3"
/>
</div>
<Dialog.Footer>
<Button type="submit" disabled={isSubmitting}>
{#if isSubmitting}
{$t('app.components.common.submitting')}
{:else}
{$t('app.components.common.save')}
{/if}
</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>
<!-- Change Password Dialog -->
<Dialog.Root bind:open={isPasswordDialogOpen}>
<Dialog.Content class="sm:max-w-[425px]">
<Dialog.Header>
<Dialog.Title>{$t('app.account.change_password')}</Dialog.Title>
<Dialog.Description>{$t('app.account.change_password_desc')}</Dialog.Description>
</Dialog.Header>
<form method="POST" action="?/updatePassword" use:enhance={({ cancel }) => {
if (newPassword !== confirmNewPassword) {
setAlert({
type: 'error',
title: $t('app.search.error'),
message: $t('app.account.passwords_do_not_match'),
duration: 3000,
show: true
});
cancel();
return;
}
isSubmitting = true;
return async ({ update }) => {
await update();
isSubmitting = false;
};
}} class="grid gap-4 py-4">
<div class="grid grid-cols-4 items-center gap-4">
<Label for="currentPassword" class="text-right">{$t('app.account.current_password')}</Label>
<Input id="currentPassword" name="currentPassword" type="password" bind:value={currentPassword} class="col-span-3" required />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="newPassword" class="text-right">{$t('app.account.new_password')}</Label>
<Input id="newPassword" name="newPassword" type="password" bind:value={newPassword} class="col-span-3" required />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="confirmNewPassword" class="text-right">{$t('app.account.confirm_new_password')}</Label>
<Input id="confirmNewPassword" type="password" bind:value={confirmNewPassword} class="col-span-3" required />
</div>
<Dialog.Footer>
<Button type="submit" disabled={isSubmitting}>
{#if isSubmitting}
{$t('app.components.common.submitting')}
{:else}
{$t('app.components.common.save')}
{/if}
</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
<Dialog.Content class="sm:max-w-[425px]">
<Dialog.Header>
<Dialog.Title>{$t('app.account.change_password')}</Dialog.Title>
<Dialog.Description>{$t('app.account.change_password_desc')}</Dialog.Description>
</Dialog.Header>
<form
method="POST"
action="?/updatePassword"
use:enhance={({ cancel }) => {
if (newPassword !== confirmNewPassword) {
setAlert({
type: 'error',
title: $t('app.search.error'),
message: $t('app.account.passwords_do_not_match'),
duration: 3000,
show: true,
});
cancel();
return;
}
isSubmitting = true;
return async ({ update }) => {
await update();
isSubmitting = false;
};
}}
class="grid gap-4 py-4"
>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="currentPassword" class="text-right"
>{$t('app.account.current_password')}</Label
>
<Input
id="currentPassword"
name="currentPassword"
type="password"
bind:value={currentPassword}
class="col-span-3"
required
/>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="newPassword" class="text-right">{$t('app.account.new_password')}</Label>
<Input
id="newPassword"
name="newPassword"
type="password"
bind:value={newPassword}
class="col-span-3"
required
/>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="confirmNewPassword" class="text-right"
>{$t('app.account.confirm_new_password')}</Label
>
<Input
id="confirmNewPassword"
type="password"
bind:value={confirmNewPassword}
class="col-span-3"
required
/>
</div>
<Dialog.Footer>
<Button type="submit" disabled={isSubmitting}>
{#if isSubmitting}
{$t('app.components.common.submitting')}
{:else}
{$t('app.components.common.save')}
{/if}
</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>

View File

@@ -207,7 +207,9 @@
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="ghost" class="h-8 w-8 p-0">
<span class="sr-only">{$t('app.users.open_menu')}</span>
<span class="sr-only"
>{$t('app.users.open_menu')}</span
>
<MoreHorizontal class="h-4 w-4" />
</Button>
{/snippet}

View File

@@ -138,7 +138,9 @@
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="ghost" class="h-8 w-8 p-0">
<span class="sr-only">{$t('app.roles.open_menu')}</span>
<span class="sr-only"
>{$t('app.roles.open_menu')}</span
>
<MoreHorizontal class="h-4 w-4" />
</Button>
{/snippet}

View File

@@ -137,7 +137,9 @@
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="ghost" class="h-8 w-8 p-0">
<span class="sr-only">{$t('app.users.open_menu')}</span>
<span class="sr-only"
>{$t('app.users.open_menu')}</span
>
<MoreHorizontal class="h-4 w-4" />
</Button>
{/snippet}