* 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
# If you run Valkey service from Docker Compose, set the REDIS_TLS_ENABLED variable to false.
REDIS_TLS_ENABLED=false
# Redis username. Only required if not using the default user.
REDIS_USER=notdefaultuser
# --- Storage Settings ---

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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 (
(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))
) {

View File

@@ -32,17 +32,52 @@ 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) {
const filePath = this.getFilePath();
if (!filePath) {
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.');
}
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.');
throw Error('EML file not found or upload not finished yet, please wait.');
}
return true;
@@ -53,8 +88,7 @@ export class EMLConnector implements IEmailConnector {
}
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 +102,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 +114,150 @@ export class EMLConnector implements IEmailConnector {
dest.on('error', reject);
});
await this.extract(zipFilePath, unzippedPath);
const files = await this.getAllFiles(unzippedPath);
for (const file of files) {
if (file.endsWith('.eml')) {
try {
// logger.info({ file }, 'Processing EML file.');
const stream = createReadStream(file);
const content = await streamToBuffer(stream);
// logger.info({ file, size: content.length }, 'Read file to buffer.');
let relativePath = file.substring(unzippedPath.length + 1);
if (dirname(relativePath) === '.') {
relativePath = '';
} else {
relativePath = dirname(relativePath);
}
const emailObject = await this.parseMessage(content, relativePath);
// logger.info({ file, messageId: emailObject.id }, 'Parsed email message.');
yield emailObject;
} catch (error) {
logger.error(
{ error, file },
'Failed to process a single EML file. Skipping.'
);
}
}
}
yield* this.processZipEntries(zipFilePath);
} catch (error) {
logger.error({ error }, 'Failed to fetch email.');
throw error;
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
try {
await this.storage.delete(this.credentials.uploadedFilePath);
} catch (error) {
logger.error(
{ error, file: this.credentials.uploadedFilePath },
'Failed to delete EML file after processing.'
);
if (this.credentials.uploadedFilePath && !this.credentials.localFilePath) {
try {
await this.storage.delete(this.credentials.uploadedFilePath);
} catch (error) {
logger.error(
{ error, file: this.credentials.uploadedFilePath },
'Failed to delete EML file after processing.'
);
}
}
}
}
private extract(zipFilePath: string, dest: string): Promise<void> {
return new Promise((resolve, reject) => {
private async *processZipEntries(zipFilePath: string): AsyncGenerator<EmailObject | null> {
// Open the ZIP file.
// Note: yauzl requires random access, so we must use the file on disk.
const zipfile = await new Promise<yauzl.ZipFile>((resolve, reject) => {
yauzl.open(zipFilePath, { lazyEntries: true, decodeStrings: false }, (err, zipfile) => {
if (err) reject(err);
zipfile.on('error', reject);
zipfile.readEntry();
zipfile.on('entry', (entry) => {
const fileName = entry.fileName.toString('utf8');
// Ignore macOS-specific metadata files.
if (fileName.startsWith('__MACOSX/')) {
zipfile.readEntry();
return;
}
const entryPath = join(dest, fileName);
if (/\/$/.test(fileName)) {
fs.mkdir(entryPath, { recursive: true })
.then(() => zipfile.readEntry())
.catch(reject);
} else {
zipfile.openReadStream(entry, (err, readStream) => {
if (err) reject(err);
const writeStream = createWriteStream(entryPath);
readStream.pipe(writeStream);
writeStream.on('finish', () => zipfile.readEntry());
writeStream.on('error', reject);
});
}
});
zipfile.on('end', () => resolve());
if (err || !zipfile) return reject(err);
resolve(zipfile);
});
});
}
private async getAllFiles(dirPath: string, arrayOfFiles: string[] = []): Promise<string[]> {
const files = await fs.readdir(dirPath);
// Create an async iterator for zip entries
const entryIterator = this.zipEntryGenerator(zipfile);
for (const file of files) {
const fullPath = join(dirPath, file);
if ((await fs.stat(fullPath)).isDirectory()) {
await this.getAllFiles(fullPath, arrayOfFiles);
} else {
arrayOfFiles.push(fullPath);
for await (const { entry, openReadStream } of entryIterator) {
const fileName = entry.fileName.toString();
if (fileName.startsWith('__MACOSX/') || /\/$/.test(fileName)) {
continue;
}
if (fileName.endsWith('.eml')) {
try {
const readStream = await openReadStream();
const relativePath = dirname(fileName) === '.' ? '' : dirname(fileName);
const emailObject = await this.parseMessage(readStream, relativePath);
yield emailObject;
} catch (error) {
logger.error(
{ error, file: fileName },
'Failed to process a single EML file from zip. Skipping.'
);
}
}
}
return arrayOfFiles;
}
private async parseMessage(emlBuffer: Buffer, path: string): Promise<EmailObject> {
private async *zipEntryGenerator(
zipfile: yauzl.ZipFile
): AsyncGenerator<{ entry: yauzl.Entry; openReadStream: () => Promise<Readable> }> {
let resolveNext: ((value: any) => void) | null = null;
let rejectNext: ((reason?: any) => void) | null = null;
let finished = false;
const queue: yauzl.Entry[] = [];
zipfile.readEntry();
zipfile.on('entry', (entry) => {
if (resolveNext) {
const resolve = resolveNext;
resolveNext = null;
rejectNext = null;
resolve(entry);
} else {
queue.push(entry);
}
});
zipfile.on('end', () => {
finished = true;
if (resolveNext) {
const resolve = resolveNext;
resolveNext = null;
rejectNext = null;
resolve(null); // Signal end
}
});
zipfile.on('error', (err) => {
finished = true;
if (rejectNext) {
const reject = rejectNext;
resolveNext = null;
rejectNext = null;
reject(err);
}
});
while (!finished || queue.length > 0) {
if (queue.length > 0) {
const entry = queue.shift()!;
yield {
entry,
openReadStream: () =>
new Promise<Readable>((resolve, reject) => {
zipfile.openReadStream(entry, (err, stream) => {
if (err || !stream) return reject(err);
resolve(stream);
});
}),
};
zipfile.readEntry(); // Read next entry only after yielding
} else {
const entry = await new Promise<yauzl.Entry | null>((resolve, reject) => {
resolveNext = resolve;
rejectNext = reject;
});
if (entry) {
yield {
entry,
openReadStream: () =>
new Promise<Readable>((resolve, reject) => {
zipfile.openReadStream(entry, (err, stream) => {
if (err || !stream) return reject(err);
resolve(stream);
});
}),
};
zipfile.readEntry(); // Read next entry only after yielding
} else {
break; // End of zip
}
}
}
}
private async parseMessage(
input: Buffer | Readable,
path: string
): Promise<EmailObject> {
let emlBuffer: Buffer;
if (Buffer.isBuffer(input)) {
emlBuffer = input;
} else {
emlBuffer = await streamToBuffer(input);
}
const parsedEmail: ParsedMail = await simpleParser(emlBuffer);
const attachments = parsedEmail.attachments.map((attachment: Attachment) => ({

View File

@@ -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,15 +61,28 @@ 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.');
throw Error('Mbox file not found or upload not finished yet, please wait.');
}
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> {
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 +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(
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 +140,21 @@ export class MboxConnector implements IEmailConnector {
yield emailObject;
} catch (error) {
logger.error(
{ error, file: this.credentials.uploadedFilePath },
{ error, file: filePath },
'Failed to process a single message from mbox file. Skipping.'
);
}
}
// After the stream is fully consumed, delete the file.
// The `for await...of` loop ensures streams are properly closed on completion,
// so we can safely delete the file here without causing a hang.
try {
await this.storage.delete(this.credentials.uploadedFilePath);
} catch (error) {
logger.error(
{ error, file: this.credentials.uploadedFilePath },
'Failed to delete mbox file after processing.'
);
if (this.credentials.uploadedFilePath && !this.credentials.localFilePath) {
try {
await this.storage.delete(filePath);
} catch (error) {
logger.error(
{ error, file: filePath },
'Failed to delete mbox file after processing.'
);
}
}
}

View File

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

View File

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

View File

@@ -0,0 +1,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_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",

View File

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

View File

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

View File

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