* fix(api): correct API key generation and proxy handling

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

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

* User profile/account page, change password, API

* docs(api): update ingestion source provider values

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

* updating tag

* feat: add REDIS_USER env variable (#172)

* feat: add REDIS_USER env variable

fixes #171

* add proper type for bullmq config

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

* Bulgarian UI Support added

* BG language UI support - Create translation.json

* update redis config logic

* Update Bulgarian language setting, register language

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

* Add agents AI doc

* Allow local file path for Mbox file ingestion


---------

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

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

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

* docs: add meilisearch dumpless upgrade guide and snapshot config

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

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

* build(coolify): enable daily snapshots for meilisearch

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

---------

Co-authored-by: Antonia Schwennesen <53372671+zophiana@users.noreply.github.com>
Co-authored-by: IT Creativity + Art Team <admin@it-playground.net>
Co-authored-by: Jan Berdajs <mrbrdo@gmail.com>
This commit is contained in:
Wei S.
2026-02-23 21:25:44 +01:00
committed by GitHub
parent cf121989ae
commit 7dac3b2bfd
22 changed files with 917 additions and 177 deletions

View File

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

View File

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

View File

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

View File

@@ -30,7 +30,14 @@ archive.zip
2. Click the **Create New** button. 2. Click the **Create New** button.
3. Select **EML Import** as the provider. 3. Select **EML Import** as the provider.
4. Enter a name for the ingestion source. 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. 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. OpenArchiver will then start importing the EML files from the zip archive. The ingestion process may take some time, depending on the size of the archive.

View File

@@ -17,7 +17,13 @@ Once you have your `.mbox` file, you can upload it to OpenArchiver through the w
1. Navigate to the **Ingestion** page. 1. Navigate to the **Ingestion** page.
2. Click on the **New Ingestion** button. 2. Click on the **New Ingestion** button.
3. Select **Mbox** as the source type. 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 ## 3. Folder Structure

View File

@@ -15,7 +15,14 @@ To ensure a successful import, you should prepare your PST file according to the
2. Click the **Create New** button. 2. Click the **Create New** button.
3. Select **PST Import** as the provider. 3. Select **PST Import** as the provider.
4. Enter a name for the ingestion source. 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. 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. OpenArchiver will then start importing the emails from the PST file. The ingestion process may take some time, depending on the size of the file.

View File

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

View File

@@ -4,9 +4,57 @@ Meilisearch, the search engine used by Open Archiver, requires a manual data mig
If an Open Archiver upgrade includes a major Meilisearch version change, you will need to migrate your search index by following the process below. 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 ### Step 1: Create a Dump

View File

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

View File

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

View File

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

View File

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

View File

@@ -219,7 +219,8 @@ export class IngestionService {
if ( if (
(source.credentials.type === 'pst_import' || (source.credentials.type === 'pst_import' ||
source.credentials.type === 'eml_import') && source.credentials.type === 'eml_import' ||
source.credentials.type === 'mbox_import') &&
source.credentials.uploadedFilePath && source.credentials.uploadedFilePath &&
(await storage.exists(source.credentials.uploadedFilePath)) (await storage.exists(source.credentials.uploadedFilePath))
) { ) {

View File

@@ -32,17 +32,52 @@ export class EMLConnector implements IEmailConnector {
this.storage = new StorageService(); 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> { public async testConnection(): Promise<boolean> {
try { try {
if (!this.credentials.uploadedFilePath) { const filePath = this.getFilePath();
if (!filePath) {
throw Error('EML file path not provided.'); throw Error('EML file path not provided.');
} }
if (!this.credentials.uploadedFilePath.includes('.zip')) { if (!filePath.includes('.zip')) {
throw Error('Provided file is not in the ZIP format.'); 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) { if (!fileExist) {
throw Error('EML file upload not finished yet, please wait.'); throw Error('EML file not found or upload not finished yet, please wait.');
} }
return true; return true;
@@ -53,8 +88,7 @@ export class EMLConnector implements IEmailConnector {
} }
public async *listAllUsers(): AsyncGenerator<MailboxUser> { public async *listAllUsers(): AsyncGenerator<MailboxUser> {
const displayName = const displayName = this.getDisplayName();
this.credentials.uploadedFileName || `eml-import-${new Date().getTime()}`;
logger.info(`Found potential mailbox: ${displayName}`); logger.info(`Found potential mailbox: ${displayName}`);
const constructedPrimaryEmail = `${displayName.replace(/ /g, '.').toLowerCase()}@eml.local`; const constructedPrimaryEmail = `${displayName.replace(/ /g, '.').toLowerCase()}@eml.local`;
yield { yield {
@@ -68,10 +102,8 @@ export class EMLConnector implements IEmailConnector {
userEmail: string, userEmail: string,
syncState?: SyncState | null syncState?: SyncState | null
): AsyncGenerator<EmailObject | 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 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'); const zipFilePath = join(tempDir, 'eml.zip');
try { try {
@@ -82,99 +114,150 @@ export class EMLConnector implements IEmailConnector {
dest.on('error', reject); dest.on('error', reject);
}); });
await this.extract(zipFilePath, unzippedPath); yield* this.processZipEntries(zipFilePath);
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.'
);
}
}
}
} catch (error) { } catch (error) {
logger.error({ error }, 'Failed to fetch email.'); logger.error({ error }, 'Failed to fetch email.');
throw error; throw error;
} finally { } finally {
await fs.rm(tempDir, { recursive: true, force: true }); await fs.rm(tempDir, { recursive: true, force: true });
try { if (this.credentials.uploadedFilePath && !this.credentials.localFilePath) {
await this.storage.delete(this.credentials.uploadedFilePath); try {
} catch (error) { await this.storage.delete(this.credentials.uploadedFilePath);
logger.error( } catch (error) {
{ error, file: this.credentials.uploadedFilePath }, logger.error(
'Failed to delete EML file after processing.' { error, file: this.credentials.uploadedFilePath },
); 'Failed to delete EML file after processing.'
);
}
} }
} }
} }
private extract(zipFilePath: string, dest: string): Promise<void> { private async *processZipEntries(zipFilePath: string): AsyncGenerator<EmailObject | null> {
return new Promise((resolve, reject) => { // 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) => { yauzl.open(zipFilePath, { lazyEntries: true, decodeStrings: false }, (err, zipfile) => {
if (err) reject(err); if (err || !zipfile) return reject(err);
zipfile.on('error', reject); resolve(zipfile);
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());
}); });
}); });
}
private async getAllFiles(dirPath: string, arrayOfFiles: string[] = []): Promise<string[]> { // Create an async iterator for zip entries
const files = await fs.readdir(dirPath); const entryIterator = this.zipEntryGenerator(zipfile);
for (const file of files) { for await (const { entry, openReadStream } of entryIterator) {
const fullPath = join(dirPath, file); const fileName = entry.fileName.toString();
if ((await fs.stat(fullPath)).isDirectory()) { if (fileName.startsWith('__MACOSX/') || /\/$/.test(fileName)) {
await this.getAllFiles(fullPath, arrayOfFiles); continue;
} else { }
arrayOfFiles.push(fullPath);
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 parsedEmail: ParsedMail = await simpleParser(emlBuffer);
const attachments = parsedEmail.attachments.map((attachment: Attachment) => ({ const attachments = parsedEmail.attachments.map((attachment: Attachment) => ({

View File

@@ -12,6 +12,7 @@ import { getThreadId } from './helpers/utils';
import { StorageService } from '../StorageService'; import { StorageService } from '../StorageService';
import { Readable, Transform } from 'stream'; import { Readable, Transform } from 'stream';
import { createHash } from 'crypto'; import { createHash } from 'crypto';
import { promises as fs, createReadStream } from 'fs';
class MboxSplitter extends Transform { class MboxSplitter extends Transform {
private buffer: Buffer = Buffer.alloc(0); private buffer: Buffer = Buffer.alloc(0);
@@ -60,15 +61,28 @@ export class MboxConnector implements IEmailConnector {
public async testConnection(): Promise<boolean> { public async testConnection(): Promise<boolean> {
try { try {
if (!this.credentials.uploadedFilePath) { const filePath = this.getFilePath();
if (!filePath) {
throw Error('Mbox file path not provided.'); 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.'); 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) { if (!fileExist) {
throw Error('Mbox file upload not finished yet, please wait.'); throw Error('Mbox file not found or upload not finished yet, please wait.');
} }
return true; return true;
@@ -78,9 +92,19 @@ export class MboxConnector implements IEmailConnector {
} }
} }
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> { public async *listAllUsers(): AsyncGenerator<MailboxUser> {
const displayName = const displayName = this.getDisplayName();
this.credentials.uploadedFileName || `mbox-import-${new Date().getTime()}`;
logger.info(`Found potential mailbox: ${displayName}`); logger.info(`Found potential mailbox: ${displayName}`);
const constructedPrimaryEmail = `${displayName.replace(/ /g, '.').toLowerCase()}@mbox.local`; const constructedPrimaryEmail = `${displayName.replace(/ /g, '.').toLowerCase()}@mbox.local`;
yield { yield {
@@ -90,11 +114,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( public async *fetchEmails(
userEmail: string, userEmail: string,
syncState?: SyncState | null syncState?: SyncState | null
): AsyncGenerator<EmailObject | 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 mboxSplitter = new MboxSplitter();
const emailStream = fileStream.pipe(mboxSplitter); const emailStream = fileStream.pipe(mboxSplitter);
@@ -104,22 +140,21 @@ export class MboxConnector implements IEmailConnector {
yield emailObject; yield emailObject;
} catch (error) { } catch (error) {
logger.error( logger.error(
{ error, file: this.credentials.uploadedFilePath }, { error, file: filePath },
'Failed to process a single message from mbox file. Skipping.' 'Failed to process a single message from mbox file. Skipping.'
); );
} }
} }
// After the stream is fully consumed, delete the file. if (this.credentials.uploadedFilePath && !this.credentials.localFilePath) {
// The `for await...of` loop ensures streams are properly closed on completion, try {
// so we can safely delete the file here without causing a hang. await this.storage.delete(filePath);
try { } catch (error) {
await this.storage.delete(this.credentials.uploadedFilePath); logger.error(
} catch (error) { { error, file: filePath },
logger.error( 'Failed to delete mbox file after processing.'
{ error, file: this.credentials.uploadedFilePath }, );
'Failed to delete mbox file after processing.' }
);
} }
} }

View File

@@ -14,7 +14,7 @@ import { StorageService } from '../StorageService';
import { Readable } from 'stream'; import { Readable } from 'stream';
import { createHash } from 'crypto'; import { createHash } from 'crypto';
import { join } from 'path'; 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. // 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([ const DELETED_FOLDERS = new Set([
@@ -111,8 +111,19 @@ export class PSTConnector implements IEmailConnector {
this.storage = new StorageService(); 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 }> { 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 tempDir = await fs.mkdtemp(join('/tmp', `pst-import-${new Date().getTime()}`));
const tempFilePath = join(tempDir, 'temp.pst'); const tempFilePath = join(tempDir, 'temp.pst');
@@ -129,15 +140,28 @@ export class PSTConnector implements IEmailConnector {
public async testConnection(): Promise<boolean> { public async testConnection(): Promise<boolean> {
try { try {
if (!this.credentials.uploadedFilePath) { const filePath = this.getFilePath();
if (!filePath) {
throw Error('PST file path not provided.'); 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.'); 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) { if (!fileExist) {
throw Error('PST file upload not finished yet, please wait.'); throw Error('PST file not found or upload not finished yet, please wait.');
} }
return true; return true;
} catch (error) { } catch (error) {
@@ -200,13 +224,15 @@ export class PSTConnector implements IEmailConnector {
if (tempDir) { if (tempDir) {
await fs.rm(tempDir, { recursive: true, force: true }); await fs.rm(tempDir, { recursive: true, force: true });
} }
try { if (this.credentials.uploadedFilePath && !this.credentials.localFilePath) {
await this.storage.delete(this.credentials.uploadedFilePath); try {
} catch (error) { await this.storage.delete(this.credentials.uploadedFilePath);
logger.error( } catch (error) {
{ error, file: this.credentials.uploadedFilePath }, logger.error(
'Failed to delete PST file after processing.' { error, file: this.credentials.uploadedFilePath },
); 'Failed to delete PST file after processing.'
);
}
} }
} }
} }

View File

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

View File

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

View File

@@ -199,6 +199,10 @@
"provider_eml_import": "EML Import", "provider_eml_import": "EML Import",
"provider_mbox_import": "Mbox Import", "provider_mbox_import": "Mbox Import",
"select_provider": "Select a provider", "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": "Service Account Key (JSON)",
"service_account_key_placeholder": "Paste your service account key JSON content", "service_account_key_placeholder": "Paste your service account key JSON content",
"impersonated_admin_email": "Impersonated Admin Email", "impersonated_admin_email": "Impersonated Admin Email",

View File

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

View File

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

View File

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