mirror of
https://github.com/LogicLabs-OU/OpenArchiver.git
synced 2026-04-06 00:31:57 +02:00
Compare commits
7 Commits
v0.4.0
...
wayneshn-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d597af472 | ||
|
|
481a5ce6f9 | ||
|
|
3434e8d6ef | ||
|
|
7dac3b2bfd | ||
|
|
cf121989ae | ||
|
|
2df5c9240d | ||
|
|
24afd13858 |
@@ -36,6 +36,8 @@ REDIS_PORT=6379
|
||||
REDIS_PASSWORD=defaultredispassword
|
||||
# If you run Valkey service from Docker Compose, set the REDIS_TLS_ENABLED variable to false.
|
||||
REDIS_TLS_ENABLED=false
|
||||
# Redis username. Only required if not using the default user.
|
||||
REDIS_USER=notdefaultuser
|
||||
|
||||
|
||||
# --- Storage Settings ---
|
||||
|
||||
18
README.md
18
README.md
@@ -11,7 +11,7 @@
|
||||
|
||||
Open Archiver provides a robust, self-hosted solution for archiving, storing, indexing, and searching emails from major platforms, including Google Workspace (Gmail), Microsoft 365, PST files, as well as generic IMAP-enabled email inboxes. Use Open Archiver to keep a permanent, tamper-proof record of your communication history, free from vendor lock-in.
|
||||
|
||||
## 📸 Screenshots
|
||||
## Screenshots
|
||||
|
||||

|
||||
_Dashboard_
|
||||
@@ -22,9 +22,9 @@ _Archived emails_
|
||||

