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

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

|
||||
_Full-text search across all your emails and attachments_
|
||||
|
||||
## 👨👩👧👦 Join our community!
|
||||
## Join our community!
|
||||
|
||||
We are committed to build an engaging community around Open Archiver, and we are inviting all of you to join our community on Discord to get real-time support and connect with the team.
|
||||
We are committed to building an engaging community around Open Archiver, and we are inviting all of you to join our community on Discord to get real-time support and connect with the team.
|
||||
|
||||
[](https://discord.gg/MTtD7BhuTQ)
|
||||
|
||||
@@ -34,11 +34,11 @@ We are committed to build an engaging community around Open Archiver, and we are
|
||||
|
||||
Check out the live demo here: https://demo.openarchiver.com
|
||||
|
||||
Username: admin@local.com
|
||||
Username: demo@openarchiver.com
|
||||
|
||||
Password: openarchiver_demo
|
||||
|
||||
## ✨ Key Features
|
||||
## Key Features
|
||||
|
||||
- **Universal Ingestion**: Connect to any email provider to perform initial bulk imports and maintain continuous, real-time synchronization. Ingestion sources include:
|
||||
- IMAP connection
|
||||
@@ -57,7 +57,7 @@ Password: openarchiver_demo
|
||||
- - Each archived email comes with an "Integrity Report" feature that indicates if the files are original.
|
||||
- **Comprehensive Auditing**: An immutable audit trail logs all system activities, ensuring you have a clear record of who accessed what and when.
|
||||
|
||||
## 🛠️ Tech Stack
|
||||
## Tech Stack
|
||||
|
||||
Open Archiver is built on a modern, scalable, and maintainable technology stack:
|
||||
|
||||
@@ -68,7 +68,7 @@ Open Archiver is built on a modern, scalable, and maintainable technology stack:
|
||||
- **Database**: PostgreSQL for metadata, user management, and audit logs
|
||||
- **Deployment**: Docker Compose deployment
|
||||
|
||||
## 📦 Deployment
|
||||
## Deployment
|
||||
|
||||
### Prerequisites
|
||||
|
||||
@@ -104,7 +104,7 @@ Open Archiver is built on a modern, scalable, and maintainable technology stack:
|
||||
4. **Access the application:**
|
||||
Once the services are running, you can access the Open Archiver web interface by navigating to `http://localhost:3000` in your web browser.
|
||||
|
||||
## ⚙️ Data Source Configuration
|
||||
## Data Source Configuration
|
||||
|
||||
After deploying the application, you will need to configure one or more ingestion sources to begin archiving emails. Follow our detailed guides to connect to your email provider:
|
||||
|
||||
@@ -112,7 +112,7 @@ After deploying the application, you will need to configure one or more ingestio
|
||||
- [Connecting to Microsoft 365](https://docs.openarchiver.com/user-guides/email-providers/imap.html)
|
||||
- [Connecting to a Generic IMAP Server](https://docs.openarchiver.com/user-guides/email-providers/imap.html)
|
||||
|
||||
## 🤝 Contributing
|
||||
## Contributing
|
||||
|
||||
We welcome contributions from the community!
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ services:
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MEILI_MASTER_KEY: ${MEILI_MASTER_KEY:-aSampleMasterKey}
|
||||
MEILI_SCHEDULE_SNAPSHOT: ${MEILI_SCHEDULE_SNAPSHOT:-86400}
|
||||
volumes:
|
||||
- meilidata:/meili_data
|
||||
networks:
|
||||
|
||||
@@ -6,7 +6,7 @@ export default defineConfig({
|
||||
'script',
|
||||
{
|
||||
defer: '',
|
||||
src: 'https://analytics.zenceipt.com/script.js',
|
||||
src: 'https://analytics.openarchiver.com/script.js',
|
||||
'data-website-id': '2c8b452e-eab5-4f82-8ead-902d8f8b976f',
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -30,7 +30,14 @@ archive.zip
|
||||
2. Click the **Create New** button.
|
||||
3. Select **EML Import** as the provider.
|
||||
4. Enter a name for the ingestion source.
|
||||
5. Click the **Choose File** button and select the zip archive containing your EML files.
|
||||
5. **Choose Import Method:**
|
||||
* **Upload File:** Click **Choose File** and select the zip archive containing your EML files. (Best for smaller archives)
|
||||
* **Local Path:** Enter the path to the zip file **inside the container**. (Best for large archives)
|
||||
|
||||
> **Note on Local Path:** When using Docker, the "Local Path" is relative to the container's filesystem.
|
||||
> * **Recommended:** Place your zip file in a `temp` folder inside your configured storage directory (`STORAGE_LOCAL_ROOT_PATH`). This path is already mounted. For example, if your storage path is `/data`, put the file in `/data/temp/emails.zip` and enter `/data/temp/emails.zip` as the path.
|
||||
> * **Alternative:** Mount a separate volume in `docker-compose.yml` (e.g., `- /host/path:/container/path`) and use the container path.
|
||||
|
||||
6. Click the **Submit** button.
|
||||
|
||||
OpenArchiver will then start importing the EML files from the zip archive. The ingestion process may take some time, depending on the size of the archive.
|
||||
|
||||
@@ -17,7 +17,13 @@ Once you have your `.mbox` file, you can upload it to OpenArchiver through the w
|
||||
1. Navigate to the **Ingestion** page.
|
||||
2. Click on the **New Ingestion** button.
|
||||
3. Select **Mbox** as the source type.
|
||||
4. Upload your `.mbox` file.
|
||||
4. **Choose Import Method:**
|
||||
* **Upload File:** Upload your `.mbox` file.
|
||||
* **Local Path:** Enter the path to the mbox file **inside the container**.
|
||||
|
||||
> **Note on Local Path:** When using Docker, the "Local Path" is relative to the container's filesystem.
|
||||
> * **Recommended:** Place your mbox file in a `temp` folder inside your configured storage directory (`STORAGE_LOCAL_ROOT_PATH`). This path is already mounted. For example, if your storage path is `/data`, put the file in `/data/temp/emails.mbox` and enter `/data/temp/emails.mbox` as the path.
|
||||
> * **Alternative:** Mount a separate volume in `docker-compose.yml` (e.g., `- /host/path:/container/path`) and use the container path.
|
||||
|
||||
## 3. Folder Structure
|
||||
|
||||
|
||||
@@ -15,7 +15,14 @@ To ensure a successful import, you should prepare your PST file according to the
|
||||
2. Click the **Create New** button.
|
||||
3. Select **PST Import** as the provider.
|
||||
4. Enter a name for the ingestion source.
|
||||
5. Click the **Choose File** button and select the PST file.
|
||||
5. **Choose Import Method:**
|
||||
* **Upload File:** Click **Choose File** and select the PST file from your computer. (Best for smaller files)
|
||||
* **Local Path:** Enter the path to the PST file **inside the container**. (Best for large files)
|
||||
|
||||
> **Note on Local Path:** When using Docker, the "Local Path" is relative to the container's filesystem.
|
||||
> * **Recommended:** Place your file in a `temp` folder inside your configured storage directory (`STORAGE_LOCAL_ROOT_PATH`). This path is already mounted. For example, if your storage path is `/data`, put the file in `/data/temp/archive.pst` and enter `/data/temp/archive.pst` as the path.
|
||||
> * **Alternative:** Mount a separate volume in `docker-compose.yml` (e.g., `- /host/path:/container/path`) and use the container path.
|
||||
|
||||
6. Click the **Submit** button.
|
||||
|
||||
OpenArchiver will then start importing the emails from the PST file. The ingestion process may take some time, depending on the size of the file.
|
||||
|
||||
@@ -115,6 +115,7 @@ These variables are used by `docker-compose.yml` to configure the services.
|
||||
| `MEILI_INDEXING_BATCH` | The number of emails to batch together for indexing. | `500` |
|
||||
| `REDIS_HOST` | The host for the Valkey (Redis) service. | `valkey` |
|
||||
| `REDIS_PORT` | The port for the Valkey (Redis) service. | `6379` |
|
||||
| `REDIS_USER` | Optional Redis username if ACLs are used. | |
|
||||
| `REDIS_PASSWORD` | The password for the Valkey (Redis) service. | `defaultredispassword` |
|
||||
| `REDIS_TLS_ENABLED` | Enable or disable TLS for Redis. | `false` |
|
||||
|
||||
|
||||
@@ -4,9 +4,57 @@ Meilisearch, the search engine used by Open Archiver, requires a manual data mig
|
||||
|
||||
If an Open Archiver upgrade includes a major Meilisearch version change, you will need to migrate your search index by following the process below.
|
||||
|
||||
## Migration Process Overview
|
||||
## Experimental: Dumpless Upgrade
|
||||
|
||||
For self-hosted instances using Docker Compose (as recommended), the migration process involves creating a data dump from your current Meilisearch instance, upgrading the Docker image, and then importing that dump into the new version.
|
||||
> **Warning:** This feature is currently **experimental**. We do not recommend using it for production environments until it is marked as stable. Please use the [standard migration process](#standard-migration-process-recommended) instead. Proceed with caution.
|
||||
|
||||
Meilisearch recently introduced an experimental "dumpless" upgrade method. This allows you to migrate the database to a new Meilisearch version without manually creating and importing a dump. However, please note that **dumpless upgrades are not currently atomic**. If the process fails, your database may become corrupted, resulting in data loss.
|
||||
|
||||
**Prerequisite: Create a Snapshot**
|
||||
|
||||
Before attempting a dumpless upgrade, you **must** take a snapshot of your instance. This ensures you have a recovery point if the upgrade fails. Learn how to create snapshots in the [official Meilisearch documentation](https://www.meilisearch.com/docs/learn/data_backup/snapshots).
|
||||
|
||||
### How to Enable
|
||||
|
||||
To perform a dumpless upgrade, you need to configure your Meilisearch instance with the experimental flag. You can do this in one of two ways:
|
||||
|
||||
**Option 1: Using an Environment Variable**
|
||||
|
||||
Add the `MEILI_EXPERIMENTAL_DUMPLESS_UPGRADE` environment variable to your `docker-compose.yml` file for the Meilisearch service.
|
||||
|
||||
```yaml
|
||||
services:
|
||||
meilisearch:
|
||||
image: getmeili/meilisearch:v1.x # The new version you want to upgrade to
|
||||
environment:
|
||||
- MEILI_MASTER_KEY=${MEILI_MASTER_KEY}
|
||||
- MEILI_EXPERIMENTAL_DUMPLESS_UPGRADE=true
|
||||
```
|
||||
|
||||
**Option 2: Using a CLI Option**
|
||||
|
||||
Alternatively, you can pass the `--experimental-dumpless-upgrade` flag in the command section of your `docker-compose.yml`.
|
||||
|
||||
```yaml
|
||||
services:
|
||||
meilisearch:
|
||||
image: getmeili/meilisearch:v1.x # The new version you want to upgrade to
|
||||
command: meilisearch --experimental-dumpless-upgrade
|
||||
```
|
||||
|
||||
After updating your configuration, restart your container:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Meilisearch will attempt to migrate your database to the new version automatically.
|
||||
|
||||
---
|
||||
|
||||
## Standard Migration Process (Recommended)
|
||||
|
||||
For self-hosted instances using Docker Compose, the recommended migration process involves creating a data dump from your current Meilisearch instance, upgrading the Docker image, and then importing that dump into the new version.
|
||||
|
||||
### Step 1: Create a Dump
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ services:
|
||||
- MEILI_HOST=http://meilisearch:7700
|
||||
- REDIS_HOST=valkey
|
||||
- REDIS_PORT=6379
|
||||
- REDIS_USER=default
|
||||
- REDIS_PASSWORD=${SERVICE_PASSWORD_VALKEY}
|
||||
- REDIS_TLS_ENABLED=false
|
||||
- STORAGE_TYPE=${STORAGE_TYPE:-local}
|
||||
@@ -73,5 +74,6 @@ services:
|
||||
image: getmeili/meilisearch:v1.15
|
||||
environment:
|
||||
- MEILI_MASTER_KEY=${SERVICE_PASSWORD_MEILISEARCH}
|
||||
- MEILI_SCHEDULE_SNAPSHOT=86400
|
||||
volumes:
|
||||
- meilidata:/meili_data
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "open-archiver",
|
||||
"version": "0.4.1",
|
||||
"version": "0.4.2",
|
||||
"private": true,
|
||||
"license": "SEE LICENSE IN LICENSE file",
|
||||
"scripts": {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Request, Response } from 'express';
|
||||
import { ApiKeyService } from '../../services/ApiKeyService';
|
||||
import { z } from 'zod';
|
||||
import { UserService } from '../../services/UserService';
|
||||
import { config } from '../../config';
|
||||
|
||||
const generateApiKeySchema = z.object({
|
||||
name: z
|
||||
@@ -18,6 +19,9 @@ export class ApiKeyController {
|
||||
private userService = new UserService();
|
||||
public generateApiKey = async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (config.app.isDemo) {
|
||||
return res.status(403).json({ message: req.t('errors.demoMode') });
|
||||
}
|
||||
const { name, expiresInDays } = generateApiKeySchema.parse(req.body);
|
||||
if (!req.user || !req.user.sub) {
|
||||
return res.status(401).json({ message: 'Unauthorized' });
|
||||
@@ -58,6 +62,9 @@ export class ApiKeyController {
|
||||
};
|
||||
|
||||
public deleteApiKey = async (req: Request, res: Response) => {
|
||||
if (config.app.isDemo) {
|
||||
return res.status(403).json({ message: req.t('errors.demoMode') });
|
||||
}
|
||||
const { id } = req.params;
|
||||
if (!req.user || !req.user.sub) {
|
||||
return res.status(401).json({ message: 'Unauthorized' });
|
||||
|
||||
@@ -3,6 +3,7 @@ import { UserService } from '../../services/UserService';
|
||||
import * as schema from '../../database/schema';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { db } from '../../database';
|
||||
import { config } from '../../config';
|
||||
|
||||
const userService = new UserService();
|
||||
|
||||
@@ -92,6 +93,9 @@ export const getProfile = async (req: Request, res: Response) => {
|
||||
};
|
||||
|
||||
export const updateProfile = async (req: Request, res: Response) => {
|
||||
if (config.app.isDemo) {
|
||||
return res.status(403).json({ message: req.t('errors.demoMode') });
|
||||
}
|
||||
const { email, first_name, last_name } = req.body;
|
||||
if (!req.user || !req.user.sub) {
|
||||
return res.status(401).json({ message: 'Unauthorized' });
|
||||
@@ -111,6 +115,9 @@ export const updateProfile = async (req: Request, res: Response) => {
|
||||
};
|
||||
|
||||
export const updatePassword = async (req: Request, res: Response) => {
|
||||
if (config.app.isDemo) {
|
||||
return res.status(403).json({ message: req.t('errors.demoMode') });
|
||||
}
|
||||
const { currentPassword, newPassword } = req.body;
|
||||
if (!req.user || !req.user.sub) {
|
||||
return res.status(401).json({ message: 'Unauthorized' });
|
||||
|
||||
@@ -7,4 +7,5 @@ export const app = {
|
||||
syncFrequency: process.env.SYNC_FREQUENCY || '* * * * *', //default to 1 minute
|
||||
enableDeletion: process.env.ENABLE_DELETION === 'true',
|
||||
allInclusiveArchive: process.env.ALL_INCLUSIVE_ARCHIVE === 'true',
|
||||
isDemo: process.env.IS_DEMO === 'true',
|
||||
};
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import 'dotenv/config';
|
||||
import { type ConnectionOptions } from 'bullmq';
|
||||
|
||||
/**
|
||||
* @see https://github.com/taskforcesh/bullmq/blob/master/docs/gitbook/guide/connections.md
|
||||
*/
|
||||
const connectionOptions: any = {
|
||||
const connectionOptions: ConnectionOptions = {
|
||||
host: process.env.REDIS_HOST || 'localhost',
|
||||
port: (process.env.REDIS_PORT && parseInt(process.env.REDIS_PORT, 10)) || 6379,
|
||||
password: process.env.REDIS_PASSWORD,
|
||||
enableReadyCheck: true,
|
||||
};
|
||||
|
||||
if (process.env.REDIS_USER) {
|
||||
connectionOptions.username = process.env.REDIS_USER;
|
||||
}
|
||||
|
||||
if (process.env.REDIS_TLS_ENABLED === 'true') {
|
||||
connectionOptions.tls = {
|
||||
rejectUnauthorized: false,
|
||||
|
||||
@@ -43,7 +43,16 @@ export const processMailboxProcessor = async (job: Job<IProcessMailboxJob, SyncS
|
||||
const connector = EmailProviderFactory.createConnector(source);
|
||||
const ingestionService = new IngestionService();
|
||||
|
||||
for await (const email of connector.fetchEmails(userEmail, source.syncState)) {
|
||||
// Create a callback to check for duplicates without fetching full email content
|
||||
const checkDuplicate = async (messageId: string) => {
|
||||
return await IngestionService.doesEmailExist(messageId, ingestionSourceId);
|
||||
};
|
||||
|
||||
for await (const email of connector.fetchEmails(
|
||||
userEmail,
|
||||
source.syncState,
|
||||
checkDuplicate
|
||||
)) {
|
||||
if (email) {
|
||||
const processedEmail = await ingestionService.processEmail(
|
||||
email,
|
||||
|
||||
69
packages/backend/src/locales/bg/translation.json
Normal file
69
packages/backend/src/locales/bg/translation.json
Normal file
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"auth": {
|
||||
"setup": {
|
||||
"allFieldsRequired": "Изискват се поща, парола и име",
|
||||
"alreadyCompleted": "Настройката вече е завършена."
|
||||
},
|
||||
"login": {
|
||||
"emailAndPasswordRequired": "Изискват се поща и парола",
|
||||
"invalidCredentials": "Невалидни идентификационни данни"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"internalServerError": "Възникна вътрешна грешка в сървъра",
|
||||
"demoMode": "Тази операция не е разрешена в демо режим",
|
||||
"unauthorized": "Неоторизирано",
|
||||
"unknown": "Възникна неизвестна грешка",
|
||||
"noPermissionToAction": "Нямате разрешение да извършите текущото действие."
|
||||
},
|
||||
"user": {
|
||||
"notFound": "Потребителят не е открит",
|
||||
"cannotDeleteOnlyUser": "Опитвате се да изтриете единствения потребител в базата данни, това не е позволено.",
|
||||
"requiresSuperAdminRole": "За управление на потребители е необходима роля на супер администратор."
|
||||
},
|
||||
"iam": {
|
||||
"failedToGetRoles": "Неуспешно получаване на роли.",
|
||||
"roleNotFound": "Ролята не е намерена.",
|
||||
"failedToGetRole": "Неуспешно получаване на роля.",
|
||||
"missingRoleFields": "Липсват задължителни полета: име и политика.",
|
||||
"invalidPolicy": "Невалидно твърдение за политика:",
|
||||
"failedToCreateRole": "Създаването на роля неуспешно.",
|
||||
"failedToDeleteRole": "Изтриването на роля неуспешно.",
|
||||
"missingUpdateFields": "Липсват полета за актуализиране: име или политики.",
|
||||
"failedToUpdateRole": "Актуализирането на ролята неуспешно.",
|
||||
"requiresSuperAdminRole": "За управление на роли е необходима роля на супер администратор."
|
||||
},
|
||||
"settings": {
|
||||
"failedToRetrieve": "Неуспешно извличане на настройките",
|
||||
"failedToUpdate": "Неуспешно актуализиране на настройките",
|
||||
"noPermissionToUpdate": "Нямате разрешение да актуализирате системните настройки."
|
||||
},
|
||||
"dashboard": {
|
||||
"permissionRequired": "Необходимо ви е разрешение за четене на таблото, за да видите данните от него."
|
||||
},
|
||||
"ingestion": {
|
||||
"failedToCreate": "Създаването на източник за приемане не бе успешно поради грешка при свързване.",
|
||||
"notFound": "Източникът за приемане не е намерен",
|
||||
"initialImportTriggered": "Първоначалният импорт е задействан успешно.",
|
||||
"forceSyncTriggered": "Принудителното синхронизиране е задействано успешно."
|
||||
},
|
||||
"archivedEmail": {
|
||||
"notFound": "Архивираната поща не е намерена"
|
||||
},
|
||||
"search": {
|
||||
"keywordsRequired": "Ключовите думи са задължителни"
|
||||
},
|
||||
"storage": {
|
||||
"filePathRequired": "Пътят към файла е задължителен",
|
||||
"invalidFilePath": "Невалиден път към файла",
|
||||
"fileNotFound": "Файлът не е намерен",
|
||||
"downloadError": "Грешка при изтегляне на файла"
|
||||
},
|
||||
"apiKeys": {
|
||||
"generateSuccess": "API ключът е генериран успешно.",
|
||||
"deleteSuccess": "API ключът е успешно изтрит."
|
||||
},
|
||||
"api": {
|
||||
"requestBodyInvalid": "Невалидно съдържание на заявката."
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,8 @@ export interface IEmailConnector {
|
||||
testConnection(): Promise<boolean>;
|
||||
fetchEmails(
|
||||
userEmail: string,
|
||||
syncState?: SyncState | null
|
||||
syncState?: SyncState | null,
|
||||
checkDuplicate?: (messageId: string) => Promise<boolean>
|
||||
): AsyncGenerator<EmailObject | null>;
|
||||
getUpdatedSyncState(userEmail?: string): SyncState;
|
||||
listAllUsers(): AsyncGenerator<MailboxUser>;
|
||||
|
||||
@@ -85,7 +85,7 @@ export class IngestionService {
|
||||
|
||||
const decryptedSource = this.decryptSource(newSource);
|
||||
if (!decryptedSource) {
|
||||
await this.delete(newSource.id, actor, actorIp);
|
||||
await this.delete(newSource.id, actor, actorIp, true);
|
||||
throw new Error(
|
||||
'Failed to process newly created ingestion source due to a decryption error.'
|
||||
);
|
||||
@@ -107,7 +107,7 @@ export class IngestionService {
|
||||
}
|
||||
} catch (error) {
|
||||
// If connection fails, delete the newly created source and throw the error.
|
||||
await this.delete(decryptedSource.id, actor, actorIp);
|
||||
await this.delete(decryptedSource.id, actor, actorIp, true);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -205,8 +205,15 @@ export class IngestionService {
|
||||
return decryptedSource;
|
||||
}
|
||||
|
||||
public static async delete(id: string, actor: User, actorIp: string): Promise<IngestionSource> {
|
||||
checkDeletionEnabled();
|
||||
public static async delete(
|
||||
id: string,
|
||||
actor: User,
|
||||
actorIp: string,
|
||||
force: boolean = false
|
||||
): Promise<IngestionSource> {
|
||||
if (!force) {
|
||||
checkDeletionEnabled();
|
||||
}
|
||||
const source = await this.findById(id);
|
||||
if (!source) {
|
||||
throw new Error('Ingestion source not found');
|
||||
@@ -219,7 +226,8 @@ export class IngestionService {
|
||||
|
||||
if (
|
||||
(source.credentials.type === 'pst_import' ||
|
||||
source.credentials.type === 'eml_import') &&
|
||||
source.credentials.type === 'eml_import' ||
|
||||
source.credentials.type === 'mbox_import') &&
|
||||
source.credentials.uploadedFilePath &&
|
||||
(await storage.exists(source.credentials.uploadedFilePath))
|
||||
) {
|
||||
@@ -382,6 +390,25 @@ export class IngestionService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Quickly checks if an email exists in the database by its Message-ID header.
|
||||
* This is used to skip downloading duplicate emails during ingestion.
|
||||
*/
|
||||
public static async doesEmailExist(
|
||||
messageId: string,
|
||||
ingestionSourceId: string
|
||||
): Promise<boolean> {
|
||||
const existingEmail = await db.query.archivedEmails.findFirst({
|
||||
where: and(
|
||||
eq(archivedEmails.messageIdHeader, messageId),
|
||||
eq(archivedEmails.ingestionSourceId, ingestionSourceId)
|
||||
),
|
||||
columns: { id: true },
|
||||
});
|
||||
|
||||
return !!existingEmail;
|
||||
}
|
||||
|
||||
public async processEmail(
|
||||
email: EmailObject,
|
||||
source: IngestionSource,
|
||||
|
||||
@@ -32,29 +32,72 @@ export class EMLConnector implements IEmailConnector {
|
||||
this.storage = new StorageService();
|
||||
}
|
||||
|
||||
private getFilePath(): string {
|
||||
return this.credentials.localFilePath || this.credentials.uploadedFilePath || '';
|
||||
}
|
||||
|
||||
private getDisplayName(): string {
|
||||
if (this.credentials.uploadedFileName) {
|
||||
return this.credentials.uploadedFileName;
|
||||
}
|
||||
if (this.credentials.localFilePath) {
|
||||
const parts = this.credentials.localFilePath.split('/');
|
||||
return parts[parts.length - 1].replace('.zip', '');
|
||||
}
|
||||
return `eml-import-${new Date().getTime()}`;
|
||||
}
|
||||
|
||||
private async getFileStream(): Promise<NodeJS.ReadableStream> {
|
||||
if (this.credentials.localFilePath) {
|
||||
return createReadStream(this.credentials.localFilePath);
|
||||
}
|
||||
return this.storage.get(this.getFilePath());
|
||||
}
|
||||
|
||||
public async testConnection(): Promise<boolean> {
|
||||
try {
|
||||
if (!this.credentials.uploadedFilePath) {
|
||||
throw Error('EML file path not provided.');
|
||||
const filePath = this.getFilePath();
|
||||
if (!filePath) {
|
||||
throw Error('EML Zip file path not provided.');
|
||||
}
|
||||
if (!this.credentials.uploadedFilePath.includes('.zip')) {
|
||||
if (!filePath.includes('.zip')) {
|
||||
throw Error('Provided file is not in the ZIP format.');
|
||||
}
|
||||
const fileExist = await this.storage.exists(this.credentials.uploadedFilePath);
|
||||
|
||||
let fileExist = false;
|
||||
if (this.credentials.localFilePath) {
|
||||
try {
|
||||
await fs.access(this.credentials.localFilePath);
|
||||
fileExist = true;
|
||||
} catch {
|
||||
fileExist = false;
|
||||
}
|
||||
} else {
|
||||
fileExist = await this.storage.exists(filePath);
|
||||
}
|
||||
|
||||
if (!fileExist) {
|
||||
throw Error('EML file upload not finished yet, please wait.');
|
||||
if (this.credentials.localFilePath) {
|
||||
throw Error(`EML Zip file not found at path: ${this.credentials.localFilePath}`);
|
||||
} else {
|
||||
throw Error(
|
||||
'Uploaded EML Zip file not found. The upload may not have finished yet, or it failed.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error({ error, credentials: this.credentials }, 'EML file validation failed.');
|
||||
logger.error(
|
||||
{ error, credentials: this.credentials },
|
||||
'EML Zip file validation failed.'
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async *listAllUsers(): AsyncGenerator<MailboxUser> {
|
||||
const displayName =
|
||||
this.credentials.uploadedFileName || `eml-import-${new Date().getTime()}`;
|
||||
const displayName = this.getDisplayName();
|
||||
logger.info(`Found potential mailbox: ${displayName}`);
|
||||
const constructedPrimaryEmail = `${displayName.replace(/ /g, '.').toLowerCase()}@eml.local`;
|
||||
yield {
|
||||
@@ -68,10 +111,8 @@ export class EMLConnector implements IEmailConnector {
|
||||
userEmail: string,
|
||||
syncState?: SyncState | null
|
||||
): AsyncGenerator<EmailObject | null> {
|
||||
const fileStream = await this.storage.get(this.credentials.uploadedFilePath);
|
||||
const fileStream = await this.getFileStream();
|
||||
const tempDir = await fs.mkdtemp(join('/tmp', `eml-import-${new Date().getTime()}`));
|
||||
const unzippedPath = join(tempDir, 'unzipped');
|
||||
await fs.mkdir(unzippedPath);
|
||||
const zipFilePath = join(tempDir, 'eml.zip');
|
||||
|
||||
try {
|
||||
@@ -82,99 +123,150 @@ export class EMLConnector implements IEmailConnector {
|
||||
dest.on('error', reject);
|
||||
});
|
||||
|
||||
await this.extract(zipFilePath, unzippedPath);
|
||||
|
||||
const files = await this.getAllFiles(unzippedPath);
|
||||
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.eml')) {
|
||||
try {
|
||||
// logger.info({ file }, 'Processing EML file.');
|
||||
const stream = createReadStream(file);
|
||||
const content = await streamToBuffer(stream);
|
||||
// logger.info({ file, size: content.length }, 'Read file to buffer.');
|
||||
let relativePath = file.substring(unzippedPath.length + 1);
|
||||
if (dirname(relativePath) === '.') {
|
||||
relativePath = '';
|
||||
} else {
|
||||
relativePath = dirname(relativePath);
|
||||
}
|
||||
const emailObject = await this.parseMessage(content, relativePath);
|
||||
// logger.info({ file, messageId: emailObject.id }, 'Parsed email message.');
|
||||
yield emailObject;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ error, file },
|
||||
'Failed to process a single EML file. Skipping.'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
yield* this.processZipEntries(zipFilePath);
|
||||
} catch (error) {
|
||||
logger.error({ error }, 'Failed to fetch email.');
|
||||
throw error;
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
try {
|
||||
await this.storage.delete(this.credentials.uploadedFilePath);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ error, file: this.credentials.uploadedFilePath },
|
||||
'Failed to delete EML file after processing.'
|
||||
);
|
||||
if (this.credentials.uploadedFilePath && !this.credentials.localFilePath) {
|
||||
try {
|
||||
await this.storage.delete(this.credentials.uploadedFilePath);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ error, file: this.credentials.uploadedFilePath },
|
||||
'Failed to delete EML file after processing.'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extract(zipFilePath: string, dest: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
private async *processZipEntries(zipFilePath: string): AsyncGenerator<EmailObject | null> {
|
||||
// Open the ZIP file.
|
||||
// Note: yauzl requires random access, so we must use the file on disk.
|
||||
const zipfile = await new Promise<yauzl.ZipFile>((resolve, reject) => {
|
||||
yauzl.open(zipFilePath, { lazyEntries: true, decodeStrings: false }, (err, zipfile) => {
|
||||
if (err) reject(err);
|
||||
zipfile.on('error', reject);
|
||||
zipfile.readEntry();
|
||||
zipfile.on('entry', (entry) => {
|
||||
const fileName = entry.fileName.toString('utf8');
|
||||
// Ignore macOS-specific metadata files.
|
||||
if (fileName.startsWith('__MACOSX/')) {
|
||||
zipfile.readEntry();
|
||||
return;
|
||||
}
|
||||
const entryPath = join(dest, fileName);
|
||||
if (/\/$/.test(fileName)) {
|
||||
fs.mkdir(entryPath, { recursive: true })
|
||||
.then(() => zipfile.readEntry())
|
||||
.catch(reject);
|
||||
} else {
|
||||
zipfile.openReadStream(entry, (err, readStream) => {
|
||||
if (err) reject(err);
|
||||
const writeStream = createWriteStream(entryPath);
|
||||
readStream.pipe(writeStream);
|
||||
writeStream.on('finish', () => zipfile.readEntry());
|
||||
writeStream.on('error', reject);
|
||||
});
|
||||
}
|
||||
});
|
||||
zipfile.on('end', () => resolve());
|
||||
if (err || !zipfile) return reject(err);
|
||||
resolve(zipfile);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async getAllFiles(dirPath: string, arrayOfFiles: string[] = []): Promise<string[]> {
|
||||
const files = await fs.readdir(dirPath);
|
||||
// Create an async iterator for zip entries
|
||||
const entryIterator = this.zipEntryGenerator(zipfile);
|
||||
|
||||
for (const file of files) {
|
||||
const fullPath = join(dirPath, file);
|
||||
if ((await fs.stat(fullPath)).isDirectory()) {
|
||||
await this.getAllFiles(fullPath, arrayOfFiles);
|
||||
} else {
|
||||
arrayOfFiles.push(fullPath);
|
||||
for await (const { entry, openReadStream } of entryIterator) {
|
||||
const fileName = entry.fileName.toString();
|
||||
if (fileName.startsWith('__MACOSX/') || /\/$/.test(fileName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (fileName.endsWith('.eml')) {
|
||||
try {
|
||||
const readStream = await openReadStream();
|
||||
const relativePath = dirname(fileName) === '.' ? '' : dirname(fileName);
|
||||
const emailObject = await this.parseMessage(readStream, relativePath);
|
||||
yield emailObject;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ error, file: fileName },
|
||||
'Failed to process a single EML file from zip. Skipping.'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return arrayOfFiles;
|
||||
}
|
||||
|
||||
private async parseMessage(emlBuffer: Buffer, path: string): Promise<EmailObject> {
|
||||
private async *zipEntryGenerator(
|
||||
zipfile: yauzl.ZipFile
|
||||
): AsyncGenerator<{ entry: yauzl.Entry; openReadStream: () => Promise<Readable> }> {
|
||||
let resolveNext: ((value: any) => void) | null = null;
|
||||
let rejectNext: ((reason?: any) => void) | null = null;
|
||||
let finished = false;
|
||||
const queue: yauzl.Entry[] = [];
|
||||
|
||||
zipfile.readEntry();
|
||||
|
||||
zipfile.on('entry', (entry) => {
|
||||
if (resolveNext) {
|
||||
const resolve = resolveNext;
|
||||
resolveNext = null;
|
||||
rejectNext = null;
|
||||
resolve(entry);
|
||||
} else {
|
||||
queue.push(entry);
|
||||
}
|
||||
});
|
||||
|
||||
zipfile.on('end', () => {
|
||||
finished = true;
|
||||
if (resolveNext) {
|
||||
const resolve = resolveNext;
|
||||
resolveNext = null;
|
||||
rejectNext = null;
|
||||
resolve(null); // Signal end
|
||||
}
|
||||
});
|
||||
|
||||
zipfile.on('error', (err) => {
|
||||
finished = true;
|
||||
if (rejectNext) {
|
||||
const reject = rejectNext;
|
||||
resolveNext = null;
|
||||
rejectNext = null;
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
|
||||
while (!finished || queue.length > 0) {
|
||||
if (queue.length > 0) {
|
||||
const entry = queue.shift()!;
|
||||
yield {
|
||||
entry,
|
||||
openReadStream: () =>
|
||||
new Promise<Readable>((resolve, reject) => {
|
||||
zipfile.openReadStream(entry, (err, stream) => {
|
||||
if (err || !stream) return reject(err);
|
||||
resolve(stream);
|
||||
});
|
||||
}),
|
||||
};
|
||||
zipfile.readEntry(); // Read next entry only after yielding
|
||||
} else {
|
||||
const entry = await new Promise<yauzl.Entry | null>((resolve, reject) => {
|
||||
resolveNext = resolve;
|
||||
rejectNext = reject;
|
||||
});
|
||||
if (entry) {
|
||||
yield {
|
||||
entry,
|
||||
openReadStream: () =>
|
||||
new Promise<Readable>((resolve, reject) => {
|
||||
zipfile.openReadStream(entry, (err, stream) => {
|
||||
if (err || !stream) return reject(err);
|
||||
resolve(stream);
|
||||
});
|
||||
}),
|
||||
};
|
||||
zipfile.readEntry(); // Read next entry only after yielding
|
||||
} else {
|
||||
break; // End of zip
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async parseMessage(
|
||||
input: Buffer | Readable,
|
||||
path: string
|
||||
): Promise<EmailObject> {
|
||||
let emlBuffer: Buffer;
|
||||
if (Buffer.isBuffer(input)) {
|
||||
emlBuffer = input;
|
||||
} else {
|
||||
emlBuffer = await streamToBuffer(input);
|
||||
}
|
||||
|
||||
const parsedEmail: ParsedMail = await simpleParser(emlBuffer);
|
||||
|
||||
const attachments = parsedEmail.attachments.map((attachment: Attachment) => ({
|
||||
|
||||
@@ -132,7 +132,8 @@ export class GoogleWorkspaceConnector implements IEmailConnector {
|
||||
*/
|
||||
public async *fetchEmails(
|
||||
userEmail: string,
|
||||
syncState?: SyncState | null
|
||||
syncState?: SyncState | null,
|
||||
checkDuplicate?: (messageId: string) => Promise<boolean>
|
||||
): AsyncGenerator<EmailObject> {
|
||||
const authClient = this.getAuthClient(userEmail, [
|
||||
'https://www.googleapis.com/auth/gmail.readonly',
|
||||
@@ -144,7 +145,7 @@ export class GoogleWorkspaceConnector implements IEmailConnector {
|
||||
|
||||
// If no sync state is provided for this user, this is an initial import. Get all messages.
|
||||
if (!startHistoryId) {
|
||||
yield* this.fetchAllMessagesForUser(gmail, userEmail);
|
||||
yield* this.fetchAllMessagesForUser(gmail, userEmail, checkDuplicate);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -170,6 +171,16 @@ export class GoogleWorkspaceConnector implements IEmailConnector {
|
||||
if (messageAdded.message?.id) {
|
||||
try {
|
||||
const messageId = messageAdded.message.id;
|
||||
|
||||
// Optimization: Check for existence before fetching full content
|
||||
if (checkDuplicate && (await checkDuplicate(messageId))) {
|
||||
logger.debug(
|
||||
{ messageId, userEmail },
|
||||
'Skipping duplicate email (pre-check)'
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const metadataResponse = await gmail.users.messages.get({
|
||||
userId: userEmail,
|
||||
id: messageId,
|
||||
@@ -258,8 +269,17 @@ export class GoogleWorkspaceConnector implements IEmailConnector {
|
||||
|
||||
private async *fetchAllMessagesForUser(
|
||||
gmail: gmail_v1.Gmail,
|
||||
userEmail: string
|
||||
userEmail: string,
|
||||
checkDuplicate?: (messageId: string) => Promise<boolean>
|
||||
): AsyncGenerator<EmailObject> {
|
||||
// Capture the history ID at the start to ensure no emails are missed during the import process.
|
||||
// Any emails arriving during this import will be covered by the next sync starting from this point.
|
||||
// Overlaps are handled by the duplicate check.
|
||||
const profileResponse = await gmail.users.getProfile({ userId: userEmail });
|
||||
if (profileResponse.data.historyId) {
|
||||
this.newHistoryId = profileResponse.data.historyId;
|
||||
}
|
||||
|
||||
let pageToken: string | undefined = undefined;
|
||||
do {
|
||||
const listResponse: Common.GaxiosResponseWithHTTP2<gmail_v1.Schema$ListMessagesResponse> =
|
||||
@@ -277,6 +297,16 @@ export class GoogleWorkspaceConnector implements IEmailConnector {
|
||||
if (message.id) {
|
||||
try {
|
||||
const messageId = message.id;
|
||||
|
||||
// Optimization: Check for existence before fetching full content
|
||||
if (checkDuplicate && (await checkDuplicate(messageId))) {
|
||||
logger.debug(
|
||||
{ messageId, userEmail },
|
||||
'Skipping duplicate email (pre-check)'
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const metadataResponse = await gmail.users.messages.get({
|
||||
userId: userEmail,
|
||||
id: messageId,
|
||||
@@ -352,12 +382,6 @@ export class GoogleWorkspaceConnector implements IEmailConnector {
|
||||
}
|
||||
pageToken = listResponse.data.nextPageToken ?? undefined;
|
||||
} while (pageToken);
|
||||
|
||||
// After fetching all messages, get the latest history ID to use as the starting point for the next sync.
|
||||
const profileResponse = await gmail.users.getProfile({ userId: userEmail });
|
||||
if (profileResponse.data.historyId) {
|
||||
this.newHistoryId = profileResponse.data.historyId;
|
||||
}
|
||||
}
|
||||
|
||||
public getUpdatedSyncState(userEmail: string): SyncState {
|
||||
|
||||
@@ -142,7 +142,8 @@ export class ImapConnector implements IEmailConnector {
|
||||
|
||||
public async *fetchEmails(
|
||||
userEmail: string,
|
||||
syncState?: SyncState | null
|
||||
syncState?: SyncState | null,
|
||||
checkDuplicate?: (messageId: string) => Promise<boolean>
|
||||
): AsyncGenerator<EmailObject | null> {
|
||||
try {
|
||||
// list all mailboxes first
|
||||
@@ -218,6 +219,22 @@ export class ImapConnector implements IEmailConnector {
|
||||
this.newMaxUids[mailboxPath] = msg.uid;
|
||||
}
|
||||
|
||||
// Optimization: Verify existence using Message-ID from envelope before fetching full body
|
||||
if (checkDuplicate && msg.envelope?.messageId) {
|
||||
const isDuplicate = await checkDuplicate(msg.envelope.messageId);
|
||||
if (isDuplicate) {
|
||||
logger.debug(
|
||||
{
|
||||
mailboxPath,
|
||||
uid: msg.uid,
|
||||
messageId: msg.envelope.messageId,
|
||||
},
|
||||
'Skipping duplicate email (pre-check)'
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug({ mailboxPath, uid: msg.uid }, 'Processing message');
|
||||
|
||||
if (msg.envelope && msg.source) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { getThreadId } from './helpers/utils';
|
||||
import { StorageService } from '../StorageService';
|
||||
import { Readable, Transform } from 'stream';
|
||||
import { createHash } from 'crypto';
|
||||
import { promises as fs, createReadStream } from 'fs';
|
||||
|
||||
class MboxSplitter extends Transform {
|
||||
private buffer: Buffer = Buffer.alloc(0);
|
||||
@@ -60,27 +61,59 @@ export class MboxConnector implements IEmailConnector {
|
||||
|
||||
public async testConnection(): Promise<boolean> {
|
||||
try {
|
||||
if (!this.credentials.uploadedFilePath) {
|
||||
const filePath = this.getFilePath();
|
||||
if (!filePath) {
|
||||
throw Error('Mbox file path not provided.');
|
||||
}
|
||||
if (!this.credentials.uploadedFilePath.includes('.mbox')) {
|
||||
if (!filePath.includes('.mbox')) {
|
||||
throw Error('Provided file is not in the MBOX format.');
|
||||
}
|
||||
const fileExist = await this.storage.exists(this.credentials.uploadedFilePath);
|
||||
|
||||
let fileExist = false;
|
||||
if (this.credentials.localFilePath) {
|
||||
try {
|
||||
await fs.access(this.credentials.localFilePath);
|
||||
fileExist = true;
|
||||
} catch {
|
||||
fileExist = false;
|
||||
}
|
||||
} else {
|
||||
fileExist = await this.storage.exists(filePath);
|
||||
}
|
||||
|
||||
if (!fileExist) {
|
||||
throw Error('Mbox file upload not finished yet, please wait.');
|
||||
if (this.credentials.localFilePath) {
|
||||
throw Error(`Mbox file not found at path: ${this.credentials.localFilePath}`);
|
||||
} else {
|
||||
throw Error(
|
||||
'Uploaded Mbox file not found. The upload may not have finished yet, or it failed.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error({ error, credentials: this.credentials }, 'Mbox file validation failed.');
|
||||
logger.error(
|
||||
{ error, credentials: this.credentials },
|
||||
'Mbox file validation failed.'
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private getFilePath(): string {
|
||||
return this.credentials.localFilePath || this.credentials.uploadedFilePath || '';
|
||||
}
|
||||
|
||||
private async getFileStream(): Promise<NodeJS.ReadableStream> {
|
||||
if (this.credentials.localFilePath) {
|
||||
return createReadStream(this.credentials.localFilePath);
|
||||
}
|
||||
return this.storage.getStream(this.getFilePath());
|
||||
}
|
||||
|
||||
public async *listAllUsers(): AsyncGenerator<MailboxUser> {
|
||||
const displayName =
|
||||
this.credentials.uploadedFileName || `mbox-import-${new Date().getTime()}`;
|
||||
const displayName = this.getDisplayName();
|
||||
logger.info(`Found potential mailbox: ${displayName}`);
|
||||
const constructedPrimaryEmail = `${displayName.replace(/ /g, '.').toLowerCase()}@mbox.local`;
|
||||
yield {
|
||||
@@ -90,11 +123,23 @@ export class MboxConnector implements IEmailConnector {
|
||||
};
|
||||
}
|
||||
|
||||
private getDisplayName(): string {
|
||||
if (this.credentials.uploadedFileName) {
|
||||
return this.credentials.uploadedFileName;
|
||||
}
|
||||
if (this.credentials.localFilePath) {
|
||||
const parts = this.credentials.localFilePath.split('/');
|
||||
return parts[parts.length - 1].replace('.mbox', '');
|
||||
}
|
||||
return `mbox-import-${new Date().getTime()}`;
|
||||
}
|
||||
|
||||
public async *fetchEmails(
|
||||
userEmail: string,
|
||||
syncState?: SyncState | null
|
||||
): AsyncGenerator<EmailObject | null> {
|
||||
const fileStream = await this.storage.getStream(this.credentials.uploadedFilePath);
|
||||
const filePath = this.getFilePath();
|
||||
const fileStream = await this.getFileStream();
|
||||
const mboxSplitter = new MboxSplitter();
|
||||
const emailStream = fileStream.pipe(mboxSplitter);
|
||||
|
||||
@@ -104,22 +149,21 @@ export class MboxConnector implements IEmailConnector {
|
||||
yield emailObject;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ error, file: this.credentials.uploadedFilePath },
|
||||
{ error, file: filePath },
|
||||
'Failed to process a single message from mbox file. Skipping.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// After the stream is fully consumed, delete the file.
|
||||
// The `for await...of` loop ensures streams are properly closed on completion,
|
||||
// so we can safely delete the file here without causing a hang.
|
||||
try {
|
||||
await this.storage.delete(this.credentials.uploadedFilePath);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ error, file: this.credentials.uploadedFilePath },
|
||||
'Failed to delete mbox file after processing.'
|
||||
);
|
||||
if (this.credentials.uploadedFilePath && !this.credentials.localFilePath) {
|
||||
try {
|
||||
await this.storage.delete(filePath);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ error, file: filePath },
|
||||
'Failed to delete mbox file after processing.'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import { StorageService } from '../StorageService';
|
||||
import { Readable } from 'stream';
|
||||
import { createHash } from 'crypto';
|
||||
import { join } from 'path';
|
||||
import { createWriteStream, promises as fs } from 'fs';
|
||||
import { createWriteStream, createReadStream, promises as fs } from 'fs';
|
||||
|
||||
// We have to hardcode names for deleted and trash folders here as current lib doesn't support looking into PST properties.
|
||||
const DELETED_FOLDERS = new Set([
|
||||
@@ -111,8 +111,19 @@ export class PSTConnector implements IEmailConnector {
|
||||
this.storage = new StorageService();
|
||||
}
|
||||
|
||||
private getFilePath(): string {
|
||||
return this.credentials.localFilePath || this.credentials.uploadedFilePath || '';
|
||||
}
|
||||
|
||||
private async getFileStream(): Promise<NodeJS.ReadableStream> {
|
||||
if (this.credentials.localFilePath) {
|
||||
return createReadStream(this.credentials.localFilePath);
|
||||
}
|
||||
return this.storage.getStream(this.getFilePath());
|
||||
}
|
||||
|
||||
private async loadPstFile(): Promise<{ pstFile: PSTFile; tempDir: string }> {
|
||||
const fileStream = await this.storage.getStream(this.credentials.uploadedFilePath);
|
||||
const fileStream = await this.getFileStream();
|
||||
const tempDir = await fs.mkdtemp(join('/tmp', `pst-import-${new Date().getTime()}`));
|
||||
const tempFilePath = join(tempDir, 'temp.pst');
|
||||
|
||||
@@ -129,19 +140,41 @@ export class PSTConnector implements IEmailConnector {
|
||||
|
||||
public async testConnection(): Promise<boolean> {
|
||||
try {
|
||||
if (!this.credentials.uploadedFilePath) {
|
||||
const filePath = this.getFilePath();
|
||||
if (!filePath) {
|
||||
throw Error('PST file path not provided.');
|
||||
}
|
||||
if (!this.credentials.uploadedFilePath.includes('.pst')) {
|
||||
if (!filePath.includes('.pst')) {
|
||||
throw Error('Provided file is not in the PST format.');
|
||||
}
|
||||
const fileExist = await this.storage.exists(this.credentials.uploadedFilePath);
|
||||
|
||||
let fileExist = false;
|
||||
if (this.credentials.localFilePath) {
|
||||
try {
|
||||
await fs.access(this.credentials.localFilePath);
|
||||
fileExist = true;
|
||||
} catch {
|
||||
fileExist = false;
|
||||
}
|
||||
} else {
|
||||
fileExist = await this.storage.exists(filePath);
|
||||
}
|
||||
|
||||
if (!fileExist) {
|
||||
throw Error('PST file upload not finished yet, please wait.');
|
||||
if (this.credentials.localFilePath) {
|
||||
throw Error(`PST file not found at path: ${this.credentials.localFilePath}`);
|
||||
} else {
|
||||
throw Error(
|
||||
'Uploaded PST file not found. The upload may not have finished yet, or it failed.'
|
||||
);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error({ error, credentials: this.credentials }, 'PST file validation failed.');
|
||||
logger.error(
|
||||
{ error, credentials: this.credentials },
|
||||
'PST file validation failed.'
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -200,13 +233,15 @@ export class PSTConnector implements IEmailConnector {
|
||||
if (tempDir) {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
try {
|
||||
await this.storage.delete(this.credentials.uploadedFilePath);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ error, file: this.credentials.uploadedFilePath },
|
||||
'Failed to delete PST file after processing.'
|
||||
);
|
||||
if (this.credentials.uploadedFilePath && !this.credentials.localFilePath) {
|
||||
try {
|
||||
await this.storage.delete(this.credentials.uploadedFilePath);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ error, file: this.credentials.uploadedFilePath },
|
||||
'Failed to delete PST file after processing.'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import * as Alert from '$lib/components/ui/alert/index.js';
|
||||
import * as RadioGroup from '$lib/components/ui/radio-group/index.js';
|
||||
import { Textarea } from '$lib/components/ui/textarea/index.js';
|
||||
import { setAlert } from '$lib/components/custom/alert/alert-state.svelte';
|
||||
import { api } from '$lib/api.client';
|
||||
@@ -70,6 +71,27 @@
|
||||
|
||||
let fileUploading = $state(false);
|
||||
|
||||
let importMethod = $state<'upload' | 'local'>(
|
||||
source?.credentials && 'localFilePath' in source.credentials && source.credentials.localFilePath
|
||||
? 'local'
|
||||
: 'upload'
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
if (importMethod === 'upload') {
|
||||
if ('localFilePath' in formData.providerConfig) {
|
||||
delete formData.providerConfig.localFilePath;
|
||||
}
|
||||
} else {
|
||||
if ('uploadedFilePath' in formData.providerConfig) {
|
||||
delete formData.providerConfig.uploadedFilePath;
|
||||
}
|
||||
if ('uploadedFileName' in formData.providerConfig) {
|
||||
delete formData.providerConfig.uploadedFileName;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const handleSubmit = async (event: Event) => {
|
||||
event.preventDefault();
|
||||
isSubmitting = true;
|
||||
@@ -236,59 +258,143 @@
|
||||
/>
|
||||
</div>
|
||||
{:else if formData.provider === 'pst_import'}
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="pst-file" class="text-left"
|
||||
>{$t('app.components.ingestion_source_form.pst_file')}</Label
|
||||
>
|
||||
<div class="col-span-3 flex flex-row items-center space-x-2">
|
||||
<Input
|
||||
id="pst-file"
|
||||
type="file"
|
||||
class=""
|
||||
accept=".pst"
|
||||
onchange={handleFileChange}
|
||||
/>
|
||||
{#if fileUploading}
|
||||
<span class=" text-primary animate-spin"><Loader2 /></span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-start gap-4">
|
||||
<Label class="text-left pt-2">{$t('app.components.ingestion_source_form.import_method')}</Label>
|
||||
<RadioGroup.Root bind:value={importMethod} class="col-span-3 flex flex-col space-y-1">
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="upload" id="pst-upload" />
|
||||
<Label for="pst-upload">{$t('app.components.ingestion_source_form.upload_file')}</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="local" id="pst-local" />
|
||||
<Label for="pst-local">{$t('app.components.ingestion_source_form.local_path')}</Label>
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
</div>
|
||||
|
||||
{#if importMethod === 'upload'}
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="pst-file" class="text-left"
|
||||
>{$t('app.components.ingestion_source_form.pst_file')}</Label
|
||||
>
|
||||
<div class="col-span-3 flex flex-row items-center space-x-2">
|
||||
<Input
|
||||
id="pst-file"
|
||||
type="file"
|
||||
class=""
|
||||
accept=".pst"
|
||||
onchange={handleFileChange}
|
||||
/>
|
||||
{#if fileUploading}
|
||||
<span class=" text-primary animate-spin"><Loader2 /></span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="pst-local-path" class="text-left"
|
||||
>{$t('app.components.ingestion_source_form.local_file_path')}</Label
|
||||
>
|
||||
<Input
|
||||
id="pst-local-path"
|
||||
bind:value={formData.providerConfig.localFilePath}
|
||||
placeholder="/path/to/file.pst"
|
||||
class="col-span-3"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if formData.provider === 'eml_import'}
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="eml-file" class="text-left"
|
||||
>{$t('app.components.ingestion_source_form.eml_file')}</Label
|
||||
>
|
||||
<div class="col-span-3 flex flex-row items-center space-x-2">
|
||||
<Input
|
||||
id="eml-file"
|
||||
type="file"
|
||||
class=""
|
||||
accept=".zip"
|
||||
onchange={handleFileChange}
|
||||
/>
|
||||
{#if fileUploading}
|
||||
<span class=" text-primary animate-spin"><Loader2 /></span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-start gap-4">
|
||||
<Label class="text-left pt-2">{$t('app.components.ingestion_source_form.import_method')}</Label>
|
||||
<RadioGroup.Root bind:value={importMethod} class="col-span-3 flex flex-col space-y-1">
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="upload" id="eml-upload" />
|
||||
<Label for="eml-upload">{$t('app.components.ingestion_source_form.upload_file')}</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="local" id="eml-local" />
|
||||
<Label for="eml-local">{$t('app.components.ingestion_source_form.local_path')}</Label>
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
</div>
|
||||
|
||||
{#if importMethod === 'upload'}
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="eml-file" class="text-left"
|
||||
>{$t('app.components.ingestion_source_form.eml_file')}</Label
|
||||
>
|
||||
<div class="col-span-3 flex flex-row items-center space-x-2">
|
||||
<Input
|
||||
id="eml-file"
|
||||
type="file"
|
||||
class=""
|
||||
accept=".zip"
|
||||
onchange={handleFileChange}
|
||||
/>
|
||||
{#if fileUploading}
|
||||
<span class=" text-primary animate-spin"><Loader2 /></span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="eml-local-path" class="text-left"
|
||||
>{$t('app.components.ingestion_source_form.local_file_path')}</Label
|
||||
>
|
||||
<Input
|
||||
id="eml-local-path"
|
||||
bind:value={formData.providerConfig.localFilePath}
|
||||
placeholder="/path/to/file.zip"
|
||||
class="col-span-3"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if formData.provider === 'mbox_import'}
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="mbox-file" class="text-left"
|
||||
>{$t('app.components.ingestion_source_form.mbox_file')}</Label
|
||||
>
|
||||
<div class="col-span-3 flex flex-row items-center space-x-2">
|
||||
<Input
|
||||
id="mbox-file"
|
||||
type="file"
|
||||
class=""
|
||||
accept=".mbox"
|
||||
onchange={handleFileChange}
|
||||
/>
|
||||
{#if fileUploading}
|
||||
<span class=" text-primary animate-spin"><Loader2 /></span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-start gap-4">
|
||||
<Label class="text-left pt-2">{$t('app.components.ingestion_source_form.import_method')}</Label>
|
||||
<RadioGroup.Root bind:value={importMethod} class="col-span-3 flex flex-col space-y-1">
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="upload" id="mbox-upload" />
|
||||
<Label for="mbox-upload">{$t('app.components.ingestion_source_form.upload_file')}</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="local" id="mbox-local" />
|
||||
<Label for="mbox-local">{$t('app.components.ingestion_source_form.local_path')}</Label>
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
</div>
|
||||
|
||||
{#if importMethod === 'upload'}
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="mbox-file" class="text-left"
|
||||
>{$t('app.components.ingestion_source_form.mbox_file')}</Label
|
||||
>
|
||||
<div class="col-span-3 flex flex-row items-center space-x-2">
|
||||
<Input
|
||||
id="mbox-file"
|
||||
type="file"
|
||||
class=""
|
||||
accept=".mbox"
|
||||
onchange={handleFileChange}
|
||||
/>
|
||||
{#if fileUploading}
|
||||
<span class=" text-primary animate-spin"><Loader2 /></span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="mbox-local-path" class="text-left"
|
||||
>{$t('app.components.ingestion_source_form.local_file_path')}</Label
|
||||
>
|
||||
<Input
|
||||
id="mbox-local-path"
|
||||
bind:value={formData.providerConfig.localFilePath}
|
||||
placeholder="/path/to/file.mbox"
|
||||
class="col-span-3"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if formData.provider === 'google_workspace' || formData.provider === 'microsoft_365'}
|
||||
<Alert.Root>
|
||||
|
||||
292
packages/frontend/src/lib/translations/bg.json
Normal file
292
packages/frontend/src/lib/translations/bg.json
Normal file
@@ -0,0 +1,292 @@
|
||||
{
|
||||
"app": {
|
||||
"auth": {
|
||||
"login": "Вход",
|
||||
"login_tip": "Въведете имейл адреса си по-долу, за да влезете в профила си.",
|
||||
"email": "Имейл",
|
||||
"password": "Парола"
|
||||
},
|
||||
"common": {
|
||||
"working": "Работата е в ход"
|
||||
},
|
||||
"archive": {
|
||||
"title": "Архив",
|
||||
"no_subject": "Без тема",
|
||||
"from": "От",
|
||||
"sent": "Изпратени",
|
||||
"recipients": "Получатели",
|
||||
"to": "До",
|
||||
"meta_data": "Метаданни",
|
||||
"folder": "Папка",
|
||||
"tags": "Етикети",
|
||||
"size": "Размер",
|
||||
"email_preview": "Преглед на имейла",
|
||||
"attachments": "Прикачени файлове",
|
||||
"download": "Изтегляне",
|
||||
"actions": "Действия",
|
||||
"download_eml": "Изтегляне на съобщения (.eml)",
|
||||
"delete_email": "Изтриване на съобщения",
|
||||
"email_thread": "Нишка от съобщения",
|
||||
"delete_confirmation_title": "Сигурни ли сте, че искате да изтриете това съобщение?",
|
||||
"delete_confirmation_description": "Това действие не може да бъде отменено и ще премахне имейла и прикачените към него файлове за постоянно.",
|
||||
"deleting": "Изтриване",
|
||||
"confirm": "Потвърдете",
|
||||
"cancel": "Отказ",
|
||||
"not_found": "Съобщението не е открито."
|
||||
},
|
||||
"ingestions": {
|
||||
"title": "Източници за приемане",
|
||||
"ingestion_sources": "Източници за приемане",
|
||||
"bulk_actions": "Групови действия",
|
||||
"force_sync": "Принудително синхронизиране",
|
||||
"delete": "Изтрийте",
|
||||
"create_new": "Създайте нов",
|
||||
"name": "Име",
|
||||
"provider": "Доставчик",
|
||||
"status": "Статус",
|
||||
"active": "Активно",
|
||||
"created_at": "Създадено в",
|
||||
"actions": "Действия",
|
||||
"last_sync_message": "Последно синхронизирано съобщение",
|
||||
"empty": "Празно",
|
||||
"open_menu": "Отворете менюто",
|
||||
"edit": "Редактиране",
|
||||
"create": "Създайте",
|
||||
"ingestion_source": "Източници за приемане",
|
||||
"edit_description": "Направете промени в източника си за приемане тук.",
|
||||
"create_description": "Добавете нов източник за приемане, за да започнете да архивирате съобщения.",
|
||||
"read": "Прочетете",
|
||||
"docs_here": "тук се намира документацията",
|
||||
"delete_confirmation_title": "Наистина ли искате да изтриете това приемане?",
|
||||
"delete_confirmation_description": "Това ще изтрие всички архивирани съобщения, прикачени файлове, индексирания и файлове, свързани с това приемане. Ако искате да спрете само синхронизирането на нови съобщения, можете вместо това да поставите приемането на пауза.",
|
||||
"deleting": "Изтриване",
|
||||
"confirm": "Потвърдете",
|
||||
"cancel": "Отказ",
|
||||
"bulk_delete_confirmation_title": "Сигурни ли сте, че искате да изтриете {{count}} избраните приемания?",
|
||||
"bulk_delete_confirmation_description": "Това ще изтрие всички архивирани съобщения, прикачени файлове, индексирания и файлове, свързани с тези приемания. Ако искате да спрете само синхронизирането на нови съобщения, можете вместо това да поставите приеманията на пауза."
|
||||
},
|
||||
"search": {
|
||||
"title": "Търсене",
|
||||
"description": "Търсене на архивирани съобщения.",
|
||||
"email_search": "Претърсване на имейл",
|
||||
"placeholder": "Търсене по ключова дума, подател, получател...",
|
||||
"search_button": "Търсене",
|
||||
"search_options": "Опции за търсене",
|
||||
"strategy_fuzzy": "Размито",
|
||||
"strategy_verbatim": "Дословно",
|
||||
"strategy_frequency": "Честота",
|
||||
"select_strategy": "Изберете стратегия",
|
||||
"error": "Грешка",
|
||||
"found_results_in": "Намерени {{total}} резултати за {{seconds}}и",
|
||||
"found_results": "Намерени {{total}} резултати",
|
||||
"from": "От",
|
||||
"to": "До",
|
||||
"in_email_body": "В съдържанието на имейла",
|
||||
"in_attachment": "В прикачения файл: {{filename}}",
|
||||
"prev": "Предишен",
|
||||
"next": "Следващ"
|
||||
},
|
||||
"roles": {
|
||||
"title": "Управление на ролите",
|
||||
"role_management": "Управление на ролите",
|
||||
"create_new": "Създаване на нова",
|
||||
"name": "Име",
|
||||
"created_at": "Създадено в",
|
||||
"actions": "Действия",
|
||||
"open_menu": "Отваряне на менюто",
|
||||
"view_policy": "Преглед на политиката",
|
||||
"edit": "Редактиране",
|
||||
"delete": "Изтриване",
|
||||
"no_roles_found": "Няма намерени роли.",
|
||||
"role_policy": "Политика за ролите",
|
||||
"viewing_policy_for_role": "Преглед на политиката за роля: {{name}}",
|
||||
"create": "Създаване",
|
||||
"role": "Роля",
|
||||
"edit_description": "Направете промени в ролята тук.",
|
||||
"create_description": "Добавете нова роля към системата.",
|
||||
"delete_confirmation_title": "Сигурни ли сте, че искате да изтриете тази роля?",
|
||||
"delete_confirmation_description": "Това действие не може да бъде отменено. Това ще изтрие ролята за постоянно.",
|
||||
"deleting": "Изтриване",
|
||||
"confirm": "Потвърдете",
|
||||
"cancel": "Отказ"
|
||||
},
|
||||
"system_settings": {
|
||||
"title": "Системни настройки",
|
||||
"system_settings": "Системни настройки",
|
||||
"description": "Управление на глобалните настройки на приложението.",
|
||||
"language": "Език",
|
||||
"default_theme": "Тема по подразбиране",
|
||||
"light": "Светла",
|
||||
"dark": "Тъмна",
|
||||
"system": "Система",
|
||||
"support_email": "Имейл за поддръжка",
|
||||
"saving": "Съхранява се",
|
||||
"save_changes": "Съхранете промените"
|
||||
},
|
||||
"users": {
|
||||
"title": "Управление на потребителите",
|
||||
"user_management": "Управление на потребителите",
|
||||
"create_new": "Създаване на нов",
|
||||
"name": "Име",
|
||||
"email": "Имейл",
|
||||
"role": "Роля",
|
||||
"created_at": "Създадено в",
|
||||
"actions": "Действия",
|
||||
"open_menu": "Отваряне на меню",
|
||||
"edit": "Редактиране",
|
||||
"delete": "Изтриване",
|
||||
"no_users_found": "Няма открити потребители.",
|
||||
"create": "Създаване",
|
||||
"user": "Потребител",
|
||||
"edit_description": "Направете промени на потребителя тук.",
|
||||
"create_description": "Добавете нов потребител към системата.",
|
||||
"delete_confirmation_title": "Сигурни ли сте, че искате да изтриете този потребител?",
|
||||
"delete_confirmation_description": "Това действие не може да бъде отменено. Това ще изтрие потребителя за постоянно и ще премахне данните му от нашите сървъри.",
|
||||
"deleting": "Изтриване",
|
||||
"confirm": "Потвърдете",
|
||||
"cancel": "Отказ"
|
||||
},
|
||||
"components": {
|
||||
"charts": {
|
||||
"emails_ingested": "Приети съобщения",
|
||||
"storage_used": "Използвано пространство",
|
||||
"emails": "Съобщения"
|
||||
},
|
||||
"common": {
|
||||
"submitting": "Изпращане...",
|
||||
"submit": "Изпратете",
|
||||
"save": "Съхранете"
|
||||
},
|
||||
"email_preview": {
|
||||
"loading": "Зареждане на визуализацията на имейла...",
|
||||
"render_error": "Не можа да се изобрази визуализация на имейла.",
|
||||
"not_available": "Необработеният .eml файл не е наличен за този имейл."
|
||||
},
|
||||
"footer": {
|
||||
"all_rights_reserved": "Всички права запазени.",
|
||||
"new_version_available": "Налична е нова версия"
|
||||
},
|
||||
"ingestion_source_form": {
|
||||
"provider_generic_imap": "Общ IMAP",
|
||||
"provider_google_workspace": "Google Workspace",
|
||||
"provider_microsoft_365": "Microsoft 365",
|
||||
"provider_pst_import": "PST Импортиране",
|
||||
"provider_eml_import": "EML Импортиране",
|
||||
"provider_mbox_import": "Mbox Импортиране",
|
||||
"select_provider": "Изберете доставчик",
|
||||
"service_account_key": "Ключ за сервизен акаунт (JSON)",
|
||||
"service_account_key_placeholder": "Поставете JSON съдържанието на ключа на вашия сервизен акаунт",
|
||||
"impersonated_admin_email": "Имейл адрес на администратор, използван като идентификатор",
|
||||
"client_id": "Приложение (Клиент) ID",
|
||||
"client_secret": "Клиентски таен ключ",
|
||||
"client_secret_placeholder": "Въведете клиентския таен ключ като стойност, а не ID тайната",
|
||||
"tenant_id": "Директория (Наемател) ID",
|
||||
"host": "Хост",
|
||||
"port": "Порт",
|
||||
"username": "Потребителско им",
|
||||
"use_tls": "Използвайте TLS",
|
||||
"allow_insecure_cert": "Разрешаване на несигурен сертификат",
|
||||
"pst_file": "PST Файл",
|
||||
"eml_file": "EML Файл",
|
||||
"mbox_file": "Mbox файл",
|
||||
"heads_up": "Внимание!",
|
||||
"org_wide_warning": "Моля, обърнете внимание, че това е операция за цялата организация. Този вид приемане ще импортира и индексира <b>всички</b> имейл входящи кутии във вашата организация. Ако искате да импортирате само конкретни имейл входящи кутии, използвайте IMAP инструмента за свързване.",
|
||||
"upload_failed": "Качването не бе успешно, моля, опитайте отново"
|
||||
},
|
||||
"role_form": {
|
||||
"policies_json": "Политики (JSON)",
|
||||
"invalid_json": "Невалиден JSON формат за политики."
|
||||
},
|
||||
"theme_switcher": {
|
||||
"toggle_theme": "Превключване на тема"
|
||||
},
|
||||
"user_form": {
|
||||
"select_role": "Изберете роля"
|
||||
}
|
||||
},
|
||||
"setup": {
|
||||
"title": "Настройка",
|
||||
"description": "Настройте първоначалния администраторски акаунт за Open Archiver.",
|
||||
"welcome": "Добре дошли",
|
||||
"create_admin_account": "Създайте първия администраторски акаунт, за да започнете.",
|
||||
"first_name": "Име",
|
||||
"last_name": "Фамилия",
|
||||
"email": "Имейл",
|
||||
"password": "Парола",
|
||||
"creating_account": "Създаване на акаунт",
|
||||
"create_account": "Създаване на акаунт"
|
||||
},
|
||||
"layout": {
|
||||
"dashboard": "Табло за управление",
|
||||
"ingestions": "Приети",
|
||||
"archived_emails": "Архивирани съобщения",
|
||||
"search": "Търсене",
|
||||
"settings": "Настройки",
|
||||
"system": "Система",
|
||||
"users": "Потребители",
|
||||
"roles": "Роли",
|
||||
"api_keys": "API ключове",
|
||||
"logout": "Изход"
|
||||
},
|
||||
"api_keys_page": {
|
||||
"title": "API ключове",
|
||||
"header": "API ключове",
|
||||
"generate_new_key": "Генериране на нов ключ",
|
||||
"name": "Име",
|
||||
"key": "Ключ",
|
||||
"expires_at": "Изтича на",
|
||||
"created_at": "Създаден на",
|
||||
"actions": "Действия",
|
||||
"delete": "Изтриване",
|
||||
"no_keys_found": "Няма намерени API ключове.",
|
||||
"generate_modal_title": "Генериране на нов API ключ",
|
||||
"generate_modal_description": "Моля, посочете име и срок на валидност за новия си API ключ.",
|
||||
"expires_in": "Изтича след",
|
||||
"select_expiration": "Изберете срок на валидност",
|
||||
"30_days": "30 дни",
|
||||
"60_days": "60 дни",
|
||||
"6_months": "6 месеца",
|
||||
"12_months": "12 месеца",
|
||||
"24_months": "24 месеца",
|
||||
"generate": "Генериране",
|
||||
"new_api_key": "Нов API ключ",
|
||||
"failed_to_delete": "Изтриването на API ключ е неуспешно",
|
||||
"api_key_deleted": "API ключът е изтрит",
|
||||
"generated_title": "API ключът е генериран",
|
||||
"generated_message": "Вашият API ключ е генериран, моля, копирайте го и го запазете на сигурно място. Този ключ ще бъде показан само веднъж."
|
||||
},
|
||||
"archived_emails_page": {
|
||||
"title": "Архивирани съобщения",
|
||||
"header": "Архивирани съобщения",
|
||||
"select_ingestion_source": "Изберете източник за приемане",
|
||||
"date": "Дата",
|
||||
"subject": "Тема",
|
||||
"sender": "Подател",
|
||||
"inbox": "Входяща поща",
|
||||
"path": "Път",
|
||||
"actions": "Действия",
|
||||
"view": "Преглед",
|
||||
"no_emails_found": "Няма намерени архивирани съобщения.",
|
||||
"prev": "Предишен",
|
||||
"next": "Следващ"
|
||||
},
|
||||
"dashboard_page": {
|
||||
"title": "Табло за управление",
|
||||
"meta_description": "Общ преглед на вашия имейл архив.",
|
||||
"header": "Табло за управление",
|
||||
"create_ingestion": "Създаване на приемане",
|
||||
"no_ingestion_header": "Нямате настроен източник за приемане.",
|
||||
"no_ingestion_text": "Добавете източник за приемане, за да започнете да архивирате входящите си кутии.",
|
||||
"total_emails_archived": "Общо архивирани съобщения",
|
||||
"total_storage_used": "Общо използвано място за съхранение",
|
||||
"failed_ingestions": "Неуспешни приемания (последните 7 дни)",
|
||||
"ingestion_history": "История на приеманията",
|
||||
"no_ingestion_history": "Няма налична история на приеманията.",
|
||||
"storage_by_source": "Съхранение по източник на приемане",
|
||||
"no_ingestion_sources": "Няма налични източници за приемане.",
|
||||
"indexed_insights": "Индексирани данни",
|
||||
"top_10_senders": "Топ 10 податели",
|
||||
"no_indexed_insights": "Няма налични индексирани данни."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -133,7 +133,7 @@
|
||||
<Table.Row id={`error-${job.id}`} class="hidden">
|
||||
<Table.Cell colspan={7} class="p-0">
|
||||
<pre
|
||||
class="max-w-full text-wrap rounded-md bg-gray-100 p-4 text-xs">{job.error}</pre>
|
||||
class="bg-muted max-w-full text-wrap rounded-md p-4 text-xs">{job.error}</pre>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/if}
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
{ value: 'pt', label: '🇵🇹 Português' },
|
||||
{ value: 'nl', label: '🇳🇱 Nederlands' },
|
||||
{ value: 'el', label: '🇬🇷 Ελληνικά' },
|
||||
{ value: 'bg', label: '🇧🇬 български' },
|
||||
{ value: 'ja', label: '🇯🇵 日本語' },
|
||||
];
|
||||
|
||||
|
||||
9
packages/types/LICENSE
Normal file
9
packages/types/LICENSE
Normal file
@@ -0,0 +1,9 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Open Archiver
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
17
packages/types/README.md
Normal file
17
packages/types/README.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# @open-archiver/types
|
||||
|
||||
This package contains shared TypeScript type definitions for the Open Archiver project.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @open-archiver/types
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Import the types you need in your TypeScript files:
|
||||
|
||||
```typescript
|
||||
import { User, Email } from '@open-archiver/types';
|
||||
```
|
||||
@@ -1,10 +1,15 @@
|
||||
{
|
||||
"name": "@open-archiver/types",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"license": "SEE LICENSE IN LICENSE file",
|
||||
"version": "0.1.4",
|
||||
"license": "MIT License",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -22,15 +22,52 @@ export interface LicenseFilePayload {
|
||||
}
|
||||
|
||||
/**
|
||||
* The structure of the cached response from the License Server.
|
||||
* Request body sent to the license server's POST /api/v1/ping endpoint.
|
||||
*/
|
||||
export interface LicenseStatusPayload {
|
||||
status: 'VALID' | 'REVOKED';
|
||||
gracePeriodEnds?: string; // ISO 8601, only present if REVOKED
|
||||
export interface LicensePingRequest {
|
||||
/** UUID of the license, taken from the license.jwt payload. */
|
||||
licenseId: string;
|
||||
/** Current number of unique archived mailboxes on this instance. */
|
||||
activeSeats: number;
|
||||
/** Version string of the running Open Archiver instance. */
|
||||
version: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The consolidated license status object returned by the API.
|
||||
* Successful response body from the license server's POST /api/v1/ping endpoint.
|
||||
*
|
||||
* - `"VALID"` — license is active. If `gracePeriodEnds` is present, seats exceed
|
||||
* the plan limit and the grace period deadline is included.
|
||||
* - `"INVALID"` — license is revoked, not found, or the overage grace period has
|
||||
* expired. All enterprise features must be disabled immediately.
|
||||
*/
|
||||
export interface LicensePingResponse {
|
||||
status: 'VALID' | 'INVALID';
|
||||
// ISO 8601 UTC timestamp.
|
||||
expirationDate: string;
|
||||
/** ISO 8601 UTC timestamp. Present only when status is "VALID" and activeSeats > planSeats. */
|
||||
gracePeriodEnds?: string;
|
||||
/** The current plan seat limit from the license server. */
|
||||
planSeats?: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The structure of the locally cached license-status.json file.
|
||||
* Written after each successful phone-home call.
|
||||
*/
|
||||
export interface LicenseStatusPayload {
|
||||
status: 'VALID' | 'INVALID';
|
||||
/** ISO 8601 UTC timestamp. Present when the instance is in a seat-overage grace period. */
|
||||
gracePeriodEnds?: string;
|
||||
/** ISO 8601 UTC timestamp of when this status was last successfully fetched. */
|
||||
lastCheckedAt?: string;
|
||||
/** The current plan seat limit from the license server. */
|
||||
planSeats: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The consolidated license status object returned by the GET /enterprise/status/license-status API.
|
||||
*/
|
||||
export interface ConsolidatedLicenseStatus {
|
||||
// From the license.jwt file
|
||||
@@ -38,8 +75,9 @@ export interface ConsolidatedLicenseStatus {
|
||||
planSeats: number;
|
||||
expiresAt: string;
|
||||
// From the cached license-status.json
|
||||
remoteStatus: 'VALID' | 'REVOKED' | 'UNKNOWN';
|
||||
remoteStatus: 'VALID' | 'INVALID' | 'UNKNOWN';
|
||||
gracePeriodEnds?: string;
|
||||
lastCheckedAt?: string;
|
||||
// Calculated values
|
||||
activeSeats: number;
|
||||
isExpired: boolean;
|
||||
|
||||
Reference in New Issue
Block a user