|
||||
_Full-text search across all your emails and attachments_
|
||||
|
||||
## 👨👩👧👦 Join our community!
|
||||
## Join our community!
|
||||
|
||||
We are committed to build an engaging community around Open Archiver, and we are inviting all of you to join our community on Discord to get real-time support and connect with the team.
|
||||
We are committed to building an engaging community around Open Archiver, and we are inviting all of you to join our community on Discord to get real-time support and connect with the team.
|
||||
|
||||
[](https://discord.gg/MTtD7BhuTQ)
|
||||
|
||||
@@ -34,11 +34,11 @@ We are committed to build an engaging community around Open Archiver, and we are
|
||||
|
||||
Check out the live demo here: https://demo.openarchiver.com
|
||||
|
||||
Username: admin@local.com
|
||||
Username: demo@openarchiver.com
|
||||
|
||||
Password: openarchiver_demo
|
||||
|
||||
## ✨ Key Features
|
||||
## Key Features
|
||||
|
||||
- **Universal Ingestion**: Connect to any email provider to perform initial bulk imports and maintain continuous, real-time synchronization. Ingestion sources include:
|
||||
- IMAP connection
|
||||
@@ -57,7 +57,7 @@ Password: openarchiver_demo
|
||||
- - Each archived email comes with an "Integrity Report" feature that indicates if the files are original.
|
||||
- **Comprehensive Auditing**: An immutable audit trail logs all system activities, ensuring you have a clear record of who accessed what and when.
|
||||
|
||||
## 🛠️ Tech Stack
|
||||
## Tech Stack
|
||||
|
||||
Open Archiver is built on a modern, scalable, and maintainable technology stack:
|
||||
|
||||
@@ -68,7 +68,7 @@ Open Archiver is built on a modern, scalable, and maintainable technology stack:
|
||||
- **Database**: PostgreSQL for metadata, user management, and audit logs
|
||||
- **Deployment**: Docker Compose deployment
|
||||
|
||||
## 📦 Deployment
|
||||
## Deployment
|
||||
|
||||
### Prerequisites
|
||||
|
||||
@@ -104,7 +104,7 @@ Open Archiver is built on a modern, scalable, and maintainable technology stack:
|
||||
4. **Access the application:**
|
||||
Once the services are running, you can access the Open Archiver web interface by navigating to `http://localhost:3000` in your web browser.
|
||||
|
||||
## ⚙️ Data Source Configuration
|
||||
## Data Source Configuration
|
||||
|
||||
After deploying the application, you will need to configure one or more ingestion sources to begin archiving emails. Follow our detailed guides to connect to your email provider:
|
||||
|
||||
@@ -112,7 +112,7 @@ After deploying the application, you will need to configure one or more ingestio
|
||||
- [Connecting to Microsoft 365](https://docs.openarchiver.com/user-guides/email-providers/imap.html)
|
||||
- [Connecting to a Generic IMAP Server](https://docs.openarchiver.com/user-guides/email-providers/imap.html)
|
||||
|
||||
## 🤝 Contributing
|
||||
## Contributing
|
||||
|
||||
We welcome contributions from the community!
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ services:
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MEILI_MASTER_KEY: ${MEILI_MASTER_KEY:-aSampleMasterKey}
|
||||
MEILI_SCHEDULE_SNAPSHOT: ${MEILI_SCHEDULE_SNAPSHOT:-86400}
|
||||
volumes:
|
||||
- meilidata:/meili_data
|
||||
networks:
|
||||
|
||||
@@ -19,11 +19,45 @@ The request body should be a `CreateIngestionSourceDto` object.
|
||||
```typescript
|
||||
interface CreateIngestionSourceDto {
|
||||
name: string;
|
||||
provider: 'google' | 'microsoft' | 'generic_imap';
|
||||
provider: 'google_workspace' | 'microsoft_365' | 'generic_imap' | 'pst_import' | 'eml_import' | 'mbox_import';
|
||||
providerConfig: IngestionCredentials;
|
||||
}
|
||||
```
|
||||
|
||||
#### Example: Creating an Mbox Import Source with File Upload
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "My Mbox Import",
|
||||
"provider": "mbox_import",
|
||||
"providerConfig": {
|
||||
"type": "mbox_import",
|
||||
"uploadedFileName": "emails.mbox",
|
||||
"uploadedFilePath": "open-archiver/tmp/uuid-emails.mbox"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Example: Creating an Mbox Import Source with Local File Path
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "My Mbox Import",
|
||||
"provider": "mbox_import",
|
||||
"providerConfig": {
|
||||
"type": "mbox_import",
|
||||
"localFilePath": "/path/to/emails.mbox"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** When using `localFilePath`, the file will not be deleted after import. When using `uploadedFilePath` (via the upload API), the file will be automatically deleted after import. The same applies to `pst_import` and `eml_import` providers.
|
||||
|
||||
**Important regarding `localFilePath`:** When running OpenArchiver in a Docker container (which is the standard deployment), `localFilePath` refers to the path **inside the Docker container**, not on the host machine.
|
||||
To use a local file:
|
||||
1. **Recommended:** Place your file inside the directory defined by `STORAGE_LOCAL_ROOT_PATH` (e.g., inside a `temp` folder). Since this directory is already mounted as a volume, the file will be accessible at the same path inside the container.
|
||||
2. **Alternative:** Mount a specific directory containing your files as a volume in `docker-compose.yml`. For example, add `- /path/to/my/files:/imports` to the `volumes` section and use `/imports/myfile.pst` as the `localFilePath`.
|
||||
|
||||
#### Responses
|
||||
|
||||
- **201 Created:** The newly created ingestion source.
|
||||
|
||||
@@ -30,7 +30,14 @@ archive.zip
|
||||
2. Click the **Create New** button.
|
||||
3. Select **EML Import** as the provider.
|
||||
4. Enter a name for the ingestion source.
|
||||
5. Click the **Choose File** button and select the zip archive containing your EML files.
|
||||
5. **Choose Import Method:**
|
||||
* **Upload File:** Click **Choose File** and select the zip archive containing your EML files. (Best for smaller archives)
|
||||
* **Local Path:** Enter the path to the zip file **inside the container**. (Best for large archives)
|
||||
|
||||
> **Note on Local Path:** When using Docker, the "Local Path" is relative to the container's filesystem.
|
||||
> * **Recommended:** Place your zip file in a `temp` folder inside your configured storage directory (`STORAGE_LOCAL_ROOT_PATH`). This path is already mounted. For example, if your storage path is `/data`, put the file in `/data/temp/emails.zip` and enter `/data/temp/emails.zip` as the path.
|
||||
> * **Alternative:** Mount a separate volume in `docker-compose.yml` (e.g., `- /host/path:/container/path`) and use the container path.
|
||||
|
||||
6. Click the **Submit** button.
|
||||
|
||||
OpenArchiver will then start importing the EML files from the zip archive. The ingestion process may take some time, depending on the size of the archive.
|
||||
|
||||
@@ -17,7 +17,13 @@ Once you have your `.mbox` file, you can upload it to OpenArchiver through the w
|
||||
1. Navigate to the **Ingestion** page.
|
||||
2. Click on the **New Ingestion** button.
|
||||
3. Select **Mbox** as the source type.
|
||||
4. Upload your `.mbox` file.
|
||||
4. **Choose Import Method:**
|
||||
* **Upload File:** Upload your `.mbox` file.
|
||||
* **Local Path:** Enter the path to the mbox file **inside the container**.
|
||||
|
||||
> **Note on Local Path:** When using Docker, the "Local Path" is relative to the container's filesystem.
|
||||
> * **Recommended:** Place your mbox file in a `temp` folder inside your configured storage directory (`STORAGE_LOCAL_ROOT_PATH`). This path is already mounted. For example, if your storage path is `/data`, put the file in `/data/temp/emails.mbox` and enter `/data/temp/emails.mbox` as the path.
|
||||
> * **Alternative:** Mount a separate volume in `docker-compose.yml` (e.g., `- /host/path:/container/path`) and use the container path.
|
||||
|
||||
## 3. Folder Structure
|
||||
|
||||
|
||||
@@ -15,7 +15,14 @@ To ensure a successful import, you should prepare your PST file according to the
|
||||
2. Click the **Create New** button.
|
||||
3. Select **PST Import** as the provider.
|
||||
4. Enter a name for the ingestion source.
|
||||
5. Click the **Choose File** button and select the PST file.
|
||||
5. **Choose Import Method:**
|
||||
* **Upload File:** Click **Choose File** and select the PST file from your computer. (Best for smaller files)
|
||||
* **Local Path:** Enter the path to the PST file **inside the container**. (Best for large files)
|
||||
|
||||
> **Note on Local Path:** When using Docker, the "Local Path" is relative to the container's filesystem.
|
||||
> * **Recommended:** Place your file in a `temp` folder inside your configured storage directory (`STORAGE_LOCAL_ROOT_PATH`). This path is already mounted. For example, if your storage path is `/data`, put the file in `/data/temp/archive.pst` and enter `/data/temp/archive.pst` as the path.
|
||||
> * **Alternative:** Mount a separate volume in `docker-compose.yml` (e.g., `- /host/path:/container/path`) and use the container path.
|
||||
|
||||
6. Click the **Submit** button.
|
||||
|
||||
OpenArchiver will then start importing the emails from the PST file. The ingestion process may take some time, depending on the size of the file.
|
||||
|
||||
@@ -115,6 +115,7 @@ These variables are used by `docker-compose.yml` to configure the services.
|
||||
| `MEILI_INDEXING_BATCH` | The number of emails to batch together for indexing. | `500` |
|
||||
| `REDIS_HOST` | The host for the Valkey (Redis) service. | `valkey` |
|
||||
| `REDIS_PORT` | The port for the Valkey (Redis) service. | `6379` |
|
||||
| `REDIS_USER` | Optional Redis username if ACLs are used. | |
|
||||
| `REDIS_PASSWORD` | The password for the Valkey (Redis) service. | `defaultredispassword` |
|
||||
| `REDIS_TLS_ENABLED` | Enable or disable TLS for Redis. | `false` |
|
||||
|
||||
|
||||
@@ -4,9 +4,57 @@ Meilisearch, the search engine used by Open Archiver, requires a manual data mig
|
||||
|
||||
If an Open Archiver upgrade includes a major Meilisearch version change, you will need to migrate your search index by following the process below.
|
||||
|
||||
## Migration Process Overview
|
||||
## Experimental: Dumpless Upgrade
|
||||
|
||||
For self-hosted instances using Docker Compose (as recommended), the migration process involves creating a data dump from your current Meilisearch instance, upgrading the Docker image, and then importing that dump into the new version.
|
||||
> **Warning:** This feature is currently **experimental**. We do not recommend using it for production environments until it is marked as stable. Please use the [standard migration process](#standard-migration-process-recommended) instead. Proceed with caution.
|
||||
|
||||
Meilisearch recently introduced an experimental "dumpless" upgrade method. This allows you to migrate the database to a new Meilisearch version without manually creating and importing a dump. However, please note that **dumpless upgrades are not currently atomic**. If the process fails, your database may become corrupted, resulting in data loss.
|
||||
|
||||
**Prerequisite: Create a Snapshot**
|
||||
|
||||
Before attempting a dumpless upgrade, you **must** take a snapshot of your instance. This ensures you have a recovery point if the upgrade fails. Learn how to create snapshots in the [official Meilisearch documentation](https://www.meilisearch.com/docs/learn/data_backup/snapshots).
|
||||
|
||||
### How to Enable
|
||||
|
||||
To perform a dumpless upgrade, you need to configure your Meilisearch instance with the experimental flag. You can do this in one of two ways:
|
||||
|
||||
**Option 1: Using an Environment Variable**
|
||||
|
||||
Add the `MEILI_EXPERIMENTAL_DUMPLESS_UPGRADE` environment variable to your `docker-compose.yml` file for the Meilisearch service.
|
||||
|
||||
```yaml
|
||||
services:
|
||||
meilisearch:
|
||||
image: getmeili/meilisearch:v1.x # The new version you want to upgrade to
|
||||
environment:
|
||||
- MEILI_MASTER_KEY=${MEILI_MASTER_KEY}
|
||||
- MEILI_EXPERIMENTAL_DUMPLESS_UPGRADE=true
|
||||
```
|
||||
|
||||
**Option 2: Using a CLI Option**
|
||||
|
||||
Alternatively, you can pass the `--experimental-dumpless-upgrade` flag in the command section of your `docker-compose.yml`.
|
||||
|
||||
```yaml
|
||||
services:
|
||||
meilisearch:
|
||||
image: getmeili/meilisearch:v1.x # The new version you want to upgrade to
|
||||
command: meilisearch --experimental-dumpless-upgrade
|
||||
```
|
||||
|
||||
After updating your configuration, restart your container:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Meilisearch will attempt to migrate your database to the new version automatically.
|
||||
|
||||
---
|
||||
|
||||
## Standard Migration Process (Recommended)
|
||||
|
||||
For self-hosted instances using Docker Compose, the recommended migration process involves creating a data dump from your current Meilisearch instance, upgrading the Docker image, and then importing that dump into the new version.
|
||||
|
||||
### Step 1: Create a Dump
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ services:
|
||||
- MEILI_HOST=http://meilisearch:7700
|
||||
- REDIS_HOST=valkey
|
||||
- REDIS_PORT=6379
|
||||
- REDIS_USER=default
|
||||
- REDIS_PASSWORD=${SERVICE_PASSWORD_VALKEY}
|
||||
- REDIS_TLS_ENABLED=false
|
||||
- STORAGE_TYPE=${STORAGE_TYPE:-local}
|
||||
@@ -73,5 +74,6 @@ services:
|
||||
image: getmeili/meilisearch:v1.15
|
||||
environment:
|
||||
- MEILI_MASTER_KEY=${SERVICE_PASSWORD_MEILISEARCH}
|
||||
- MEILI_SCHEDULE_SNAPSHOT=86400
|
||||
volumes:
|
||||
- meilidata:/meili_data
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "open-archiver",
|
||||
"version": "0.4.0",
|
||||
"version": "0.4.2",
|
||||
"private": true,
|
||||
"license": "SEE LICENSE IN LICENSE file",
|
||||
"scripts": {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Request, Response } from 'express';
|
||||
import { ApiKeyService } from '../../services/ApiKeyService';
|
||||
import { z } from 'zod';
|
||||
import { UserService } from '../../services/UserService';
|
||||
import { config } from '../../config';
|
||||
|
||||
const generateApiKeySchema = z.object({
|
||||
name: z
|
||||
@@ -16,8 +17,11 @@ const generateApiKeySchema = z.object({
|
||||
});
|
||||
export class ApiKeyController {
|
||||
private userService = new UserService();
|
||||
public async generateApiKey(req: Request, res: Response) {
|
||||
public generateApiKey = async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (config.app.isDemo) {
|
||||
return res.status(403).json({ message: req.t('errors.demoMode') });
|
||||
}
|
||||
const { name, expiresInDays } = generateApiKeySchema.parse(req.body);
|
||||
if (!req.user || !req.user.sub) {
|
||||
return res.status(401).json({ message: 'Unauthorized' });
|
||||
@@ -45,9 +49,9 @@ export class ApiKeyController {
|
||||
}
|
||||
res.status(500).json({ message: req.t('errors.internalServerError') });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
public async getApiKeys(req: Request, res: Response) {
|
||||
public getApiKeys = async (req: Request, res: Response) => {
|
||||
if (!req.user || !req.user.sub) {
|
||||
return res.status(401).json({ message: 'Unauthorized' });
|
||||
}
|
||||
@@ -55,9 +59,12 @@ export class ApiKeyController {
|
||||
const keys = await ApiKeyService.getKeys(userId);
|
||||
|
||||
res.status(200).json(keys);
|
||||
}
|
||||
};
|
||||
|
||||
public async deleteApiKey(req: Request, res: Response) {
|
||||
public deleteApiKey = async (req: Request, res: Response) => {
|
||||
if (config.app.isDemo) {
|
||||
return res.status(403).json({ message: req.t('errors.demoMode') });
|
||||
}
|
||||
const { id } = req.params;
|
||||
if (!req.user || !req.user.sub) {
|
||||
return res.status(401).json({ message: 'Unauthorized' });
|
||||
@@ -70,5 +77,5 @@ export class ApiKeyController {
|
||||
await ApiKeyService.deleteKey(id, userId, actor, req.ip || 'unknown');
|
||||
|
||||
res.status(204).send({ message: req.t('apiKeys.deleteSuccess') });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { UserService } from '../../services/UserService';
|
||||
import * as schema from '../../database/schema';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { db } from '../../database';
|
||||
import { config } from '../../config';
|
||||
|
||||
const userService = new UserService();
|
||||
|
||||
@@ -79,3 +80,66 @@ export const deleteUser = async (req: Request, res: Response) => {
|
||||
await userService.deleteUser(req.params.id, actor, req.ip || 'unknown');
|
||||
res.status(204).send();
|
||||
};
|
||||
|
||||
export const getProfile = async (req: Request, res: Response) => {
|
||||
if (!req.user || !req.user.sub) {
|
||||
return res.status(401).json({ message: 'Unauthorized' });
|
||||
}
|
||||
const user = await userService.findById(req.user.sub);
|
||||
if (!user) {
|
||||
return res.status(404).json({ message: req.t('user.notFound') });
|
||||
}
|
||||
res.json(user);
|
||||
};
|
||||
|
||||
export const updateProfile = async (req: Request, res: Response) => {
|
||||
if (config.app.isDemo) {
|
||||
return res.status(403).json({ message: req.t('errors.demoMode') });
|
||||
}
|
||||
const { email, first_name, last_name } = req.body;
|
||||
if (!req.user || !req.user.sub) {
|
||||
return res.status(401).json({ message: 'Unauthorized' });
|
||||
}
|
||||
const actor = await userService.findById(req.user.sub);
|
||||
if (!actor) {
|
||||
return res.status(401).json({ message: 'Unauthorized' });
|
||||
}
|
||||
const updatedUser = await userService.updateUser(
|
||||
req.user.sub,
|
||||
{ email, first_name, last_name },
|
||||
undefined,
|
||||
actor,
|
||||
req.ip || 'unknown'
|
||||
);
|
||||
res.json(updatedUser);
|
||||
};
|
||||
|
||||
export const updatePassword = async (req: Request, res: Response) => {
|
||||
if (config.app.isDemo) {
|
||||
return res.status(403).json({ message: req.t('errors.demoMode') });
|
||||
}
|
||||
const { currentPassword, newPassword } = req.body;
|
||||
if (!req.user || !req.user.sub) {
|
||||
return res.status(401).json({ message: 'Unauthorized' });
|
||||
}
|
||||
const actor = await userService.findById(req.user.sub);
|
||||
if (!actor) {
|
||||
return res.status(401).json({ message: 'Unauthorized' });
|
||||
}
|
||||
|
||||
try {
|
||||
await userService.updatePassword(
|
||||
req.user.sub,
|
||||
currentPassword,
|
||||
newPassword,
|
||||
actor,
|
||||
req.ip || 'unknown'
|
||||
);
|
||||
res.status(200).json({ message: 'Password updated successfully' });
|
||||
} catch (e: any) {
|
||||
if (e.message === 'Invalid current password') {
|
||||
return res.status(400).json({ message: e.message });
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -11,6 +11,10 @@ export const createUserRouter = (authService: AuthService): Router => {
|
||||
|
||||
router.get('/', requirePermission('read', 'users'), userController.getUsers);
|
||||
|
||||
router.get('/profile', userController.getProfile);
|
||||
router.patch('/profile', userController.updateProfile);
|
||||
router.post('/profile/password', userController.updatePassword);
|
||||
|
||||
router.get('/:id', requirePermission('read', 'users'), userController.getUser);
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,4 +7,5 @@ export const app = {
|
||||
syncFrequency: process.env.SYNC_FREQUENCY || '* * * * *', //default to 1 minute
|
||||
enableDeletion: process.env.ENABLE_DELETION === 'true',
|
||||
allInclusiveArchive: process.env.ALL_INCLUSIVE_ARCHIVE === 'true',
|
||||
isDemo: process.env.IS_DEMO === 'true',
|
||||
};
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import 'dotenv/config';
|
||||
import { type ConnectionOptions } from 'bullmq';
|
||||
|
||||
/**
|
||||
* @see https://github.com/taskforcesh/bullmq/blob/master/docs/gitbook/guide/connections.md
|
||||
*/
|
||||
const connectionOptions: any = {
|
||||
const connectionOptions: ConnectionOptions = {
|
||||
host: process.env.REDIS_HOST || 'localhost',
|
||||
port: (process.env.REDIS_PORT && parseInt(process.env.REDIS_PORT, 10)) || 6379,
|
||||
password: process.env.REDIS_PASSWORD,
|
||||
enableReadyCheck: true,
|
||||
};
|
||||
|
||||
if (process.env.REDIS_USER) {
|
||||
connectionOptions.username = process.env.REDIS_USER;
|
||||
}
|
||||
|
||||
if (process.env.REDIS_TLS_ENABLED === 'true') {
|
||||
connectionOptions.tls = {
|
||||
rejectUnauthorized: false,
|
||||
|
||||
@@ -43,7 +43,16 @@ export const processMailboxProcessor = async (job: Job<IProcessMailboxJob, SyncS
|
||||
const connector = EmailProviderFactory.createConnector(source);
|
||||
const ingestionService = new IngestionService();
|
||||
|
||||
for await (const email of connector.fetchEmails(userEmail, source.syncState)) {
|
||||
// Create a callback to check for duplicates without fetching full email content
|
||||
const checkDuplicate = async (messageId: string) => {
|
||||
return await IngestionService.doesEmailExist(messageId, ingestionSourceId);
|
||||
};
|
||||
|
||||
for await (const email of connector.fetchEmails(
|
||||
userEmail,
|
||||
source.syncState,
|
||||
checkDuplicate
|
||||
)) {
|
||||
if (email) {
|
||||
const processedEmail = await ingestionService.processEmail(
|
||||
email,
|
||||
|
||||
69
packages/backend/src/locales/bg/translation.json
Normal file
69
packages/backend/src/locales/bg/translation.json
Normal file
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"auth": {
|
||||
"setup": {
|
||||
"allFieldsRequired": "Изискват се поща, парола и име",
|
||||
"alreadyCompleted": "Настройката вече е завършена."
|
||||
},
|
||||
"login": {
|
||||
"emailAndPasswordRequired": "Изискват се поща и парола",
|
||||
"invalidCredentials": "Невалидни идентификационни данни"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"internalServerError": "Възникна вътрешна грешка в сървъра",
|
||||
"demoMode": "Тази операция не е разрешена в демо режим",
|
||||
"unauthorized": "Неоторизирано",
|
||||
"unknown": "Възникна неизвестна грешка",
|
||||
"noPermissionToAction": "Нямате разрешение да извършите текущото действие."
|
||||
},
|
||||
"user": {
|
||||
"notFound": "Потребителят не е открит",
|
||||
"cannotDeleteOnlyUser": "Опитвате се да изтриете единствения потребител в базата данни, това не е позволено.",
|
||||
"requiresSuperAdminRole": "За управление на потребители е необходима роля на супер администратор."
|
||||
},
|
||||
"iam": {
|
||||
"failedToGetRoles": "Неуспешно получаване на роли.",
|
||||
"roleNotFound": "Ролята не е намерена.",
|
||||
"failedToGetRole": "Неуспешно получаване на роля.",
|
||||
"missingRoleFields": "Липсват задължителни полета: име и политика.",
|
||||
"invalidPolicy": "Невалидно твърдение за политика:",
|
||||
"failedToCreateRole": "Създаването на роля неуспешно.",
|
||||
"failedToDeleteRole": "Изтриването на роля неуспешно.",
|
||||
"missingUpdateFields": "Липсват полета за актуализиране: име или политики.",
|
||||
"failedToUpdateRole": "Актуализирането на ролята неуспешно.",
|
||||
"requiresSuperAdminRole": "За управление на роли е необходима роля на супер администратор."
|
||||
},
|
||||
"settings": {
|
||||
"failedToRetrieve": "Неуспешно извличане на настройките",
|
||||
"failedToUpdate": "Неуспешно актуализиране на настройките",
|
||||
"noPermissionToUpdate": "Нямате разрешение да актуализирате системните настройки."
|
||||
},
|
||||
"dashboard": {
|
||||
"permissionRequired": "Необходимо ви е разрешение за четене на таблото, за да видите данните от него."
|
||||
},
|
||||
"ingestion": {
|
||||
"failedToCreate": "Създаването на източник за приемане не бе успешно поради грешка при свързване.",
|
||||
"notFound": "Източникът за приемане не е намерен",
|
||||
"initialImportTriggered": "Първоначалният импорт е задействан успешно.",
|
||||
"forceSyncTriggered": "Принудителното синхронизиране е задействано успешно."
|
||||
},
|
||||
"archivedEmail": {
|
||||
"notFound": "Архивираната поща не е намерена"
|
||||
},
|
||||
"search": {
|
||||
"keywordsRequired": "Ключовите думи са задължителни"
|
||||
},
|
||||
"storage": {
|
||||
"filePathRequired": "Пътят към файла е задължителен",
|
||||
"invalidFilePath": "Невалиден път към файла",
|
||||
"fileNotFound": "Файлът не е намерен",
|
||||
"downloadError": "Грешка при изтегляне на файла"
|
||||
},
|
||||
"apiKeys": {
|
||||
"generateSuccess": "API ключът е генериран успешно.",
|
||||
"deleteSuccess": "API ключът е успешно изтрит."
|
||||
},
|
||||
"api": {
|
||||
"requestBodyInvalid": "Невалидно съдържание на заявката."
|
||||
}
|
||||
}
|
||||
@@ -20,16 +20,17 @@ export class ApiKeyService {
|
||||
expiresAt.setDate(expiresAt.getDate() + expiresInDays);
|
||||
const keyHash = createHash('sha256').update(key).digest('hex');
|
||||
|
||||
await db.insert(apiKeys).values({
|
||||
userId,
|
||||
name,
|
||||
key: CryptoService.encrypt(key),
|
||||
keyHash,
|
||||
expiresAt,
|
||||
});
|
||||
try {
|
||||
await db.insert(apiKeys).values({
|
||||
userId,
|
||||
name,
|
||||
key: CryptoService.encrypt(key),
|
||||
keyHash,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
await this.auditService.createAuditLog({
|
||||
actorIdentifier: actor.id,
|
||||
await this.auditService.createAuditLog({
|
||||
actorIdentifier: actor.id,
|
||||
actionType: 'GENERATE',
|
||||
targetType: 'ApiKey',
|
||||
targetId: name,
|
||||
@@ -40,6 +41,9 @@ export class ApiKeyService {
|
||||
});
|
||||
|
||||
return key;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public static async getKeys(userId: string): Promise<ApiKey[]> {
|
||||
|
||||
@@ -22,7 +22,8 @@ export interface IEmailConnector {
|
||||
testConnection(): Promise<boolean>;
|
||||
fetchEmails(
|
||||
userEmail: string,
|
||||
syncState?: SyncState | null
|
||||
syncState?: SyncState | null,
|
||||
checkDuplicate?: (messageId: string) => Promise<boolean>
|
||||
): AsyncGenerator<EmailObject | null>;
|
||||
getUpdatedSyncState(userEmail?: string): SyncState;
|
||||
listAllUsers(): AsyncGenerator<MailboxUser>;
|
||||
|
||||
@@ -85,7 +85,7 @@ export class IngestionService {
|
||||
|
||||
const decryptedSource = this.decryptSource(newSource);
|
||||
if (!decryptedSource) {
|
||||
await this.delete(newSource.id, actor, actorIp);
|
||||
await this.delete(newSource.id, actor, actorIp, true);
|
||||
throw new Error(
|
||||
'Failed to process newly created ingestion source due to a decryption error.'
|
||||
);
|
||||
@@ -107,7 +107,7 @@ export class IngestionService {
|
||||
}
|
||||
} catch (error) {
|
||||
// If connection fails, delete the newly created source and throw the error.
|
||||
await this.delete(decryptedSource.id, actor, actorIp);
|
||||
await this.delete(decryptedSource.id, actor, actorIp, true);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -205,8 +205,15 @@ export class IngestionService {
|
||||
return decryptedSource;
|
||||
}
|
||||
|
||||
public static async delete(id: string, actor: User, actorIp: string): Promise<IngestionSource> {
|
||||
checkDeletionEnabled();
|
||||
public static async delete(
|
||||
id: string,
|
||||
actor: User,
|
||||
actorIp: string,
|
||||
force: boolean = false
|
||||
): Promise<IngestionSource> {
|
||||
if (!force) {
|
||||
checkDeletionEnabled();
|
||||
}
|
||||
const source = await this.findById(id);
|
||||
if (!source) {
|
||||
throw new Error('Ingestion source not found');
|
||||
@@ -219,7 +226,8 @@ export class IngestionService {
|
||||
|
||||
if (
|
||||
(source.credentials.type === 'pst_import' ||
|
||||
source.credentials.type === 'eml_import') &&
|
||||
source.credentials.type === 'eml_import' ||
|
||||
source.credentials.type === 'mbox_import') &&
|
||||
source.credentials.uploadedFilePath &&
|
||||
(await storage.exists(source.credentials.uploadedFilePath))
|
||||
) {
|
||||
@@ -382,6 +390,25 @@ export class IngestionService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Quickly checks if an email exists in the database by its Message-ID header.
|
||||
* This is used to skip downloading duplicate emails during ingestion.
|
||||
*/
|
||||
public static async doesEmailExist(
|
||||
messageId: string,
|
||||
ingestionSourceId: string
|
||||
): Promise<boolean> {
|
||||
const existingEmail = await db.query.archivedEmails.findFirst({
|
||||
where: and(
|
||||
eq(archivedEmails.messageIdHeader, messageId),
|
||||
eq(archivedEmails.ingestionSourceId, ingestionSourceId)
|
||||
),
|
||||
columns: { id: true },
|
||||
});
|
||||
|
||||
return !!existingEmail;
|
||||
}
|
||||
|
||||
public async processEmail(
|
||||
email: EmailObject,
|
||||
source: IngestionSource,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { db } from '../database';
|
||||
import * as schema from '../database/schema';
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
import { hash } from 'bcryptjs';
|
||||
import { hash, compare } from 'bcryptjs';
|
||||
import type { CaslPolicy, User } from '@open-archiver/types';
|
||||
import { AuditService } from './AuditService';
|
||||
|
||||
@@ -152,6 +152,46 @@ export class UserService {
|
||||
});
|
||||
}
|
||||
|
||||
public async updatePassword(
|
||||
id: string,
|
||||
currentPassword: string,
|
||||
newPassword: string,
|
||||
actor: User,
|
||||
actorIp: string
|
||||
): Promise<void> {
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(schema.users.id, id),
|
||||
});
|
||||
|
||||
if (!user || !user.password) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
const isPasswordValid = await compare(currentPassword, user.password);
|
||||
|
||||
if (!isPasswordValid) {
|
||||
throw new Error('Invalid current password');
|
||||
}
|
||||
|
||||
const hashedPassword = await hash(newPassword, 10);
|
||||
|
||||
await db
|
||||
.update(schema.users)
|
||||
.set({ password: hashedPassword })
|
||||
.where(eq(schema.users.id, id));
|
||||
|
||||
await UserService.auditService.createAuditLog({
|
||||
actorIdentifier: actor.id,
|
||||
actionType: 'UPDATE',
|
||||
targetType: 'User',
|
||||
targetId: id,
|
||||
actorIp,
|
||||
details: {
|
||||
field: 'password',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an admin user in the database. The user created will be assigned the 'Super Admin' role.
|
||||
*
|
||||
|
||||
@@ -32,29 +32,72 @@ export class EMLConnector implements IEmailConnector {
|
||||
this.storage = new StorageService();
|
||||
}
|
||||
|
||||
private getFilePath(): string {
|
||||
return this.credentials.localFilePath || this.credentials.uploadedFilePath || '';
|
||||
}
|
||||
|
||||
private getDisplayName(): string {
|
||||
if (this.credentials.uploadedFileName) {
|
||||
return this.credentials.uploadedFileName;
|
||||
}
|
||||
if (this.credentials.localFilePath) {
|
||||
const parts = this.credentials.localFilePath.split('/');
|
||||
return parts[parts.length - 1].replace('.zip', '');
|
||||
}
|
||||
return `eml-import-${new Date().getTime()}`;
|
||||
}
|
||||
|
||||
private async getFileStream(): Promise<NodeJS.ReadableStream> {
|
||||
if (this.credentials.localFilePath) {
|
||||
return createReadStream(this.credentials.localFilePath);
|
||||
}
|
||||
return this.storage.get(this.getFilePath());
|
||||
}
|
||||
|
||||
public async testConnection(): Promise<boolean> {
|
||||
try {
|
||||
if (!this.credentials.uploadedFilePath) {
|
||||
throw Error('EML file path not provided.');
|
||||
const filePath = this.getFilePath();
|
||||
if (!filePath) {
|
||||
throw Error('EML Zip file path not provided.');
|
||||
}
|
||||
if (!this.credentials.uploadedFilePath.includes('.zip')) {
|
||||
if (!filePath.includes('.zip')) {
|
||||
throw Error('Provided file is not in the ZIP format.');
|
||||
}
|
||||
const fileExist = await this.storage.exists(this.credentials.uploadedFilePath);
|
||||
|
||||
let fileExist = false;
|
||||
if (this.credentials.localFilePath) {
|
||||
try {
|
||||
await fs.access(this.credentials.localFilePath);
|
||||
fileExist = true;
|
||||
} catch {
|
||||
fileExist = false;
|
||||
}
|
||||
} else {
|
||||
fileExist = await this.storage.exists(filePath);
|
||||
}
|
||||
|
||||
if (!fileExist) {
|
||||
throw Error('EML file upload not finished yet, please wait.');
|
||||
if (this.credentials.localFilePath) {
|
||||
throw Error(`EML Zip file not found at path: ${this.credentials.localFilePath}`);
|
||||
} else {
|
||||
throw Error(
|
||||
'Uploaded EML Zip file not found. The upload may not have finished yet, or it failed.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error({ error, credentials: this.credentials }, 'EML file validation failed.');
|
||||
logger.error(
|
||||
{ error, credentials: this.credentials },
|
||||
'EML Zip file validation failed.'
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async *listAllUsers(): AsyncGenerator<MailboxUser> {
|
||||
const displayName =
|
||||
this.credentials.uploadedFileName || `eml-import-${new Date().getTime()}`;
|
||||
const displayName = this.getDisplayName();
|
||||
logger.info(`Found potential mailbox: ${displayName}`);
|
||||
const constructedPrimaryEmail = `${displayName.replace(/ /g, '.').toLowerCase()}@eml.local`;
|
||||
yield {
|
||||
@@ -68,10 +111,8 @@ export class EMLConnector implements IEmailConnector {
|
||||
userEmail: string,
|
||||
syncState?: SyncState | null
|
||||
): AsyncGenerator<EmailObject | null> {
|
||||
const fileStream = await this.storage.get(this.credentials.uploadedFilePath);
|
||||
const fileStream = await this.getFileStream();
|
||||
const tempDir = await fs.mkdtemp(join('/tmp', `eml-import-${new Date().getTime()}`));
|
||||
const unzippedPath = join(tempDir, 'unzipped');
|
||||
await fs.mkdir(unzippedPath);
|
||||
const zipFilePath = join(tempDir, 'eml.zip');
|
||||
|
||||
try {
|
||||
@@ -82,99 +123,150 @@ export class EMLConnector implements IEmailConnector {
|
||||
dest.on('error', reject);
|
||||
});
|
||||
|
||||
await this.extract(zipFilePath, unzippedPath);
|
||||
|
||||
const files = await this.getAllFiles(unzippedPath);
|
||||
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.eml')) {
|
||||
try {
|
||||
// logger.info({ file }, 'Processing EML file.');
|
||||
const stream = createReadStream(file);
|
||||
const content = await streamToBuffer(stream);
|
||||
// logger.info({ file, size: content.length }, 'Read file to buffer.');
|
||||
let relativePath = file.substring(unzippedPath.length + 1);
|
||||
if (dirname(relativePath) === '.') {
|
||||
relativePath = '';
|
||||
} else {
|
||||
relativePath = dirname(relativePath);
|
||||
}
|
||||
const emailObject = await this.parseMessage(content, relativePath);
|
||||
// logger.info({ file, messageId: emailObject.id }, 'Parsed email message.');
|
||||
yield emailObject;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ error, file },
|
||||
'Failed to process a single EML file. Skipping.'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
yield* this.processZipEntries(zipFilePath);
|
||||
} catch (error) {
|
||||
logger.error({ error }, 'Failed to fetch email.');
|
||||
throw error;
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
try {
|
||||
await this.storage.delete(this.credentials.uploadedFilePath);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ error, file: this.credentials.uploadedFilePath },
|
||||
'Failed to delete EML file after processing.'
|
||||
);
|
||||
if (this.credentials.uploadedFilePath && !this.credentials.localFilePath) {
|
||||
try {
|
||||
await this.storage.delete(this.credentials.uploadedFilePath);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ error, file: this.credentials.uploadedFilePath },
|
||||
'Failed to delete EML file after processing.'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extract(zipFilePath: string, dest: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
private async *processZipEntries(zipFilePath: string): AsyncGenerator<EmailObject | null> {
|
||||
// Open the ZIP file.
|
||||
// Note: yauzl requires random access, so we must use the file on disk.
|
||||
const zipfile = await new Promise<yauzl.ZipFile>((resolve, reject) => {
|
||||
yauzl.open(zipFilePath, { lazyEntries: true, decodeStrings: false }, (err, zipfile) => {
|
||||
if (err) reject(err);
|
||||
zipfile.on('error', reject);
|
||||
zipfile.readEntry();
|
||||
zipfile.on('entry', (entry) => {
|
||||
const fileName = entry.fileName.toString('utf8');
|
||||
// Ignore macOS-specific metadata files.
|
||||
if (fileName.startsWith('__MACOSX/')) {
|
||||
zipfile.readEntry();
|
||||
return;
|
||||
}
|
||||
const entryPath = join(dest, fileName);
|
||||
if (/\/$/.test(fileName)) {
|
||||
fs.mkdir(entryPath, { recursive: true })
|
||||
.then(() => zipfile.readEntry())
|
||||
.catch(reject);
|
||||
} else {
|
||||
zipfile.openReadStream(entry, (err, readStream) => {
|
||||
if (err) reject(err);
|
||||
const writeStream = createWriteStream(entryPath);
|
||||
readStream.pipe(writeStream);
|
||||
writeStream.on('finish', () => zipfile.readEntry());
|
||||
writeStream.on('error', reject);
|
||||
});
|
||||
}
|
||||
});
|
||||
zipfile.on('end', () => resolve());
|
||||
if (err || !zipfile) return reject(err);
|
||||
resolve(zipfile);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async getAllFiles(dirPath: string, arrayOfFiles: string[] = []): Promise<string[]> {
|
||||
const files = await fs.readdir(dirPath);
|
||||
// Create an async iterator for zip entries
|
||||
const entryIterator = this.zipEntryGenerator(zipfile);
|
||||
|
||||
for (const file of files) {
|
||||
const fullPath = join(dirPath, file);
|
||||
if ((await fs.stat(fullPath)).isDirectory()) {
|
||||
await this.getAllFiles(fullPath, arrayOfFiles);
|
||||
} else {
|
||||
arrayOfFiles.push(fullPath);
|
||||
for await (const { entry, openReadStream } of entryIterator) {
|
||||
const fileName = entry.fileName.toString();
|
||||
if (fileName.startsWith('__MACOSX/') || /\/$/.test(fileName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (fileName.endsWith('.eml')) {
|
||||
try {
|
||||
const readStream = await openReadStream();
|
||||
const relativePath = dirname(fileName) === '.' ? '' : dirname(fileName);
|
||||
const emailObject = await this.parseMessage(readStream, relativePath);
|
||||
yield emailObject;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ error, file: fileName },
|
||||
'Failed to process a single EML file from zip. Skipping.'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return arrayOfFiles;
|
||||
}
|
||||
|
||||
private async parseMessage(emlBuffer: Buffer, path: string): Promise<EmailObject> {
|
||||
private async *zipEntryGenerator(
|
||||
zipfile: yauzl.ZipFile
|
||||
): AsyncGenerator<{ entry: yauzl.Entry; openReadStream: () => Promise<Readable> }> {
|
||||
let resolveNext: ((value: any) => void) | null = null;
|
||||
let rejectNext: ((reason?: any) => void) | null = null;
|
||||
let finished = false;
|
||||
const queue: yauzl.Entry[] = [];
|
||||
|
||||
zipfile.readEntry();
|
||||
|
||||
zipfile.on('entry', (entry) => {
|
||||
if (resolveNext) {
|
||||
const resolve = resolveNext;
|
||||
resolveNext = null;
|
||||
rejectNext = null;
|
||||
resolve(entry);
|
||||
} else {
|
||||
queue.push(entry);
|
||||
}
|
||||
});
|
||||
|
||||
zipfile.on('end', () => {
|
||||
finished = true;
|
||||
if (resolveNext) {
|
||||
const resolve = resolveNext;
|
||||
resolveNext = null;
|
||||
rejectNext = null;
|
||||
resolve(null); // Signal end
|
||||
}
|
||||
});
|
||||
|
||||
zipfile.on('error', (err) => {
|
||||
finished = true;
|
||||
if (rejectNext) {
|
||||
const reject = rejectNext;
|
||||
resolveNext = null;
|
||||
rejectNext = null;
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
|
||||
while (!finished || queue.length > 0) {
|
||||
if (queue.length > 0) {
|
||||
const entry = queue.shift()!;
|
||||
yield {
|
||||
entry,
|
||||
openReadStream: () =>
|
||||
new Promise<Readable>((resolve, reject) => {
|
||||
zipfile.openReadStream(entry, (err, stream) => {
|
||||
if (err || !stream) return reject(err);
|
||||
resolve(stream);
|
||||
});
|
||||
}),
|
||||
};
|
||||
zipfile.readEntry(); // Read next entry only after yielding
|
||||
} else {
|
||||
const entry = await new Promise<yauzl.Entry | null>((resolve, reject) => {
|
||||
resolveNext = resolve;
|
||||
rejectNext = reject;
|
||||
});
|
||||
if (entry) {
|
||||
yield {
|
||||
entry,
|
||||
openReadStream: () =>
|
||||
new Promise<Readable>((resolve, reject) => {
|
||||
zipfile.openReadStream(entry, (err, stream) => {
|
||||
if (err || !stream) return reject(err);
|
||||
resolve(stream);
|
||||
});
|
||||
}),
|
||||
};
|
||||
zipfile.readEntry(); // Read next entry only after yielding
|
||||
} else {
|
||||
break; // End of zip
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async parseMessage(
|
||||
input: Buffer | Readable,
|
||||
path: string
|
||||
): Promise<EmailObject> {
|
||||
let emlBuffer: Buffer;
|
||||
if (Buffer.isBuffer(input)) {
|
||||
emlBuffer = input;
|
||||
} else {
|
||||
emlBuffer = await streamToBuffer(input);
|
||||
}
|
||||
|
||||
const parsedEmail: ParsedMail = await simpleParser(emlBuffer);
|
||||
|
||||
const attachments = parsedEmail.attachments.map((attachment: Attachment) => ({
|
||||
|
||||
@@ -132,7 +132,8 @@ export class GoogleWorkspaceConnector implements IEmailConnector {
|
||||
*/
|
||||
public async *fetchEmails(
|
||||
userEmail: string,
|
||||
syncState?: SyncState | null
|
||||
syncState?: SyncState | null,
|
||||
checkDuplicate?: (messageId: string) => Promise<boolean>
|
||||
): AsyncGenerator<EmailObject> {
|
||||
const authClient = this.getAuthClient(userEmail, [
|
||||
'https://www.googleapis.com/auth/gmail.readonly',
|
||||
@@ -144,7 +145,7 @@ export class GoogleWorkspaceConnector implements IEmailConnector {
|
||||
|
||||
// If no sync state is provided for this user, this is an initial import. Get all messages.
|
||||
if (!startHistoryId) {
|
||||
yield* this.fetchAllMessagesForUser(gmail, userEmail);
|
||||
yield* this.fetchAllMessagesForUser(gmail, userEmail, checkDuplicate);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -170,6 +171,16 @@ export class GoogleWorkspaceConnector implements IEmailConnector {
|
||||
if (messageAdded.message?.id) {
|
||||
try {
|
||||
const messageId = messageAdded.message.id;
|
||||
|
||||
// Optimization: Check for existence before fetching full content
|
||||
if (checkDuplicate && (await checkDuplicate(messageId))) {
|
||||
logger.debug(
|
||||
{ messageId, userEmail },
|
||||
'Skipping duplicate email (pre-check)'
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const metadataResponse = await gmail.users.messages.get({
|
||||
userId: userEmail,
|
||||
id: messageId,
|
||||
@@ -258,8 +269,17 @@ export class GoogleWorkspaceConnector implements IEmailConnector {
|
||||
|
||||
private async *fetchAllMessagesForUser(
|
||||
gmail: gmail_v1.Gmail,
|
||||
userEmail: string
|
||||
userEmail: string,
|
||||
checkDuplicate?: (messageId: string) => Promise<boolean>
|
||||
): AsyncGenerator<EmailObject> {
|
||||
// Capture the history ID at the start to ensure no emails are missed during the import process.
|
||||
// Any emails arriving during this import will be covered by the next sync starting from this point.
|
||||
// Overlaps are handled by the duplicate check.
|
||||
const profileResponse = await gmail.users.getProfile({ userId: userEmail });
|
||||
if (profileResponse.data.historyId) {
|
||||
this.newHistoryId = profileResponse.data.historyId;
|
||||
}
|
||||
|
||||
let pageToken: string | undefined = undefined;
|
||||
do {
|
||||
const listResponse: Common.GaxiosResponseWithHTTP2<gmail_v1.Schema$ListMessagesResponse> =
|
||||
@@ -277,6 +297,16 @@ export class GoogleWorkspaceConnector implements IEmailConnector {
|
||||
if (message.id) {
|
||||
try {
|
||||
const messageId = message.id;
|
||||
|
||||
// Optimization: Check for existence before fetching full content
|
||||
if (checkDuplicate && (await checkDuplicate(messageId))) {
|
||||
logger.debug(
|
||||
{ messageId, userEmail },
|
||||
'Skipping duplicate email (pre-check)'
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const metadataResponse = await gmail.users.messages.get({
|
||||
userId: userEmail,
|
||||
id: messageId,
|
||||
@@ -352,12 +382,6 @@ export class GoogleWorkspaceConnector implements IEmailConnector {
|
||||
}
|
||||
pageToken = listResponse.data.nextPageToken ?? undefined;
|
||||
} while (pageToken);
|
||||
|
||||
// After fetching all messages, get the latest history ID to use as the starting point for the next sync.
|
||||
const profileResponse = await gmail.users.getProfile({ userId: userEmail });
|
||||
if (profileResponse.data.historyId) {
|
||||
this.newHistoryId = profileResponse.data.historyId;
|
||||
}
|
||||
}
|
||||
|
||||
public getUpdatedSyncState(userEmail: string): SyncState {
|
||||
|
||||
@@ -142,7 +142,8 @@ export class ImapConnector implements IEmailConnector {
|
||||
|
||||
public async *fetchEmails(
|
||||
userEmail: string,
|
||||
syncState?: SyncState | null
|
||||
syncState?: SyncState | null,
|
||||
checkDuplicate?: (messageId: string) => Promise<boolean>
|
||||
): AsyncGenerator<EmailObject | null> {
|
||||
try {
|
||||
// list all mailboxes first
|
||||
@@ -218,6 +219,22 @@ export class ImapConnector implements IEmailConnector {
|
||||
this.newMaxUids[mailboxPath] = msg.uid;
|
||||
}
|
||||
|
||||
// Optimization: Verify existence using Message-ID from envelope before fetching full body
|
||||
if (checkDuplicate && msg.envelope?.messageId) {
|
||||
const isDuplicate = await checkDuplicate(msg.envelope.messageId);
|
||||
if (isDuplicate) {
|
||||
logger.debug(
|
||||
{
|
||||
mailboxPath,
|
||||
uid: msg.uid,
|
||||
messageId: msg.envelope.messageId,
|
||||
},
|
||||
'Skipping duplicate email (pre-check)'
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug({ mailboxPath, uid: msg.uid }, 'Processing message');
|
||||
|
||||
if (msg.envelope && msg.source) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { getThreadId } from './helpers/utils';
|
||||
import { StorageService } from '../StorageService';
|
||||
import { Readable, Transform } from 'stream';
|
||||
import { createHash } from 'crypto';
|
||||
import { promises as fs, createReadStream } from 'fs';
|
||||
|
||||
class MboxSplitter extends Transform {
|
||||
private buffer: Buffer = Buffer.alloc(0);
|
||||
@@ -60,27 +61,59 @@ export class MboxConnector implements IEmailConnector {
|
||||
|
||||
public async testConnection(): Promise<boolean> {
|
||||
try {
|
||||
if (!this.credentials.uploadedFilePath) {
|
||||
const filePath = this.getFilePath();
|
||||
if (!filePath) {
|
||||
throw Error('Mbox file path not provided.');
|
||||
}
|
||||
if (!this.credentials.uploadedFilePath.includes('.mbox')) {
|
||||
if (!filePath.includes('.mbox')) {
|
||||
throw Error('Provided file is not in the MBOX format.');
|
||||
}
|
||||
const fileExist = await this.storage.exists(this.credentials.uploadedFilePath);
|
||||
|
||||
let fileExist = false;
|
||||
if (this.credentials.localFilePath) {
|
||||
try {
|
||||
await fs.access(this.credentials.localFilePath);
|
||||
fileExist = true;
|
||||
} catch {
|
||||
fileExist = false;
|
||||
}
|
||||
} else {
|
||||
fileExist = await this.storage.exists(filePath);
|
||||
}
|
||||
|
||||
if (!fileExist) {
|
||||
throw Error('Mbox file upload not finished yet, please wait.');
|
||||
if (this.credentials.localFilePath) {
|
||||
throw Error(`Mbox file not found at path: ${this.credentials.localFilePath}`);
|
||||
} else {
|
||||
throw Error(
|
||||
'Uploaded Mbox file not found. The upload may not have finished yet, or it failed.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error({ error, credentials: this.credentials }, 'Mbox file validation failed.');
|
||||
logger.error(
|
||||
{ error, credentials: this.credentials },
|
||||
'Mbox file validation failed.'
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private getFilePath(): string {
|
||||
return this.credentials.localFilePath || this.credentials.uploadedFilePath || '';
|
||||
}
|
||||
|
||||
private async getFileStream(): Promise<NodeJS.ReadableStream> {
|
||||
if (this.credentials.localFilePath) {
|
||||
return createReadStream(this.credentials.localFilePath);
|
||||
}
|
||||
return this.storage.getStream(this.getFilePath());
|
||||
}
|
||||
|
||||
public async *listAllUsers(): AsyncGenerator<MailboxUser> {
|
||||
const displayName =
|
||||
this.credentials.uploadedFileName || `mbox-import-${new Date().getTime()}`;
|
||||
const displayName = this.getDisplayName();
|
||||
logger.info(`Found potential mailbox: ${displayName}`);
|
||||
const constructedPrimaryEmail = `${displayName.replace(/ /g, '.').toLowerCase()}@mbox.local`;
|
||||
yield {
|
||||
@@ -90,11 +123,23 @@ export class MboxConnector implements IEmailConnector {
|
||||
};
|
||||
}
|
||||
|
||||
private getDisplayName(): string {
|
||||
if (this.credentials.uploadedFileName) {
|
||||
return this.credentials.uploadedFileName;
|
||||
}
|
||||
if (this.credentials.localFilePath) {
|
||||
const parts = this.credentials.localFilePath.split('/');
|
||||
return parts[parts.length - 1].replace('.mbox', '');
|
||||
}
|
||||
return `mbox-import-${new Date().getTime()}`;
|
||||
}
|
||||
|
||||
public async *fetchEmails(
|
||||
userEmail: string,
|
||||
syncState?: SyncState | null
|
||||
): AsyncGenerator<EmailObject | null> {
|
||||
const fileStream = await this.storage.getStream(this.credentials.uploadedFilePath);
|
||||
const filePath = this.getFilePath();
|
||||
const fileStream = await this.getFileStream();
|
||||
const mboxSplitter = new MboxSplitter();
|
||||
const emailStream = fileStream.pipe(mboxSplitter);
|
||||
|
||||
@@ -104,22 +149,21 @@ export class MboxConnector implements IEmailConnector {
|
||||
yield emailObject;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ error, file: this.credentials.uploadedFilePath },
|
||||
{ error, file: filePath },
|
||||
'Failed to process a single message from mbox file. Skipping.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// After the stream is fully consumed, delete the file.
|
||||
// The `for await...of` loop ensures streams are properly closed on completion,
|
||||
// so we can safely delete the file here without causing a hang.
|
||||
try {
|
||||
await this.storage.delete(this.credentials.uploadedFilePath);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ error, file: this.credentials.uploadedFilePath },
|
||||
'Failed to delete mbox file after processing.'
|
||||
);
|
||||
if (this.credentials.uploadedFilePath && !this.credentials.localFilePath) {
|
||||
try {
|
||||
await this.storage.delete(filePath);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ error, file: filePath },
|
||||
'Failed to delete mbox file after processing.'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import { StorageService } from '../StorageService';
|
||||
import { Readable } from 'stream';
|
||||
import { createHash } from 'crypto';
|
||||
import { join } from 'path';
|
||||
import { createWriteStream, promises as fs } from 'fs';
|
||||
import { createWriteStream, createReadStream, promises as fs } from 'fs';
|
||||
|
||||
// We have to hardcode names for deleted and trash folders here as current lib doesn't support looking into PST properties.
|
||||
const DELETED_FOLDERS = new Set([
|
||||
@@ -111,8 +111,19 @@ export class PSTConnector implements IEmailConnector {
|
||||
this.storage = new StorageService();
|
||||
}
|
||||
|
||||
private getFilePath(): string {
|
||||
return this.credentials.localFilePath || this.credentials.uploadedFilePath || '';
|
||||
}
|
||||
|
||||
private async getFileStream(): Promise<NodeJS.ReadableStream> {
|
||||
if (this.credentials.localFilePath) {
|
||||
return createReadStream(this.credentials.localFilePath);
|
||||
}
|
||||
return this.storage.getStream(this.getFilePath());
|
||||
}
|
||||
|
||||
private async loadPstFile(): Promise<{ pstFile: PSTFile; tempDir: string }> {
|
||||
const fileStream = await this.storage.getStream(this.credentials.uploadedFilePath);
|
||||
const fileStream = await this.getFileStream();
|
||||
const tempDir = await fs.mkdtemp(join('/tmp', `pst-import-${new Date().getTime()}`));
|
||||
const tempFilePath = join(tempDir, 'temp.pst');
|
||||
|
||||
@@ -129,19 +140,41 @@ export class PSTConnector implements IEmailConnector {
|
||||
|
||||
public async testConnection(): Promise<boolean> {
|
||||
try {
|
||||
if (!this.credentials.uploadedFilePath) {
|
||||
const filePath = this.getFilePath();
|
||||
if (!filePath) {
|
||||
throw Error('PST file path not provided.');
|
||||
}
|
||||
if (!this.credentials.uploadedFilePath.includes('.pst')) {
|
||||
if (!filePath.includes('.pst')) {
|
||||
throw Error('Provided file is not in the PST format.');
|
||||
}
|
||||
const fileExist = await this.storage.exists(this.credentials.uploadedFilePath);
|
||||
|
||||
let fileExist = false;
|
||||
if (this.credentials.localFilePath) {
|
||||
try {
|
||||
await fs.access(this.credentials.localFilePath);
|
||||
fileExist = true;
|
||||
} catch {
|
||||
fileExist = false;
|
||||
}
|
||||
} else {
|
||||
fileExist = await this.storage.exists(filePath);
|
||||
}
|
||||
|
||||
if (!fileExist) {
|
||||
throw Error('PST file upload not finished yet, please wait.');
|
||||
if (this.credentials.localFilePath) {
|
||||
throw Error(`PST file not found at path: ${this.credentials.localFilePath}`);
|
||||
} else {
|
||||
throw Error(
|
||||
'Uploaded PST file not found. The upload may not have finished yet, or it failed.'
|
||||
);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error({ error, credentials: this.credentials }, 'PST file validation failed.');
|
||||
logger.error(
|
||||
{ error, credentials: this.credentials },
|
||||
'PST file validation failed.'
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -200,13 +233,15 @@ export class PSTConnector implements IEmailConnector {
|
||||
if (tempDir) {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
try {
|
||||
await this.storage.delete(this.credentials.uploadedFilePath);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ error, file: this.credentials.uploadedFilePath },
|
||||
'Failed to delete PST file after processing.'
|
||||
);
|
||||
if (this.credentials.uploadedFilePath && !this.credentials.localFilePath) {
|
||||
try {
|
||||
await this.storage.delete(this.credentials.uploadedFilePath);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ error, file: this.credentials.uploadedFilePath },
|
||||
'Failed to delete PST file after processing.'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import * as Alert from '$lib/components/ui/alert/index.js';
|
||||
import * as RadioGroup from '$lib/components/ui/radio-group/index.js';
|
||||
import { Textarea } from '$lib/components/ui/textarea/index.js';
|
||||
import { setAlert } from '$lib/components/custom/alert/alert-state.svelte';
|
||||
import { api } from '$lib/api.client';
|
||||
@@ -70,6 +71,27 @@
|
||||
|
||||
let fileUploading = $state(false);
|
||||
|
||||
let importMethod = $state<'upload' | 'local'>(
|
||||
source?.credentials && 'localFilePath' in source.credentials && source.credentials.localFilePath
|
||||
? 'local'
|
||||
: 'upload'
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
if (importMethod === 'upload') {
|
||||
if ('localFilePath' in formData.providerConfig) {
|
||||
delete formData.providerConfig.localFilePath;
|
||||
}
|
||||
} else {
|
||||
if ('uploadedFilePath' in formData.providerConfig) {
|
||||
delete formData.providerConfig.uploadedFilePath;
|
||||
}
|
||||
if ('uploadedFileName' in formData.providerConfig) {
|
||||
delete formData.providerConfig.uploadedFileName;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const handleSubmit = async (event: Event) => {
|
||||
event.preventDefault();
|
||||
isSubmitting = true;
|
||||
@@ -236,59 +258,143 @@
|
||||
/>
|
||||
</div>
|
||||
{:else if formData.provider === 'pst_import'}
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="pst-file" class="text-left"
|
||||
>{$t('app.components.ingestion_source_form.pst_file')}</Label
|
||||
>
|
||||
<div class="col-span-3 flex flex-row items-center space-x-2">
|
||||
<Input
|
||||
id="pst-file"
|
||||
type="file"
|
||||
class=""
|
||||
accept=".pst"
|
||||
onchange={handleFileChange}
|
||||
/>
|
||||
{#if fileUploading}
|
||||
<span class=" text-primary animate-spin"><Loader2 /></span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-start gap-4">
|
||||
<Label class="text-left pt-2">{$t('app.components.ingestion_source_form.import_method')}</Label>
|
||||
<RadioGroup.Root bind:value={importMethod} class="col-span-3 flex flex-col space-y-1">
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="upload" id="pst-upload" />
|
||||
<Label for="pst-upload">{$t('app.components.ingestion_source_form.upload_file')}</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="local" id="pst-local" />
|
||||
<Label for="pst-local">{$t('app.components.ingestion_source_form.local_path')}</Label>
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
</div>
|
||||
|
||||
{#if importMethod === 'upload'}
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="pst-file" class="text-left"
|
||||
>{$t('app.components.ingestion_source_form.pst_file')}</Label
|
||||
>
|
||||
<div class="col-span-3 flex flex-row items-center space-x-2">
|
||||
<Input
|
||||
id="pst-file"
|
||||
type="file"
|
||||
class=""
|
||||
accept=".pst"
|
||||
onchange={handleFileChange}
|
||||
/>
|
||||
{#if fileUploading}
|
||||
<span class=" text-primary animate-spin"><Loader2 /></span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="pst-local-path" class="text-left"
|
||||
>{$t('app.components.ingestion_source_form.local_file_path')}</Label
|
||||
>
|
||||
<Input
|
||||
id="pst-local-path"
|
||||
bind:value={formData.providerConfig.localFilePath}
|
||||
placeholder="/path/to/file.pst"
|
||||
class="col-span-3"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if formData.provider === 'eml_import'}
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="eml-file" class="text-left"
|
||||
>{$t('app.components.ingestion_source_form.eml_file')}</Label
|
||||
>
|
||||
<div class="col-span-3 flex flex-row items-center space-x-2">
|
||||
<Input
|
||||
id="eml-file"
|
||||
type="file"
|
||||
class=""
|
||||
accept=".zip"
|
||||
onchange={handleFileChange}
|
||||
/>
|
||||
{#if fileUploading}
|
||||
<span class=" text-primary animate-spin"><Loader2 /></span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-start gap-4">
|
||||
<Label class="text-left pt-2">{$t('app.components.ingestion_source_form.import_method')}</Label>
|
||||
<RadioGroup.Root bind:value={importMethod} class="col-span-3 flex flex-col space-y-1">
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="upload" id="eml-upload" />
|
||||
<Label for="eml-upload">{$t('app.components.ingestion_source_form.upload_file')}</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="local" id="eml-local" />
|
||||
<Label for="eml-local">{$t('app.components.ingestion_source_form.local_path')}</Label>
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
</div>
|
||||
|
||||
{#if importMethod === 'upload'}
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="eml-file" class="text-left"
|
||||
>{$t('app.components.ingestion_source_form.eml_file')}</Label
|
||||
>
|
||||
<div class="col-span-3 flex flex-row items-center space-x-2">
|
||||
<Input
|
||||
id="eml-file"
|
||||
type="file"
|
||||
class=""
|
||||
accept=".zip"
|
||||
onchange={handleFileChange}
|
||||
/>
|
||||
{#if fileUploading}
|
||||
<span class=" text-primary animate-spin"><Loader2 /></span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="eml-local-path" class="text-left"
|
||||
>{$t('app.components.ingestion_source_form.local_file_path')}</Label
|
||||
>
|
||||
<Input
|
||||
id="eml-local-path"
|
||||
bind:value={formData.providerConfig.localFilePath}
|
||||
placeholder="/path/to/file.zip"
|
||||
class="col-span-3"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if formData.provider === 'mbox_import'}
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="mbox-file" class="text-left"
|
||||
>{$t('app.components.ingestion_source_form.mbox_file')}</Label
|
||||
>
|
||||
<div class="col-span-3 flex flex-row items-center space-x-2">
|
||||
<Input
|
||||
id="mbox-file"
|
||||
type="file"
|
||||
class=""
|
||||
accept=".mbox"
|
||||
onchange={handleFileChange}
|
||||
/>
|
||||
{#if fileUploading}
|
||||
<span class=" text-primary animate-spin"><Loader2 /></span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-start gap-4">
|
||||
<Label class="text-left pt-2">{$t('app.components.ingestion_source_form.import_method')}</Label>
|
||||
<RadioGroup.Root bind:value={importMethod} class="col-span-3 flex flex-col space-y-1">
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="upload" id="mbox-upload" />
|
||||
<Label for="mbox-upload">{$t('app.components.ingestion_source_form.upload_file')}</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="local" id="mbox-local" />
|
||||
<Label for="mbox-local">{$t('app.components.ingestion_source_form.local_path')}</Label>
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
</div>
|
||||
|
||||
{#if importMethod === 'upload'}
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="mbox-file" class="text-left"
|
||||
>{$t('app.components.ingestion_source_form.mbox_file')}</Label
|
||||
>
|
||||
<div class="col-span-3 flex flex-row items-center space-x-2">
|
||||
<Input
|
||||
id="mbox-file"
|
||||
type="file"
|
||||
class=""
|
||||
accept=".mbox"
|
||||
onchange={handleFileChange}
|
||||
/>
|
||||
{#if fileUploading}
|
||||
<span class=" text-primary animate-spin"><Loader2 /></span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="mbox-local-path" class="text-left"
|
||||
>{$t('app.components.ingestion_source_form.local_file_path')}</Label
|
||||
>
|
||||
<Input
|
||||
id="mbox-local-path"
|
||||
bind:value={formData.providerConfig.localFilePath}
|
||||
placeholder="/path/to/file.mbox"
|
||||
class="col-span-3"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if formData.provider === 'google_workspace' || formData.provider === 'microsoft_365'}
|
||||
<Alert.Root>
|
||||
|
||||
292
packages/frontend/src/lib/translations/bg.json
Normal file
292
packages/frontend/src/lib/translations/bg.json
Normal file
@@ -0,0 +1,292 @@
|
||||
{
|
||||
"app": {
|
||||
"auth": {
|
||||
"login": "Вход",
|
||||
"login_tip": "Въведете имейл адреса си по-долу, за да влезете в профила си.",
|
||||
"email": "Имейл",
|
||||
"password": "Парола"
|
||||
},
|
||||
"common": {
|
||||
"working": "Работата е в ход"
|
||||
},
|
||||
"archive": {
|
||||
"title": "Архив",
|
||||
"no_subject": "Без тема",
|
||||
"from": "От",
|
||||
"sent": "Изпратени",
|
||||
"recipients": "Получатели",
|
||||
"to": "До",
|
||||
"meta_data": "Метаданни",
|
||||
"folder": "Папка",
|
||||
"tags": "Етикети",
|
||||
"size": "Размер",
|
||||
"email_preview": "Преглед на имейла",
|
||||
"attachments": "Прикачени файлове",
|
||||
"download": "Изтегляне",
|
||||
"actions": "Действия",
|
||||
"download_eml": "Изтегляне на съобщения (.eml)",
|
||||
"delete_email": "Изтриване на съобщения",
|
||||
"email_thread": "Нишка от съобщения",
|
||||
"delete_confirmation_title": "Сигурни ли сте, че искате да изтриете това съобщение?",
|
||||
"delete_confirmation_description": "Това действие не може да бъде отменено и ще премахне имейла и прикачените към него файлове за постоянно.",
|
||||
"deleting": "Изтриване",
|
||||
"confirm": "Потвърдете",
|
||||
"cancel": "Отказ",
|
||||
"not_found": "Съобщението не е открито."
|
||||
},
|
||||
"ingestions": {
|
||||
"title": "Източници за приемане",
|
||||
"ingestion_sources": "Източници за приемане",
|
||||
"bulk_actions": "Групови действия",
|
||||
"force_sync": "Принудително синхронизиране",
|
||||
"delete": "Изтрийте",
|
||||
"create_new": "Създайте нов",
|
||||
"name": "Име",
|
||||
"provider": "Доставчик",
|
||||
"status": "Статус",
|
||||
"active": "Активно",
|
||||
"created_at": "Създадено в",
|
||||
"actions": "Действия",
|
||||
"last_sync_message": "Последно синхронизирано съобщение",
|
||||
"empty": "Празно",
|
||||
"open_menu": "Отворете менюто",
|
||||
"edit": "Редактиране",
|
||||
"create": "Създайте",
|
||||
"ingestion_source": "Източници за приемане",
|
||||
"edit_description": "Направете промени в източника си за приемане тук.",
|
||||
"create_description": "Добавете нов източник за приемане, за да започнете да архивирате съобщения.",
|
||||
"read": "Прочетете",
|
||||
"docs_here": "тук се намира документацията",
|
||||
"delete_confirmation_title": "Наистина ли искате да изтриете това приемане?",
|
||||
"delete_confirmation_description": "Това ще изтрие всички архивирани съобщения, прикачени файлове, индексирания и файлове, свързани с това приемане. Ако искате да спрете само синхронизирането на нови съобщения, можете вместо това да поставите приемането на пауза.",
|
||||
"deleting": "Изтриване",
|
||||
"confirm": "Потвърдете",
|
||||
"cancel": "Отказ",
|
||||
"bulk_delete_confirmation_title": "Сигурни ли сте, че искате да изтриете {{count}} избраните приемания?",
|
||||
"bulk_delete_confirmation_description": "Това ще изтрие всички архивирани съобщения, прикачени файлове, индексирания и файлове, свързани с тези приемания. Ако искате да спрете само синхронизирането на нови съобщения, можете вместо това да поставите приеманията на пауза."
|
||||
},
|
||||
"search": {
|
||||
"title": "Търсене",
|
||||
"description": "Търсене на архивирани съобщения.",
|
||||
"email_search": "Претърсване на имейл",
|
||||
"placeholder": "Търсене по ключова дума, подател, получател...",
|
||||
"search_button": "Търсене",
|
||||
"search_options": "Опции за търсене",
|
||||
"strategy_fuzzy": "Размито",
|
||||
"strategy_verbatim": "Дословно",
|
||||
"strategy_frequency": "Честота",
|
||||
"select_strategy": "Изберете стратегия",
|
||||
"error": "Грешка",
|
||||
"found_results_in": "Намерени {{total}} резултати за {{seconds}}и",
|
||||
"found_results": "Намерени {{total}} резултати",
|
||||
"from": "От",
|
||||
"to": "До",
|
||||
"in_email_body": "В съдържанието на имейла",
|
||||
"in_attachment": "В прикачения файл: {{filename}}",
|
||||
"prev": "Предишен",
|
||||
"next": "Следващ"
|
||||
},
|
||||
"roles": {
|
||||
"title": "Управление на ролите",
|
||||
"role_management": "Управление на ролите",
|
||||
"create_new": "Създаване на нова",
|
||||
"name": "Име",
|
||||
"created_at": "Създадено в",
|
||||
"actions": "Действия",
|
||||
"open_menu": "Отваряне на менюто",
|
||||
"view_policy": "Преглед на политиката",
|
||||
"edit": "Редактиране",
|
||||
"delete": "Изтриване",
|
||||
"no_roles_found": "Няма намерени роли.",
|
||||
"role_policy": "Политика за ролите",
|
||||
"viewing_policy_for_role": "Преглед на политиката за роля: {{name}}",
|
||||
"create": "Създаване",
|
||||
"role": "Роля",
|
||||
"edit_description": "Направете промени в ролята тук.",
|
||||
"create_description": "Добавете нова роля към системата.",
|
||||
"delete_confirmation_title": "Сигурни ли сте, че искате да изтриете тази роля?",
|
||||
"delete_confirmation_description": "Това действие не може да бъде отменено. Това ще изтрие ролята за постоянно.",
|
||||
"deleting": "Изтриване",
|
||||
"confirm": "Потвърдете",
|
||||
"cancel": "Отказ"
|
||||
},
|
||||
"system_settings": {
|
||||
"title": "Системни настройки",
|
||||
"system_settings": "Системни настройки",
|
||||
"description": "Управление на глобалните настройки на приложението.",
|
||||
"language": "Език",
|
||||
"default_theme": "Тема по подразбиране",
|
||||
"light": "Светла",
|
||||
"dark": "Тъмна",
|
||||
"system": "Система",
|
||||
"support_email": "Имейл за поддръжка",
|
||||
"saving": "Съхранява се",
|
||||
"save_changes": "Съхранете промените"
|
||||
},
|
||||
"users": {
|
||||
"title": "Управление на потребителите",
|
||||
"user_management": "Управление на потребителите",
|
||||
"create_new": "Създаване на нов",
|
||||
"name": "Име",
|
||||
"email": "Имейл",
|
||||
"role": "Роля",
|
||||
"created_at": "Създадено в",
|
||||
"actions": "Действия",
|
||||
"open_menu": "Отваряне на меню",
|
||||
"edit": "Редактиране",
|
||||
"delete": "Изтриване",
|
||||
"no_users_found": "Няма открити потребители.",
|
||||
"create": "Създаване",
|
||||
"user": "Потребител",
|
||||
"edit_description": "Направете промени на потребителя тук.",
|
||||
"create_description": "Добавете нов потребител към системата.",
|
||||
"delete_confirmation_title": "Сигурни ли сте, че искате да изтриете този потребител?",
|
||||
"delete_confirmation_description": "Това действие не може да бъде отменено. Това ще изтрие потребителя за постоянно и ще премахне данните му от нашите сървъри.",
|
||||
"deleting": "Изтриване",
|
||||
"confirm": "Потвърдете",
|
||||
"cancel": "Отказ"
|
||||
},
|
||||
"components": {
|
||||
"charts": {
|
||||
"emails_ingested": "Приети съобщения",
|
||||
"storage_used": "Използвано пространство",
|
||||
"emails": "Съобщения"
|
||||
},
|
||||
"common": {
|
||||
"submitting": "Изпращане...",
|
||||
"submit": "Изпратете",
|
||||
"save": "Съхранете"
|
||||
},
|
||||
"email_preview": {
|
||||
"loading": "Зареждане на визуализацията на имейла...",
|
||||
"render_error": "Не можа да се изобрази визуализация на имейла.",
|
||||
"not_available": "Необработеният .eml файл не е наличен за този имейл."
|
||||
},
|
||||
"footer": {
|
||||
"all_rights_reserved": "Всички права запазени.",
|
||||
"new_version_available": "Налична е нова версия"
|
||||
},
|
||||
"ingestion_source_form": {
|
||||
"provider_generic_imap": "Общ IMAP",
|
||||
"provider_google_workspace": "Google Workspace",
|
||||
"provider_microsoft_365": "Microsoft 365",
|
||||
"provider_pst_import": "PST Импортиране",
|
||||
"provider_eml_import": "EML Импортиране",
|
||||
"provider_mbox_import": "Mbox Импортиране",
|
||||
"select_provider": "Изберете доставчик",
|
||||
"service_account_key": "Ключ за сервизен акаунт (JSON)",
|
||||
"service_account_key_placeholder": "Поставете JSON съдържанието на ключа на вашия сервизен акаунт",
|
||||
"impersonated_admin_email": "Имейл адрес на администратор, използван като идентификатор",
|
||||
"client_id": "Приложение (Клиент) ID",
|
||||
"client_secret": "Клиентски таен ключ",
|
||||
"client_secret_placeholder": "Въведете клиентския таен ключ като стойност, а не ID тайната",
|
||||
"tenant_id": "Директория (Наемател) ID",
|
||||
"host": "Хост",
|
||||
"port": "Порт",
|
||||
"username": "Потребителско им",
|
||||
"use_tls": "Използвайте TLS",
|
||||
"allow_insecure_cert": "Разрешаване на несигурен сертификат",
|
||||
"pst_file": "PST Файл",
|
||||
"eml_file": "EML Файл",
|
||||
"mbox_file": "Mbox файл",
|
||||
"heads_up": "Внимание!",
|
||||
"org_wide_warning": "Моля, обърнете внимание, че това е операция за цялата организация. Този вид приемане ще импортира и индексира <b>всички</b> имейл входящи кутии във вашата организация. Ако искате да импортирате само конкретни имейл входящи кутии, използвайте IMAP инструмента за свързване.",
|
||||
"upload_failed": "Качването не бе успешно, моля, опитайте отново"
|
||||
},
|
||||
"role_form": {
|
||||
"policies_json": "Политики (JSON)",
|
||||
"invalid_json": "Невалиден JSON формат за политики."
|
||||
},
|
||||
"theme_switcher": {
|
||||
"toggle_theme": "Превключване на тема"
|
||||
},
|
||||
"user_form": {
|
||||
"select_role": "Изберете роля"
|
||||
}
|
||||
},
|
||||
"setup": {
|
||||
"title": "Настройка",
|
||||
"description": "Настройте първоначалния администраторски акаунт за Open Archiver.",
|
||||
"welcome": "Добре дошли",
|
||||
"create_admin_account": "Създайте първия администраторски акаунт, за да започнете.",
|
||||
"first_name": "Име",
|
||||
"last_name": "Фамилия",
|
||||
"email": "Имейл",
|
||||
"password": "Парола",
|
||||
"creating_account": "Създаване на акаунт",
|
||||
"create_account": "Създаване на акаунт"
|
||||
},
|
||||
"layout": {
|
||||
"dashboard": "Табло за управление",
|
||||
"ingestions": "Приети",
|
||||
"archived_emails": "Архивирани съобщения",
|
||||
"search": "Търсене",
|
||||
"settings": "Настройки",
|
||||
"system": "Система",
|
||||
"users": "Потребители",
|
||||
"roles": "Роли",
|
||||
"api_keys": "API ключове",
|
||||
"logout": "Изход"
|
||||
},
|
||||
"api_keys_page": {
|
||||
"title": "API ключове",
|
||||
"header": "API ключове",
|
||||
"generate_new_key": "Генериране на нов ключ",
|
||||
"name": "Име",
|
||||
"key": "Ключ",
|
||||
"expires_at": "Изтича на",
|
||||
"created_at": "Създаден на",
|
||||
"actions": "Действия",
|
||||
"delete": "Изтриване",
|
||||
"no_keys_found": "Няма намерени API ключове.",
|
||||
"generate_modal_title": "Генериране на нов API ключ",
|
||||
"generate_modal_description": "Моля, посочете име и срок на валидност за новия си API ключ.",
|
||||
"expires_in": "Изтича след",
|
||||
"select_expiration": "Изберете срок на валидност",
|
||||
"30_days": "30 дни",
|
||||
"60_days": "60 дни",
|
||||
"6_months": "6 месеца",
|
||||
"12_months": "12 месеца",
|
||||
"24_months": "24 месеца",
|
||||
"generate": "Генериране",
|
||||
"new_api_key": "Нов API ключ",
|
||||
"failed_to_delete": "Изтриването на API ключ е неуспешно",
|
||||
"api_key_deleted": "API ключът е изтрит",
|
||||
"generated_title": "API ключът е генериран",
|
||||
"generated_message": "Вашият API ключ е генериран, моля, копирайте го и го запазете на сигурно място. Този ключ ще бъде показан само веднъж."
|
||||
},
|
||||
"archived_emails_page": {
|
||||
"title": "Архивирани съобщения",
|
||||
"header": "Архивирани съобщения",
|
||||
"select_ingestion_source": "Изберете източник за приемане",
|
||||
"date": "Дата",
|
||||
"subject": "Тема",
|
||||
"sender": "Подател",
|
||||
"inbox": "Входяща поща",
|
||||
"path": "Път",
|
||||
"actions": "Действия",
|
||||
"view": "Преглед",
|
||||
"no_emails_found": "Няма намерени архивирани съобщения.",
|
||||
"prev": "Предишен",
|
||||
"next": "Следващ"
|
||||
},
|
||||
"dashboard_page": {
|
||||
"title": "Табло за управление",
|
||||
"meta_description": "Общ преглед на вашия имейл архив.",
|
||||
"header": "Табло за управление",
|
||||
"create_ingestion": "Създаване на приемане",
|
||||
"no_ingestion_header": "Нямате настроен източник за приемане.",
|
||||
"no_ingestion_text": "Добавете източник за приемане, за да започнете да архивирате входящите си кутии.",
|
||||
"total_emails_archived": "Общо архивирани съобщения",
|
||||
"total_storage_used": "Общо използвано място за съхранение",
|
||||
"failed_ingestions": "Неуспешни приемания (последните 7 дни)",
|
||||
"ingestion_history": "История на приеманията",
|
||||
"no_ingestion_history": "Няма налична история на приеманията.",
|
||||
"storage_by_source": "Съхранение по източник на приемане",
|
||||
"no_ingestion_sources": "Няма налични източници за приемане.",
|
||||
"indexed_insights": "Индексирани данни",
|
||||
"top_10_senders": "Топ 10 податели",
|
||||
"no_indexed_insights": "Няма налични индексирани данни."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -118,6 +118,23 @@
|
||||
"confirm": "Confirm",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"account": {
|
||||
"title": "Account Settings",
|
||||
"description": "Manage your profile and security settings.",
|
||||
"personal_info": "Personal Information",
|
||||
"personal_info_desc": "Update your personal details.",
|
||||
"security": "Security",
|
||||
"security_desc": "Manage your password and security preferences.",
|
||||
"edit_profile": "Edit Profile",
|
||||
"change_password": "Change Password",
|
||||
"edit_profile_desc": "Make changes to your profile here.",
|
||||
"change_password_desc": "Change your password. You will need to enter your current password.",
|
||||
"current_password": "Current Password",
|
||||
"new_password": "New Password",
|
||||
"confirm_new_password": "Confirm New Password",
|
||||
"operation_successful": "Operation successful",
|
||||
"passwords_do_not_match": "Passwords do not match"
|
||||
},
|
||||
"system_settings": {
|
||||
"title": "System Settings",
|
||||
"system_settings": "System Settings",
|
||||
@@ -182,6 +199,10 @@
|
||||
"provider_eml_import": "EML Import",
|
||||
"provider_mbox_import": "Mbox Import",
|
||||
"select_provider": "Select a provider",
|
||||
"import_method": "Import Method",
|
||||
"upload_file": "Upload File",
|
||||
"local_path": "Local Path (Recommended for large files)",
|
||||
"local_file_path": "Local File Path",
|
||||
"service_account_key": "Service Account Key (JSON)",
|
||||
"service_account_key_placeholder": "Paste your service account key JSON content",
|
||||
"impersonated_admin_email": "Impersonated Admin Email",
|
||||
@@ -234,6 +255,7 @@
|
||||
"users": "Users",
|
||||
"roles": "Roles",
|
||||
"api_keys": "API Keys",
|
||||
"account": "Account",
|
||||
"logout": "Logout",
|
||||
"admin": "Admin"
|
||||
},
|
||||
|
||||
@@ -12,6 +12,7 @@ import nl from './nl.json';
|
||||
import ja from './ja.json';
|
||||
import et from './et.json';
|
||||
import el from './el.json';
|
||||
import bg from './bg.json'
|
||||
// This is your config object.
|
||||
// It defines the languages and how to load them.
|
||||
const config: Config = {
|
||||
@@ -77,6 +78,12 @@ const config: Config = {
|
||||
key: 'app',
|
||||
loader: async () => el.app,
|
||||
},
|
||||
// Bulgarian 🇧🇬
|
||||
{
|
||||
locale: 'bg',
|
||||
key: 'app',
|
||||
loader: async () => bg.app,
|
||||
},
|
||||
],
|
||||
fallbackLocale: 'en',
|
||||
};
|
||||
|
||||
@@ -1,289 +1,400 @@
|
||||
{
|
||||
"app": {
|
||||
"auth": {
|
||||
"login": "Accedi",
|
||||
"login_tip": "Inserisci la tua email qui sotto per accedere al tuo account.",
|
||||
"email": "Email",
|
||||
"password": "Password"
|
||||
},
|
||||
"common": {
|
||||
"working": "In corso"
|
||||
},
|
||||
"archive": {
|
||||
"title": "Archivio",
|
||||
"no_subject": "Nessun Oggetto",
|
||||
"from": "Da",
|
||||
"sent": "Inviato",
|
||||
"recipients": "Destinatari",
|
||||
"to": "A",
|
||||
"meta_data": "Metadati",
|
||||
"folder": "Cartella",
|
||||
"tags": "Tag",
|
||||
"size": "Dimensione",
|
||||
"email_preview": "Anteprima Email",
|
||||
"attachments": "Allegati",
|
||||
"download": "Scarica",
|
||||
"actions": "Azioni",
|
||||
"download_eml": "Scarica Email (.eml)",
|
||||
"delete_email": "Elimina Email",
|
||||
"email_thread": "Thread Email",
|
||||
"delete_confirmation_title": "Sei sicuro di voler eliminare questa email?",
|
||||
"delete_confirmation_description": "Questa azione non può essere annullata e rimuoverà permanentemente l'email e i suoi allegati.",
|
||||
"deleting": "Eliminazione in corso",
|
||||
"confirm": "Conferma",
|
||||
"cancel": "Annulla",
|
||||
"not_found": "Email non trovata."
|
||||
},
|
||||
"ingestions": {
|
||||
"title": "Sorgenti di Ingestione",
|
||||
"ingestion_sources": "Sorgenti di Ingestione",
|
||||
"bulk_actions": "Azioni di Massa",
|
||||
"force_sync": "Forza Sincronizzazione",
|
||||
"delete": "Elimina",
|
||||
"create_new": "Crea Nuovo",
|
||||
"name": "Nome",
|
||||
"provider": "Provider",
|
||||
"status": "Stato",
|
||||
"active": "Attivo",
|
||||
"created_at": "Creato il",
|
||||
"actions": "Azioni",
|
||||
"last_sync_message": "Ultimo messaggio di sincronizzazione",
|
||||
"empty": "Vuoto",
|
||||
"open_menu": "Apri menu",
|
||||
"edit": "Modifica",
|
||||
"create": "Crea",
|
||||
"ingestion_source": "Sorgente di Ingestione",
|
||||
"edit_description": "Apporta modifiche alla tua sorgente di ingestione qui.",
|
||||
"create_description": "Aggiungi una nuova sorgente di ingestione per iniziare ad archiviare le email.",
|
||||
"read": "Leggi",
|
||||
"docs_here": "documenti qui",
|
||||
"delete_confirmation_title": "Sei sicuro di voler eliminare questa ingestione?",
|
||||
"delete_confirmation_description": "Questo cancellerà tutte le email archiviate, gli allegati, l'indicizzazione e i file associati a questa ingestione. Se vuoi solo interrompere la sincronizzazione di nuove email, puoi mettere in pausa l'ingestione.",
|
||||
"deleting": "Eliminazione in corso",
|
||||
"confirm": "Conferma",
|
||||
"cancel": "Annulla",
|
||||
"bulk_delete_confirmation_title": "Sei sicuro di voler eliminare {{count}} ingestioni selezionate?",
|
||||
"bulk_delete_confirmation_description": "Questo cancellerà tutte le email archiviate, gli allegati, l'indicizzazione e i file associati a queste ingestioni. Se vuoi solo interrompere la sincronizzazione di nuove email, puoi mettere in pausa le ingestioni."
|
||||
},
|
||||
"search": {
|
||||
"title": "Ricerca",
|
||||
"description": "Ricerca email archiviate.",
|
||||
"email_search": "Ricerca Email",
|
||||
"placeholder": "Cerca per parola chiave, mittente, destinatario...",
|
||||
"search_button": "Cerca",
|
||||
"search_options": "Opzioni di ricerca",
|
||||
"strategy_fuzzy": "Approssimativa",
|
||||
"strategy_verbatim": "Esatta",
|
||||
"strategy_frequency": "Frequenza",
|
||||
"select_strategy": "Seleziona una strategia",
|
||||
"error": "Errore",
|
||||
"found_results_in": "Trovati {{total}} risultati in {{seconds}}s",
|
||||
"found_results": "Trovati {{total}} risultati",
|
||||
"from": "Da",
|
||||
"to": "A",
|
||||
"in_email_body": "Nel corpo dell'email",
|
||||
"in_attachment": "Nell'allegato: {{filename}}",
|
||||
"prev": "Prec",
|
||||
"next": "Succ"
|
||||
},
|
||||
"roles": {
|
||||
"title": "Gestione Ruoli",
|
||||
"role_management": "Gestione Ruoli",
|
||||
"create_new": "Crea Nuovo",
|
||||
"name": "Nome",
|
||||
"created_at": "Creato il",
|
||||
"actions": "Azioni",
|
||||
"open_menu": "Apri menu",
|
||||
"view_policy": "Visualizza Policy",
|
||||
"edit": "Modifica",
|
||||
"delete": "Elimina",
|
||||
"no_roles_found": "Nessun ruolo trovato.",
|
||||
"role_policy": "Policy Ruolo",
|
||||
"viewing_policy_for_role": "Visualizzazione policy per il ruolo: {{name}}",
|
||||
"create": "Crea",
|
||||
"role": "Ruolo",
|
||||
"edit_description": "Apporta modifiche al ruolo qui.",
|
||||
"create_description": "Aggiungi un nuovo ruolo al sistema.",
|
||||
"delete_confirmation_title": "Sei sicuro di voler eliminare questo ruolo?",
|
||||
"delete_confirmation_description": "Questa azione non può essere annullata. Questo eliminerà permanentemente il ruolo.",
|
||||
"deleting": "Eliminazione in corso",
|
||||
"confirm": "Conferma",
|
||||
"cancel": "Annulla"
|
||||
},
|
||||
"system_settings": {
|
||||
"title": "Impostazioni di Sistema",
|
||||
"system_settings": "Impostazioni di Sistema",
|
||||
"description": "Gestisci le impostazioni globali dell'applicazione.",
|
||||
"language": "Lingua",
|
||||
"default_theme": "Tema predefinito",
|
||||
"light": "Chiaro",
|
||||
"dark": "Scuro",
|
||||
"system": "Sistema",
|
||||
"support_email": "Email di Supporto",
|
||||
"saving": "Salvataggio in corso",
|
||||
"save_changes": "Salva Modifiche"
|
||||
},
|
||||
"users": {
|
||||
"title": "Gestione Utenti",
|
||||
"user_management": "Gestione Utenti",
|
||||
"create_new": "Crea Nuovo",
|
||||
"name": "Nome",
|
||||
"email": "Email",
|
||||
"role": "Ruolo",
|
||||
"created_at": "Creato il",
|
||||
"actions": "Azioni",
|
||||
"open_menu": "Apri menu",
|
||||
"edit": "Modifica",
|
||||
"delete": "Elimina",
|
||||
"no_users_found": "Nessun utente trovato.",
|
||||
"create": "Crea",
|
||||
"user": "Utente",
|
||||
"edit_description": "Apporta modifiche all'utente qui.",
|
||||
"create_description": "Aggiungi un nuovo utente al sistema.",
|
||||
"delete_confirmation_title": "Sei sicuro di voler eliminare questo utente?",
|
||||
"delete_confirmation_description": "Questa azione non può essere annullata. Questo eliminerà permanentemente l'utente e rimuoverà i suoi dati dai nostri server.",
|
||||
"deleting": "Eliminazione in corso",
|
||||
"confirm": "Conferma",
|
||||
"cancel": "Annulla"
|
||||
},
|
||||
"components": {
|
||||
"charts": {
|
||||
"emails_ingested": "Email Acquisite",
|
||||
"storage_used": "Spazio di Archiviazione Utilizzato",
|
||||
"emails": "Email"
|
||||
},
|
||||
"common": {
|
||||
"submitting": "Invio in corso...",
|
||||
"submit": "Invia",
|
||||
"save": "Salva"
|
||||
},
|
||||
"email_preview": {
|
||||
"loading": "Caricamento anteprima email...",
|
||||
"render_error": "Impossibile renderizzare l'anteprima dell'email.",
|
||||
"not_available": "File .eml grezzo non disponibile per questa email."
|
||||
},
|
||||
"footer": {
|
||||
"all_rights_reserved": "Tutti i diritti riservati."
|
||||
},
|
||||
"ingestion_source_form": {
|
||||
"provider_generic_imap": "IMAP Generico",
|
||||
"provider_google_workspace": "Google Workspace",
|
||||
"provider_microsoft_365": "Microsoft 365",
|
||||
"provider_pst_import": "Importazione PST",
|
||||
"provider_eml_import": "Importazione EML",
|
||||
"select_provider": "Seleziona un provider",
|
||||
"service_account_key": "Chiave Account di Servizio (JSON)",
|
||||
"service_account_key_placeholder": "Incolla il contenuto JSON della chiave del tuo account di servizio",
|
||||
"impersonated_admin_email": "Email dell'Amministratore Impersonato",
|
||||
"client_id": "ID Applicazione (Client)",
|
||||
"client_secret": "Valore Segreto Client",
|
||||
"client_secret_placeholder": "Inserisci il Valore segreto, non l'ID Segreto",
|
||||
"tenant_id": "ID Directory (Tenant)",
|
||||
"host": "Host",
|
||||
"port": "Porta",
|
||||
"username": "Nome Utente",
|
||||
"use_tls": "Usa TLS",
|
||||
"allow_insecure_cert": "Consenti certificato non sicuro",
|
||||
"pst_file": "File PST",
|
||||
"eml_file": "File EML",
|
||||
"heads_up": "Attenzione!",
|
||||
"org_wide_warning": "Si prega di notare che questa è un'operazione a livello di organizzazione. Questo tipo di ingestione importerà e indicizzerà <b>tutte</b> le caselle di posta elettronica nella tua organizzazione. Se vuoi importare solo caselle di posta elettronica specifiche, usa il connettore IMAP.",
|
||||
"upload_failed": "Caricamento Fallito, riprova"
|
||||
},
|
||||
"role_form": {
|
||||
"policies_json": "Policy (JSON)",
|
||||
"invalid_json": "Formato JSON non valido per le policy."
|
||||
},
|
||||
"theme_switcher": {
|
||||
"toggle_theme": "Cambia tema"
|
||||
},
|
||||
"user_form": {
|
||||
"select_role": "Seleziona un ruolo"
|
||||
}
|
||||
},
|
||||
"setup": {
|
||||
"title": "Configurazione",
|
||||
"description": "Configura l'account amministratore iniziale per Open Archiver.",
|
||||
"welcome": "Benvenuto",
|
||||
"create_admin_account": "Crea il primo account amministratore per iniziare.",
|
||||
"first_name": "Nome",
|
||||
"last_name": "Cognome",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"creating_account": "Creazione Account",
|
||||
"create_account": "Crea Account"
|
||||
},
|
||||
"layout": {
|
||||
"dashboard": "Dashboard",
|
||||
"ingestions": "Ingestioni",
|
||||
"archived_emails": "Email archiviate",
|
||||
"search": "Ricerca",
|
||||
"settings": "Impostazioni",
|
||||
"system": "Sistema",
|
||||
"users": "Utenti",
|
||||
"roles": "Ruoli",
|
||||
"api_keys": "Chiavi API",
|
||||
"logout": "Esci"
|
||||
},
|
||||
"api_keys_page": {
|
||||
"title": "Chiavi API",
|
||||
"header": "Chiavi API",
|
||||
"generate_new_key": "Genera Nuova Chiave",
|
||||
"name": "Nome",
|
||||
"key": "Chiave",
|
||||
"expires_at": "Scade il",
|
||||
"created_at": "Creato il",
|
||||
"actions": "Azioni",
|
||||
"delete": "Elimina",
|
||||
"no_keys_found": "Nessuna chiave API trovata.",
|
||||
"generate_modal_title": "Genera Nuova Chiave API",
|
||||
"generate_modal_description": "Fornisci un nome e una scadenza per la tua nuova chiave API.",
|
||||
"expires_in": "Scade Tra",
|
||||
"select_expiration": "Seleziona una scadenza",
|
||||
"30_days": "30 Giorni",
|
||||
"60_days": "60 Giorni",
|
||||
"6_months": "6 Mesi",
|
||||
"12_months": "12 Mesi",
|
||||
"24_months": "24 Mesi",
|
||||
"generate": "Genera",
|
||||
"new_api_key": "Nuova Chiave API",
|
||||
"failed_to_delete": "Impossibile eliminare la chiave API",
|
||||
"api_key_deleted": "Chiave API eliminata",
|
||||
"generated_title": "Chiave API Generata",
|
||||
"generated_message": "La tua chiave API è stata generata, per favore copiala e salvala in un luogo sicuro. Questa chiave verrà mostrata solo una volta."
|
||||
},
|
||||
"archived_emails_page": {
|
||||
"title": "Email archiviate",
|
||||
"header": "Email Archiviate",
|
||||
"select_ingestion_source": "Seleziona una sorgente di ingestione",
|
||||
"date": "Data",
|
||||
"subject": "Oggetto",
|
||||
"sender": "Mittente",
|
||||
"inbox": "Posta in arrivo",
|
||||
"path": "Percorso",
|
||||
"actions": "Azioni",
|
||||
"view": "Visualizza",
|
||||
"no_emails_found": "Nessuna email archiviata trovata.",
|
||||
"prev": "Prec",
|
||||
"next": "Succ"
|
||||
},
|
||||
"dashboard_page": {
|
||||
"title": "Dashboard",
|
||||
"meta_description": "Panoramica del tuo archivio email.",
|
||||
"header": "Dashboard",
|
||||
"create_ingestion": "Crea un'ingestione",
|
||||
"no_ingestion_header": "Non hai impostato nessuna sorgente di ingestione.",
|
||||
"no_ingestion_text": "Aggiungi una sorgente di ingestione per iniziare ad archiviare le tue caselle di posta.",
|
||||
"total_emails_archived": "Totale Email Archiviate",
|
||||
"total_storage_used": "Spazio di Archiviazione Totale Utilizzato",
|
||||
"failed_ingestions": "Ingestioni Fallite (Ultimi 7 Giorni)",
|
||||
"ingestion_history": "Cronologia Ingestioni",
|
||||
"no_ingestion_history": "Nessuna cronologia delle ingestioni disponibile.",
|
||||
"storage_by_source": "Spazio di Archiviazione per Sorgente di Ingestione",
|
||||
"no_ingestion_sources": "Nessuna sorgente di ingestione disponibile.",
|
||||
"indexed_insights": "Approfondimenti indicizzati",
|
||||
"top_10_senders": "I 10 Mittenti Principali",
|
||||
"no_indexed_insights": "Nessun approfondimento indicizzato disponibile."
|
||||
}
|
||||
}
|
||||
}
|
||||
{
|
||||
"app": {
|
||||
"auth": {
|
||||
"login": "Accedi",
|
||||
"login_tip": "Inserisci la tua email qui sotto per accedere al tuo account.",
|
||||
"email": "Email",
|
||||
"password": "Password"
|
||||
},
|
||||
"common": {
|
||||
"working": "In corso",
|
||||
"read_docs": "Leggi la documentazione"
|
||||
},
|
||||
"archive": {
|
||||
"title": "Archivio",
|
||||
"no_subject": "Nessun oggetto",
|
||||
"from": "Da",
|
||||
"sent": "Inviato",
|
||||
"recipients": "Destinatari",
|
||||
"to": "A",
|
||||
"meta_data": "Metadati",
|
||||
"folder": "Cartella",
|
||||
"tags": "Tag",
|
||||
"size": "Dimensione",
|
||||
"email_preview": "Anteprima email",
|
||||
"attachments": "Allegati",
|
||||
"download": "Scarica",
|
||||
"actions": "Azioni",
|
||||
"download_eml": "Scarica Email (.eml)",
|
||||
"delete_email": "Elimina Email",
|
||||
"email_thread": "Thread Email",
|
||||
"delete_confirmation_title": "Sei sicuro di voler eliminare questa email?",
|
||||
"delete_confirmation_description": "Questa azione non può essere annullata e rimuoverà definitivamente l'email e i suoi allegati.",
|
||||
"deleting": "Eliminazione in corso",
|
||||
"confirm": "Conferma",
|
||||
"cancel": "Annulla",
|
||||
"not_found": "Email non trovata.",
|
||||
"integrity_report": "Rapporto di integrità",
|
||||
"email_eml": "Email (.eml)",
|
||||
"valid": "Valido",
|
||||
"invalid": "Non valido",
|
||||
"integrity_check_failed_title": "Controllo di integrità non riuscito",
|
||||
"integrity_check_failed_message": "Impossibile verificare l'integrità dell'email e dei suoi allegati.",
|
||||
"integrity_report_description": "Questo rapporto verifica che il contenuto delle tue email archiviate non sia stato alterato."
|
||||
},
|
||||
"ingestions": {
|
||||
"title": "Fonti di acquisizione",
|
||||
"ingestion_sources": "Fonti di acquisizione",
|
||||
"bulk_actions": "Azioni di massa",
|
||||
"force_sync": "Forza sincronizzazione",
|
||||
"delete": "Elimina",
|
||||
"create_new": "Crea nuovo",
|
||||
"name": "Nome",
|
||||
"provider": "Provider",
|
||||
"status": "Stato",
|
||||
"active": "Attivo",
|
||||
"created_at": "Creato il",
|
||||
"actions": "Azioni",
|
||||
"last_sync_message": "Ultimo messaggio di sincronizzazione",
|
||||
"empty": "Vuoto",
|
||||
"open_menu": "Apri menu",
|
||||
"edit": "Modifica",
|
||||
"create": "Crea",
|
||||
"ingestion_source": "Fonte di acquisizione",
|
||||
"edit_description": "Apporta modifiche alla tua fonte di acquisizione qui.",
|
||||
"create_description": "Aggiungi una nuova fonte di acquisizione per iniziare ad archiviare le email.",
|
||||
"read": "Leggi",
|
||||
"docs_here": "documentazione qui",
|
||||
"delete_confirmation_title": "Sei sicuro di voler eliminare questa acquisizione?",
|
||||
"delete_confirmation_description": "Questo cancellerà tutte le email archiviate, gli allegati, l'indicizzazione e i file associati a questa acquisizione. Se vuoi solo interrompere la sincronizzazione di nuove email, puoi invece mettere in pausa l'acquisizione.",
|
||||
"deleting": "Eliminazione in corso",
|
||||
"confirm": "Conferma",
|
||||
"cancel": "Annulla",
|
||||
"bulk_delete_confirmation_title": "Sei sicuro di voler eliminare {{count}} acquisizioni selezionate?",
|
||||
"bulk_delete_confirmation_description": "Questo cancellerà tutte le email archiviate, gli allegati, l'indicizzazione e i file associati a queste acquisizioni. Se vuoi solo interrompere la sincronizzazione di nuove email, puoi invece mettere in pausa le acquisizioni."
|
||||
},
|
||||
"search": {
|
||||
"title": "Cerca",
|
||||
"description": "Cerca email archiviate.",
|
||||
"email_search": "Ricerca email",
|
||||
"placeholder": "Cerca per parola chiave, mittente, destinatario...",
|
||||
"search_button": "Cerca",
|
||||
"search_options": "Opzioni di ricerca",
|
||||
"strategy_fuzzy": "Approssimativa",
|
||||
"strategy_verbatim": "Testuale",
|
||||
"strategy_frequency": "Frequenza",
|
||||
"select_strategy": "Seleziona una strategia",
|
||||
"error": "Errore",
|
||||
"found_results_in": "Trovati {{total}} risultati in {{seconds}}s",
|
||||
"found_results": "Trovati {{total}} risultati",
|
||||
"from": "Da",
|
||||
"to": "A",
|
||||
"in_email_body": "Nel corpo dell'email",
|
||||
"in_attachment": "Nell'allegato: {{filename}}",
|
||||
"prev": "Prec",
|
||||
"next": "Succ"
|
||||
},
|
||||
"roles": {
|
||||
"title": "Gestione ruoli",
|
||||
"role_management": "Gestione ruoli",
|
||||
"create_new": "Crea nuovo",
|
||||
"name": "Nome",
|
||||
"created_at": "Creato il",
|
||||
"actions": "Azioni",
|
||||
"open_menu": "Apri menu",
|
||||
"view_policy": "Visualizza Policy",
|
||||
"edit": "Modifica",
|
||||
"delete": "Elimina",
|
||||
"no_roles_found": "Nessun ruolo trovato.",
|
||||
"role_policy": "Policy del ruolo",
|
||||
"viewing_policy_for_role": "Visualizzazione della policy per il ruolo: {{name}}",
|
||||
"create": "Crea",
|
||||
"role": "Ruolo",
|
||||
"edit_description": "Apporta modifiche al ruolo qui.",
|
||||
"create_description": "Aggiungi un nuovo ruolo al sistema.",
|
||||
"delete_confirmation_title": "Sei sicuro di voler eliminare questo ruolo?",
|
||||
"delete_confirmation_description": "Questa azione non può essere annullata. Questo cancellerà definitivamente il ruolo.",
|
||||
"deleting": "Eliminazione in corso",
|
||||
"confirm": "Conferma",
|
||||
"cancel": "Annulla"
|
||||
},
|
||||
"account": {
|
||||
"title": "Impostazioni account",
|
||||
"description": "Gestisci il tuo profilo e le impostazioni di sicurezza.",
|
||||
"personal_info": "Informazioni personali",
|
||||
"personal_info_desc": "Aggiorna i tuoi dati personali.",
|
||||
"security": "Sicurezza",
|
||||
"security_desc": "Gestisci la tua password e le preferenze di sicurezza.",
|
||||
"edit_profile": "Modifica profilo",
|
||||
"change_password": "Cambia password",
|
||||
"edit_profile_desc": "Apporta modifiche al tuo profilo qui.",
|
||||
"change_password_desc": "Cambia la tua password. Dovrai inserire la tua password attuale.",
|
||||
"current_password": "Password attuale",
|
||||
"new_password": "Nuova password",
|
||||
"confirm_new_password": "Conferma nuova password",
|
||||
"operation_successful": "Operazione riuscita",
|
||||
"passwords_do_not_match": "Le password non corrispondono"
|
||||
},
|
||||
"system_settings": {
|
||||
"title": "Impostazioni di sistema",
|
||||
"system_settings": "Impostazioni di sistema",
|
||||
"description": "Gestisci le impostazioni globali dell'applicazione.",
|
||||
"language": "Lingua",
|
||||
"default_theme": "Tema predefinito",
|
||||
"light": "Chiaro",
|
||||
"dark": "Scuro",
|
||||
"system": "Sistema",
|
||||
"support_email": "Email di supporto",
|
||||
"saving": "Salvataggio in corso",
|
||||
"save_changes": "Salva modifiche"
|
||||
},
|
||||
"users": {
|
||||
"title": "Gestione utenti",
|
||||
"user_management": "Gestione utenti",
|
||||
"create_new": "Crea nuovo",
|
||||
"name": "Nome",
|
||||
"email": "Email",
|
||||
"role": "Ruolo",
|
||||
"created_at": "Creato il",
|
||||
"actions": "Azioni",
|
||||
"open_menu": "Apri menu",
|
||||
"edit": "Modifica",
|
||||
"delete": "Elimina",
|
||||
"no_users_found": "Nessun utente trovato.",
|
||||
"create": "Crea",
|
||||
"user": "Utente",
|
||||
"edit_description": "Apporta modifiche all'utente qui.",
|
||||
"create_description": "Aggiungi un nuovo utente al sistema.",
|
||||
"delete_confirmation_title": "Sei sicuro di voler eliminare questo utente?",
|
||||
"delete_confirmation_description": "Questa azione non può essere annullata. Questo cancellerà definitivamente l'utente e rimuoverà i suoi dati dai nostri server.",
|
||||
"deleting": "Eliminazione in corso",
|
||||
"confirm": "Conferma",
|
||||
"cancel": "Annulla"
|
||||
},
|
||||
"components": {
|
||||
"charts": {
|
||||
"emails_ingested": "Email acquisite",
|
||||
"storage_used": "Spazio di archiviazione utilizzato",
|
||||
"emails": "Email"
|
||||
},
|
||||
"common": {
|
||||
"submitting": "Invio in corso...",
|
||||
"submit": "Invia",
|
||||
"save": "Salva"
|
||||
},
|
||||
"email_preview": {
|
||||
"loading": "Caricamento anteprima email...",
|
||||
"render_error": "Impossibile visualizzare l'anteprima dell'email.",
|
||||
"not_available": "File .eml grezzo non disponibile per questa email."
|
||||
},
|
||||
"footer": {
|
||||
"all_rights_reserved": "Tutti i diritti riservati.",
|
||||
"new_version_available": "Nuova versione disponibile"
|
||||
},
|
||||
"ingestion_source_form": {
|
||||
"provider_generic_imap": "IMAP generico",
|
||||
"provider_google_workspace": "Google Workspace",
|
||||
"provider_microsoft_365": "Microsoft 365",
|
||||
"provider_pst_import": "Importazione PST",
|
||||
"provider_eml_import": "Importazione EML",
|
||||
"provider_mbox_import": "Importazione Mbox",
|
||||
"select_provider": "Seleziona un provider",
|
||||
"service_account_key": "Chiave dell'account di servizio (JSON)",
|
||||
"service_account_key_placeholder": "Incolla il contenuto JSON della chiave del tuo account di servizio",
|
||||
"impersonated_admin_email": "Email dell'amministratore impersonato",
|
||||
"client_id": "ID applicazione (client)",
|
||||
"client_secret": "Valore del segreto client",
|
||||
"client_secret_placeholder": "Inserisci il valore segreto, non l'ID segreto",
|
||||
"tenant_id": "ID directory (tenant)",
|
||||
"host": "Host",
|
||||
"port": "Porta",
|
||||
"username": "Nome utente",
|
||||
"use_tls": "Usa TLS",
|
||||
"allow_insecure_cert": "Consenti certificato non sicuro",
|
||||
"pst_file": "File PST",
|
||||
"eml_file": "File EML",
|
||||
"mbox_file": "File Mbox",
|
||||
"heads_up": "Attenzione!",
|
||||
"org_wide_warning": "Tieni presente che questa è un'operazione a livello di organizzazione. Questo tipo di acquisizione importerà e indicizzerà <b>tutte</b> le caselle di posta nella tua organizzazione. Se vuoi importare solo caselle di posta specifiche, usa il connettore IMAP.",
|
||||
"upload_failed": "Caricamento non riuscito, riprova"
|
||||
},
|
||||
"role_form": {
|
||||
"policies_json": "Policy (JSON)",
|
||||
"invalid_json": "Formato JSON non valido per le policy."
|
||||
},
|
||||
"theme_switcher": {
|
||||
"toggle_theme": "Attiva/disattiva tema"
|
||||
},
|
||||
"user_form": {
|
||||
"select_role": "Seleziona un ruolo"
|
||||
}
|
||||
},
|
||||
"setup": {
|
||||
"title": "Configurazione",
|
||||
"description": "Configura l'account amministratore iniziale per Open Archiver.",
|
||||
"welcome": "Benvenuto",
|
||||
"create_admin_account": "Crea il primo account amministratore per iniziare.",
|
||||
"first_name": "Nome",
|
||||
"last_name": "Cognome",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"creating_account": "Creazione account",
|
||||
"create_account": "Crea account"
|
||||
},
|
||||
"layout": {
|
||||
"dashboard": "Dashboard",
|
||||
"ingestions": "Acquisizioni",
|
||||
"archived_emails": "Email archiviate",
|
||||
"search": "Cerca",
|
||||
"settings": "Impostazioni",
|
||||
"system": "Sistema",
|
||||
"users": "Utenti",
|
||||
"roles": "Ruoli",
|
||||
"api_keys": "Chiavi API",
|
||||
"account": "Account",
|
||||
"logout": "Disconnetti",
|
||||
"admin": "Amministratore"
|
||||
},
|
||||
"api_keys_page": {
|
||||
"title": "Chiavi API",
|
||||
"header": "Chiavi API",
|
||||
"generate_new_key": "Genera nuova chiave",
|
||||
"name": "Nome",
|
||||
"key": "Chiave",
|
||||
"expires_at": "Scade il",
|
||||
"created_at": "Creato il",
|
||||
"actions": "Azioni",
|
||||
"delete": "Elimina",
|
||||
"no_keys_found": "Nessuna chiave API trovata.",
|
||||
"generate_modal_title": "Genera nuova chiave API",
|
||||
"generate_modal_description": "Fornisci un nome e una scadenza per la tua nuova chiave API.",
|
||||
"expires_in": "Scade tra",
|
||||
"select_expiration": "Seleziona una scadenza",
|
||||
"30_days": "30 giorni",
|
||||
"60_days": "60 giorni",
|
||||
"6_months": "6 mesi",
|
||||
"12_months": "12 mesi",
|
||||
"24_months": "24 mesi",
|
||||
"generate": "Genera",
|
||||
"new_api_key": "Nuova chiave API",
|
||||
"failed_to_delete": "Impossibile eliminare la chiave API",
|
||||
"api_key_deleted": "Chiave API eliminata",
|
||||
"generated_title": "Chiave API generata",
|
||||
"generated_message": "La tua chiave API è stata generata, copiala e salvala in un luogo sicuro. Questa chiave verrà mostrata solo una volta."
|
||||
},
|
||||
"archived_emails_page": {
|
||||
"title": "Email archiviate",
|
||||
"header": "Email archiviate",
|
||||
"select_ingestion_source": "Seleziona una fonte di acquisizione",
|
||||
"date": "Data",
|
||||
"subject": "Oggetto",
|
||||
"sender": "Mittente",
|
||||
"inbox": "Posta in arrivo",
|
||||
"path": "Percorso",
|
||||
"actions": "Azioni",
|
||||
"view": "Visualizza",
|
||||
"no_emails_found": "Nessuna email archiviata trovata.",
|
||||
"prev": "Prec",
|
||||
"next": "Succ"
|
||||
},
|
||||
"dashboard_page": {
|
||||
"title": "Dashboard",
|
||||
"meta_description": "Panoramica del tuo archivio email.",
|
||||
"header": "Dashboard",
|
||||
"create_ingestion": "Crea un'acquisizione",
|
||||
"no_ingestion_header": "Non hai configurato alcuna fonte di acquisizione.",
|
||||
"no_ingestion_text": "Aggiungi una fonte di acquisizione per iniziare ad archiviare le tue caselle di posta.",
|
||||
"total_emails_archived": "Email totali archiviate",
|
||||
"total_storage_used": "Spazio di archiviazione totale utilizzato",
|
||||
"failed_ingestions": "Acquisizioni non riuscite (ultimi 7 giorni)",
|
||||
"ingestion_history": "Cronologia acquisizioni",
|
||||
"no_ingestion_history": "Nessuna cronologia acquisizioni disponibile.",
|
||||
"storage_by_source": "Spazio di archiviazione per fonte di acquisizione",
|
||||
"no_ingestion_sources": "Nessuna fonte di acquisizione disponibile.",
|
||||
"indexed_insights": "Informazioni indicizzate",
|
||||
"top_10_senders": "I 10 mittenti principali",
|
||||
"no_indexed_insights": "Nessuna informazione indicizzata disponibile."
|
||||
},
|
||||
"audit_log": {
|
||||
"title": "Registro di audit",
|
||||
"header": "Registro di audit",
|
||||
"verify_integrity": "Verifica l'integrità del registro",
|
||||
"log_entries": "Voci di registro",
|
||||
"timestamp": "Timestamp",
|
||||
"actor": "Attore",
|
||||
"action": "Azione",
|
||||
"target": "Obiettivo",
|
||||
"details": "Dettagli",
|
||||
"ip_address": "Indirizzo IP",
|
||||
"target_type": "Tipo di obiettivo",
|
||||
"target_id": "ID obiettivo",
|
||||
"no_logs_found": "Nessun registro di audit trovato.",
|
||||
"prev": "Prec",
|
||||
"next": "Succ",
|
||||
"log_entry_details": "Dettagli della voce di registro",
|
||||
"viewing_details_for": "Visualizzazione dei dettagli completi per la voce di registro #",
|
||||
"actor_id": "ID attore",
|
||||
"previous_hash": "Hash precedente",
|
||||
"current_hash": "Hash corrente",
|
||||
"close": "Chiudi",
|
||||
"verification_successful_title": "Verifica riuscita",
|
||||
"verification_successful_message": "Integrità del registro di audit verificata con successo.",
|
||||
"verification_failed_title": "Verifica non riuscita",
|
||||
"verification_failed_message": "Il controllo di integrità del registro di audit non è riuscito. Controlla i registri di sistema per maggiori dettagli.",
|
||||
"verification_error_message": "Si è verificato un errore inatteso durante la verifica. Riprova."
|
||||
},
|
||||
"jobs": {
|
||||
"title": "Code dei lavori",
|
||||
"queues": "Code dei lavori",
|
||||
"active": "Attivo",
|
||||
"completed": "Completato",
|
||||
"failed": "Fallito",
|
||||
"delayed": "Ritardato",
|
||||
"waiting": "In attesa",
|
||||
"paused": "In pausa",
|
||||
"back_to_queues": "Torna alle code",
|
||||
"queue_overview": "Panoramica della coda",
|
||||
"jobs": "Lavori",
|
||||
"id": "ID",
|
||||
"name": "Nome",
|
||||
"state": "Stato",
|
||||
|
||||
"created_at": "Creato il",
|
||||
"processed_at": "Elaborato il",
|
||||
"finished_at": "Terminato il",
|
||||
"showing": "Visualizzazione di",
|
||||
"of": "di",
|
||||
"previous": "Precedente",
|
||||
"next": "Successivo",
|
||||
"ingestion_source": "Fonte di acquisizione"
|
||||
},
|
||||
"license_page": {
|
||||
"title": "Stato della licenza Enterprise",
|
||||
"meta_description": "Visualizza lo stato attuale della tua licenza Open Archiver Enterprise.",
|
||||
"revoked_title": "Licenza revocata",
|
||||
"revoked_message": "La tua licenza è stata revocata dall'amministratore della licenza. Le funzionalità Enterprise verranno disabilitate {{grace_period}}. Contatta il tuo account manager per assistenza.",
|
||||
"revoked_grace_period": "il {{date}}",
|
||||
"revoked_immediately": "immediatamente",
|
||||
"seat_limit_exceeded_title": "Limite di posti superato",
|
||||
"seat_limit_exceeded_message": "La tua licenza è per {{planSeats}} utenti, ma ne stai attualmente utilizzando {{activeSeats}}. Contatta il reparto vendite per modificare il tuo abbonamento.",
|
||||
"customer": "Cliente",
|
||||
"license_details": "Dettagli licenza",
|
||||
"license_status": "Stato licenza",
|
||||
"active": "Attivo",
|
||||
"expired": "Scaduto",
|
||||
"revoked": "Revocato",
|
||||
"unknown": "Sconosciuto",
|
||||
"expires": "Scade",
|
||||
"seat_usage": "Utilizzo posti",
|
||||
"seats_used": "{{activeSeats}} di {{planSeats}} posti utilizzati",
|
||||
"enabled_features": "Funzionalità abilitate",
|
||||
"enabled_features_description": "Le seguenti funzionalità enterprise sono attualmente abilitate.",
|
||||
"feature": "Funzionalità",
|
||||
"status": "Stato",
|
||||
"enabled": "Abilitato",
|
||||
"disabled": "Disabilitato",
|
||||
"could_not_load_title": "Impossibile caricare la licenza",
|
||||
"could_not_load_message": "Si è verificato un errore inatteso."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,10 +10,20 @@ const handleRequest: RequestHandler = async ({ request, params, fetch }) => {
|
||||
const targetUrl = `${BACKEND_URL}/${slug}${url.search}`;
|
||||
|
||||
try {
|
||||
let body: ArrayBuffer | null = null;
|
||||
const headers = new Headers(request.headers);
|
||||
|
||||
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
||||
body = await request.arrayBuffer();
|
||||
if (body.byteLength > 0) {
|
||||
headers.set('Content-Length', String(body.byteLength));
|
||||
}
|
||||
}
|
||||
|
||||
const proxyRequest = new Request(targetUrl, {
|
||||
method: request.method,
|
||||
headers: request.headers,
|
||||
body: request.body,
|
||||
headers: headers,
|
||||
body: body,
|
||||
duplex: 'half',
|
||||
} as RequestInit);
|
||||
|
||||
|
||||
@@ -64,6 +64,10 @@
|
||||
href: '/dashboard/settings/api-keys',
|
||||
label: $t('app.layout.api_keys'),
|
||||
},
|
||||
{
|
||||
href: '/dashboard/settings/account',
|
||||
label: $t('app.layout.account'),
|
||||
},
|
||||
],
|
||||
position: 5,
|
||||
},
|
||||
|
||||
@@ -133,7 +133,7 @@
|
||||
<Table.Row id={`error-${job.id}`} class="hidden">
|
||||
<Table.Cell colspan={7} class="p-0">
|
||||
<pre
|
||||
class="max-w-full text-wrap rounded-md bg-gray-100 p-4 text-xs">{job.error}</pre>
|
||||
class="bg-muted max-w-full text-wrap rounded-md p-4 text-xs">{job.error}</pre>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { api } from '$lib/server/api';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import type { User } from '@open-archiver/types';
|
||||
|
||||
export const load: PageServerLoad = async (event) => {
|
||||
const response = await api('/users/profile', event);
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
console.error('Failed to fetch profile:', error);
|
||||
// Return null user if failed, handle in UI
|
||||
return { user: null };
|
||||
}
|
||||
const user: User = await response.json();
|
||||
return { user };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
updateProfile: async (event) => {
|
||||
const data = await event.request.formData();
|
||||
const first_name = data.get('first_name');
|
||||
const last_name = data.get('last_name');
|
||||
const email = data.get('email');
|
||||
|
||||
const response = await api('/users/profile', event, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ first_name, last_name, email }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
return fail(response.status, {
|
||||
profileError: true,
|
||||
message: error.message || 'Failed to update profile',
|
||||
});
|
||||
}
|
||||
return { success: true };
|
||||
},
|
||||
updatePassword: async (event) => {
|
||||
const data = await event.request.formData();
|
||||
const currentPassword = data.get('currentPassword');
|
||||
const newPassword = data.get('newPassword');
|
||||
|
||||
const response = await api('/users/profile/password', event, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ currentPassword, newPassword }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
return fail(response.status, {
|
||||
passwordError: true,
|
||||
message: error.message || 'Failed to update password',
|
||||
});
|
||||
}
|
||||
return { success: true };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,218 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { t } from '$lib/translations';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
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);
|
||||
|
||||
let isProfileDialogOpen = $state(false);
|
||||
let isPasswordDialogOpen = $state(false);
|
||||
let isSubmitting = $state(false);
|
||||
|
||||
// Profile form state
|
||||
let profileFirstName = $state('');
|
||||
let profileLastName = $state('');
|
||||
let profileEmail = $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 || '';
|
||||
}
|
||||
});
|
||||
|
||||
// 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 openPasswordDialog() {
|
||||
currentPassword = '';
|
||||
newPassword = '';
|
||||
confirmNewPassword = '';
|
||||
isPasswordDialogOpen = true;
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$t('app.account.title')} - OpenArchiver</title>
|
||||
</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>
|
||||
|
||||
<!-- 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>
|
||||
</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.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.Root>
|
||||
@@ -24,6 +24,7 @@
|
||||
{ value: 'pt', label: '🇵🇹 Português' },
|
||||
{ value: 'nl', label: '🇳🇱 Nederlands' },
|
||||
{ value: 'el', label: '🇬🇷 Ελληνικά' },
|
||||
{ value: 'bg', label: '🇧🇬 български' },
|
||||
{ value: 'ja', label: '🇯🇵 日本語' },
|
||||
];
|
||||
|
||||
|
||||
@@ -72,20 +72,23 @@ export interface Microsoft365Credentials extends BaseIngestionCredentials {
|
||||
|
||||
export interface PSTImportCredentials extends BaseIngestionCredentials {
|
||||
type: 'pst_import';
|
||||
uploadedFileName: string;
|
||||
uploadedFilePath: string;
|
||||
uploadedFileName?: string;
|
||||
uploadedFilePath?: string;
|
||||
localFilePath?: string;
|
||||
}
|
||||
|
||||
export interface EMLImportCredentials extends BaseIngestionCredentials {
|
||||
type: 'eml_import';
|
||||
uploadedFileName: string;
|
||||
uploadedFilePath: string;
|
||||
uploadedFileName?: string;
|
||||
uploadedFilePath?: string;
|
||||
localFilePath?: string;
|
||||
}
|
||||
|
||||
export interface MboxImportCredentials extends BaseIngestionCredentials {
|
||||
type: 'mbox_import';
|
||||
uploadedFileName: string;
|
||||
uploadedFilePath: string;
|
||||
uploadedFileName?: string;
|
||||
uploadedFilePath?: string;
|
||||
localFilePath?: string;
|
||||
}
|
||||
|
||||
// Discriminated union for all possible credential types
|
||||
|
||||
Reference in New Issue
Block a user