Compare commits

...

10 Commits

Author SHA1 Message Date
Wei S.
3434e8d6ef v0.4.2-fix: improve ingestion error handling and error messages (#312)
* fix(backend): improve ingestion error handling and error messages

This commit introduces a "force delete" mechanism for Ingestion Sources and improves error messages for file-based connectors.

Changes:
- Update `IngestionService.delete` to accept a `force` flag, bypassing the `checkDeletionEnabled` check.
- Use `force` deletion when rolling back failed ingestion source creations (e.g., decryption errors or connection failures) to ensure cleanup even if deletion is globally disabled.
- Enhance error messages in `EMLConnector`, `MboxConnector`, and `PSTConnector` to distinguish between missing local files and failed uploads, providing more specific feedback to the user.

* feat(ingestion): optimize duplicate handling and fix race conditions in Google Workspace

- Implement fast duplicate check (by Message-ID) to skip full content download for existing emails in Google Workspace and IMAP connectors.
- Fix race condition in Google Workspace initial import by capturing `historyId` before listing messages, ensuring no data loss for incoming mail during import.
2026-02-24 18:10:32 +01:00
Wei S.
7dac3b2bfd V0.4.2 (#310)
* 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>
2026-02-23 21:25:44 +01:00
albanobattistella
cf121989ae Update Italian linguage (#278) 2026-01-18 15:28:20 +01:00
Wei S.
2df5c9240d V0.4.1 dev (#276)
* 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
2026-01-17 13:21:01 +01:00
Wei S.
24afd13858 V0.4.1: API key generation fix, change password, account profile (#273)
* 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.
2026-01-17 02:46:27 +02:00
Wei S.
c2006dfa94 V0.4 fix 2 (#210)
* formatting code

* Remove uninstalled packages

* fix(imap): Improve IMAP connection stability and error handling

This commit refactors the IMAP connector to enhance connection management, error handling, and overall stability during email ingestion.

The `isConnected` flag has been removed in favor of relying directly on the `client.usable` property from the `imapflow` library. This simplifies the connection logic and avoids state synchronization issues.

The `connect` method now re-creates the client instance if it's not usable, ensuring a fresh connection after errors or disconnects. The retry mechanism (`withRetry`) has been updated to no longer manually reset the connection state, as the `connect` method now handles this automatically on the next attempt.

Additionally, a minor bug in the `sync-cycle-finished` processor has been fixed. The logic for merging sync states from successful jobs has been simplified and correctly typed, preventing potential runtime errors when no successful jobs are present.

---------

Co-authored-by: Wayne <5291640+ringoinca@users.noreply.github.com>
2025-10-29 12:59:19 +01:00
Wei S.
399059a773 V0.4 fix 2 (#207)
* formatting code

* Remove uninstalled packages

---------

Co-authored-by: Wayne <5291640+ringoinca@users.noreply.github.com>
2025-10-28 13:39:09 +01:00
Wei S.
0cff788656 formatting code (#206)
Co-authored-by: Wayne <5291640+ringoinca@users.noreply.github.com>
2025-10-28 13:35:53 +01:00
Wei S.
ddb4d56107 V0.4.0 fix (#205)
* Jobs page responsive fix

* feat(ingestion): Refactor email indexing into a dedicated background job

This commit refactors the email indexing process to improve the performance and reliability of the ingestion pipeline.

Previously, email indexing was performed synchronously within the mailbox processing job. This could lead to timeouts and failed ingestion cycles if the indexing step was slow or encountered errors.

To address this, the indexing logic has been moved into a separate, dedicated background job queue (`indexingQueue`). Now, the mailbox processor simply adds a batch of emails to this queue. A separate worker then processes the indexing job asynchronously.

This decoupling makes the ingestion process more robust:
- It prevents slow indexing from blocking or failing the entire mailbox sync.
- It allows for better resource management and scalability by handling indexing in a dedicated process.
- It improves error handling, as a failed indexing job can be retried independently without affecting the main ingestion flow.

Additionally, this commit includes minor documentation updates and removes a premature timeout in the PDF text extraction helper that was causing issues.

* remove uninstalled packages

---------

Co-authored-by: Wayne <5291640+ringoinca@users.noreply.github.com>
2025-10-28 13:19:56 +01:00
Wei S.
42b0f6e5f1 V0.4.0 fix (#204)
* Jobs page responsive fix

* feat(ingestion): Refactor email indexing into a dedicated background job

This commit refactors the email indexing process to improve the performance and reliability of the ingestion pipeline.

Previously, email indexing was performed synchronously within the mailbox processing job. This could lead to timeouts and failed ingestion cycles if the indexing step was slow or encountered errors.

To address this, the indexing logic has been moved into a separate, dedicated background job queue (`indexingQueue`). Now, the mailbox processor simply adds a batch of emails to this queue. A separate worker then processes the indexing job asynchronously.

This decoupling makes the ingestion process more robust:
- It prevents slow indexing from blocking or failing the entire mailbox sync.
- It allows for better resource management and scalability by handling indexing in a dedicated process.
- It improves error handling, as a failed indexing job can be retried independently without affecting the main ingestion flow.

Additionally, this commit includes minor documentation updates and removes a premature timeout in the PDF text extraction helper that was causing issues.

---------

Co-authored-by: Wayne <5291640+ringoinca@users.noreply.github.com>
2025-10-28 13:14:43 +01:00
45 changed files with 2072 additions and 603 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

@@ -7,7 +7,7 @@
[![Redis](https://img.shields.io/badge/Redis-DC382D?style=for-the-badge&logo=redis&logoColor=white)](https://redis.io)
[![SvelteKit](https://img.shields.io/badge/SvelteKit-FF3E00?style=for-the-badge&logo=svelte&logoColor=white)](https://svelte.dev/)
**A secure, sovereign, and open-source platform for email archiving and eDiscovery.**
**A secure, sovereign, and open-source platform for email archiving.**
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.
@@ -48,13 +48,14 @@ Password: openarchiver_demo
- Zipped .eml files
- Mbox files
- **Secure & Efficient Storage**: Emails are stored in the standard `.eml` format. The system uses deduplication and compression to minimize storage costs. All data is encrypted at rest.
- **Secure & Efficient Storage**: Emails are stored in the standard `.eml` format. The system uses deduplication and compression to minimize storage costs. All files are encrypted at rest.
- **Pluggable Storage Backends**: Support both local filesystem storage and S3-compatible object storage (like AWS S3 or MinIO).
- **Powerful Search & eDiscovery**: A high-performance search engine indexes the full text of emails and attachments (PDF, DOCX, etc.).
- **Thread discovery**: The ability to discover if an email belongs to a thread/conversation and present the context.
- **Compliance & Retention**: Define granular retention policies to automatically manage the lifecycle of your data. Place legal holds on communications to prevent deletion during litigation (TBD).
- **File Hash and Encryption**: Email and attachment file hash values are stored in the meta database upon ingestion, meaning any attempt to alter the file content will be identified, ensuring legal and regulatory compliance.
- **Comprehensive Auditing**: An immutable audit trail logs all system activities, ensuring you have a clear record of who accessed what and when (TBD).
- - 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

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

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

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

@@ -1,4 +1,4 @@
# Email Integrity Check
# Integrity Check
Open Archiver allows you to verify the integrity of your archived emails and their attachments. This guide explains how the integrity check works and what the results mean.

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.0",
"version": "0.4.2",
"private": true,
"license": "SEE LICENSE IN LICENSE file",
"scripts": {

View File

@@ -16,7 +16,7 @@ const generateApiKeySchema = z.object({
});
export class ApiKeyController {
private userService = new UserService();
public async generateApiKey(req: Request, res: Response) {
public generateApiKey = async (req: Request, res: Response) => {
try {
const { name, expiresInDays } = generateApiKeySchema.parse(req.body);
if (!req.user || !req.user.sub) {
@@ -45,9 +45,9 @@ export class ApiKeyController {
}
res.status(500).json({ message: req.t('errors.internalServerError') });
}
}
};
public async getApiKeys(req: Request, res: Response) {
public getApiKeys = async (req: Request, res: Response) => {
if (!req.user || !req.user.sub) {
return res.status(401).json({ message: 'Unauthorized' });
}
@@ -55,9 +55,9 @@ export class ApiKeyController {
const keys = await ApiKeyService.getKeys(userId);
res.status(200).json(keys);
}
};
public async deleteApiKey(req: Request, res: Response) {
public deleteApiKey = async (req: Request, res: Response) => {
const { id } = req.params;
if (!req.user || !req.user.sub) {
return res.status(401).json({ message: 'Unauthorized' });
@@ -70,5 +70,5 @@ export class ApiKeyController {
await ApiKeyService.deleteKey(id, userId, actor, req.ip || 'unknown');
res.status(204).send({ message: req.t('apiKeys.deleteSuccess') });
}
};
}

View File

@@ -79,3 +79,60 @@ export const deleteUser = async (req: Request, res: Response) => {
await userService.deleteUser(req.params.id, actor, req.ip || 'unknown');
res.status(204).send();
};
export const getProfile = async (req: Request, res: Response) => {
if (!req.user || !req.user.sub) {
return res.status(401).json({ message: 'Unauthorized' });
}
const user = await userService.findById(req.user.sub);
if (!user) {
return res.status(404).json({ message: req.t('user.notFound') });
}
res.json(user);
};
export const updateProfile = async (req: Request, res: Response) => {
const { email, first_name, last_name } = req.body;
if (!req.user || !req.user.sub) {
return res.status(401).json({ message: 'Unauthorized' });
}
const actor = await userService.findById(req.user.sub);
if (!actor) {
return res.status(401).json({ message: 'Unauthorized' });
}
const updatedUser = await userService.updateUser(
req.user.sub,
{ email, first_name, last_name },
undefined,
actor,
req.ip || 'unknown'
);
res.json(updatedUser);
};
export const updatePassword = async (req: Request, res: Response) => {
const { currentPassword, newPassword } = req.body;
if (!req.user || !req.user.sub) {
return res.status(401).json({ message: 'Unauthorized' });
}
const actor = await userService.findById(req.user.sub);
if (!actor) {
return res.status(401).json({ message: 'Unauthorized' });
}
try {
await userService.updatePassword(
req.user.sub,
currentPassword,
newPassword,
actor,
req.ip || 'unknown'
);
res.status(200).json({ message: 'Password updated successfully' });
} catch (e: any) {
if (e.message === 'Invalid current password') {
return res.status(400).json({ message: e.message });
}
throw e;
}
};

View File

@@ -11,6 +11,10 @@ export const createUserRouter = (authService: AuthService): Router => {
router.get('/', requirePermission('read', 'users'), userController.getUsers);
router.get('/profile', userController.getProfile);
router.patch('/profile', userController.updateProfile);
router.post('/profile/password', userController.updatePassword);
router.get('/:id', requirePermission('read', 'users'), userController.getUser);
/**

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

@@ -47,10 +47,10 @@ function extractTextFromPdf(buffer: Buffer): Promise<string> {
}
// reduced Timeout for better performance
setTimeout(() => {
logger.warn('PDF parsing timed out');
finish('');
}, 5000);
// setTimeout(() => {
// logger.warn('PDF parsing timed out');
// finish('');
// }, 5000);
});
}

View File

@@ -33,7 +33,6 @@ export const processMailboxProcessor = async (job: Job<IProcessMailboxJob, SyncS
const searchService = new SearchService();
const storageService = new StorageService();
const databaseService = new DatabaseService();
const indexingService = new IndexingService(databaseService, searchService, storageService);
try {
const source = await IngestionService.findById(ingestionSourceId);
@@ -44,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,
@@ -72,7 +80,8 @@ export const processMailboxProcessor = async (job: Job<IProcessMailboxJob, SyncS
return newSyncState;
} catch (error) {
if (emailBatch.length > 0) {
await indexingService.indexEmailBatch(emailBatch);
await indexingQueue.add('index-email-batch', { emails: emailBatch });
emailBatch = [];
}
logger.error({ err: error, ingestionSourceId, userEmail }, 'Error processing mailbox');

View File

@@ -51,7 +51,7 @@ export default async (job: Job<ISyncCycleFinishedJob, any, string>) => {
const finalSyncState = deepmerge(
...successfulJobs.filter((s) => s && Object.keys(s).length > 0)
);
) as SyncState;
const source = await IngestionService.findById(ingestionSourceId);
let status: IngestionStatus = 'active';
@@ -63,7 +63,9 @@ export default async (job: Job<ISyncCycleFinishedJob, any, string>) => {
let message: string;
// Check for a specific rate-limit message from the successful jobs
const rateLimitMessage = successfulJobs.find((j) => j.statusMessage)?.statusMessage;
const rateLimitMessage = successfulJobs.find(
(j) => j.statusMessage && j.statusMessage.includes('rate limit')
)?.statusMessage;
if (failedJobs.length > 0) {
status = 'error';

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

@@ -20,16 +20,17 @@ export class ApiKeyService {
expiresAt.setDate(expiresAt.getDate() + expiresInDays);
const keyHash = createHash('sha256').update(key).digest('hex');
await db.insert(apiKeys).values({
userId,
name,
key: CryptoService.encrypt(key),
keyHash,
expiresAt,
});
try {
await db.insert(apiKeys).values({
userId,
name,
key: CryptoService.encrypt(key),
keyHash,
expiresAt,
});
await this.auditService.createAuditLog({
actorIdentifier: actor.id,
await this.auditService.createAuditLog({
actorIdentifier: actor.id,
actionType: 'GENERATE',
targetType: 'ApiKey',
targetId: name,
@@ -40,6 +41,9 @@ export class ApiKeyService {
});
return key;
} catch (error) {
throw error;
}
}
public static async getKeys(userId: string): Promise<ApiKey[]> {

View File

@@ -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>;

View File

@@ -93,21 +93,19 @@ export class IndexingService {
const batch = emails.slice(i, i + CONCURRENCY_LIMIT);
const batchDocuments = await Promise.allSettled(
batch.map(async ({ email, sourceId, archivedId }) => {
batch.map(async (pendingEmail) => {
try {
return await this.createEmailDocumentFromRawForBatch(
email,
sourceId,
archivedId,
email.userEmail || ''
const document = await this.indexEmailById(
pendingEmail.archivedEmailId
);
if (document) {
return document;
}
return null;
} catch (error) {
logger.error(
{
emailId: archivedId,
sourceId,
userEmail: email.userEmail || '',
rawEmailData: JSON.stringify(email, null, 2),
emailId: pendingEmail.archivedEmailId,
error: error instanceof Error ? error.message : String(error),
},
'Failed to create document for email in batch'
@@ -118,10 +116,15 @@ export class IndexingService {
);
for (const result of batchDocuments) {
if (result.status === 'fulfilled') {
if (result.status === 'fulfilled' && result.value) {
rawDocuments.push(result.value);
} else {
} else if (result.status === 'rejected') {
logger.error({ error: result.reason }, 'Failed to process email in batch');
} else {
logger.error(
{ result: result },
'Failed to process email in batch, reason unknown.'
);
}
}
}
@@ -195,10 +198,7 @@ export class IndexingService {
}
}
/**
* @deprecated
*/
private async indexEmailById(emailId: string): Promise<void> {
private async indexEmailById(emailId: string): Promise<EmailDocument | null> {
const email = await this.dbService.db.query.archivedEmails.findFirst({
where: eq(archivedEmails.id, emailId),
});
@@ -228,13 +228,13 @@ export class IndexingService {
emailAttachmentsResult,
email.userEmail
);
await this.searchService.addDocuments('emails', [document], 'id');
return document;
}
/**
* @deprecated
*/
private async indexByEmail(pendingEmail: PendingEmail): Promise<void> {
/* private async indexByEmail(pendingEmail: PendingEmail): Promise<void> {
const attachments: AttachmentsType = [];
if (pendingEmail.email.attachments && pendingEmail.email.attachments.length > 0) {
for (const attachment of pendingEmail.email.attachments) {
@@ -254,12 +254,12 @@ export class IndexingService {
);
// console.log(document);
await this.searchService.addDocuments('emails', [document], 'id');
}
} */
/**
* Creates a search document from a raw email object and its attachments.
*/
private async createEmailDocumentFromRawForBatch(
/* private async createEmailDocumentFromRawForBatch(
email: EmailObject,
ingestionSourceId: string,
archivedEmailId: string,
@@ -333,7 +333,7 @@ export class IndexingService {
timestamp: new Date(email.receivedAt).getTime(),
ingestionSourceId: ingestionSourceId,
};
}
} */
private async createEmailDocumentFromRaw(
email: EmailObject,

View File

@@ -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,
@@ -518,12 +545,8 @@ export class IngestionService {
}
}
email.userEmail = userEmail;
return {
email,
sourceId: source.id,
archivedId: archivedEmail.id,
archivedEmailId: archivedEmail.id,
};
} catch (error) {
logger.error({

View File

@@ -81,6 +81,79 @@ export class StorageService implements IStorageProvider {
return Readable.from(decryptedContent);
}
public async getStream(path: string): Promise<NodeJS.ReadableStream> {
const stream = await this.provider.get(path);
if (!this.encryptionKey) {
return stream;
}
// For encrypted files, we need to read the prefix and IV first.
// This part still buffers a small, fixed amount of data, which is acceptable.
const prefixAndIvBuffer = await new Promise<Buffer>((resolve, reject) => {
const chunks: Buffer[] = [];
let totalLength = 0;
const targetLength = ENCRYPTION_PREFIX.length + 16;
const onData = (chunk: Buffer) => {
chunks.push(chunk);
totalLength += chunk.length;
if (totalLength >= targetLength) {
stream.removeListener('data', onData);
resolve(Buffer.concat(chunks));
}
};
stream.on('data', onData);
stream.on('error', reject);
stream.on('end', () => {
// Handle cases where the file is smaller than the prefix + IV
if (totalLength < targetLength) {
resolve(Buffer.concat(chunks));
}
});
});
const prefix = prefixAndIvBuffer.subarray(0, ENCRYPTION_PREFIX.length);
if (!prefix.equals(ENCRYPTION_PREFIX)) {
// File is not encrypted, return a new stream containing the buffered prefix and the rest of the original stream
const combinedStream = new Readable({
read() {},
});
combinedStream.push(prefixAndIvBuffer);
stream.on('data', (chunk) => {
combinedStream.push(chunk);
});
stream.on('end', () => {
combinedStream.push(null); // No more data
});
stream.on('error', (err) => {
combinedStream.emit('error', err);
});
return combinedStream;
}
try {
const iv = prefixAndIvBuffer.subarray(
ENCRYPTION_PREFIX.length,
ENCRYPTION_PREFIX.length + 16
);
const decipher = createDecipheriv(this.algorithm, this.encryptionKey, iv);
// Push the remaining part of the initial buffer to the decipher
const remainingBuffer = prefixAndIvBuffer.subarray(ENCRYPTION_PREFIX.length + 16);
if (remainingBuffer.length > 0) {
decipher.write(remainingBuffer);
}
// Pipe the rest of the stream
stream.pipe(decipher);
return decipher;
} catch (error) {
throw new Error('Failed to decrypt file. It may be corrupted or the key is incorrect.');
}
}
delete(path: string): Promise<void> {
return this.provider.delete(path);
}

View File

@@ -1,7 +1,7 @@
import { db } from '../database';
import * as schema from '../database/schema';
import { eq, sql } from 'drizzle-orm';
import { hash } from 'bcryptjs';
import { hash, compare } from 'bcryptjs';
import type { CaslPolicy, User } from '@open-archiver/types';
import { AuditService } from './AuditService';
@@ -152,6 +152,46 @@ export class UserService {
});
}
public async updatePassword(
id: string,
currentPassword: string,
newPassword: string,
actor: User,
actorIp: string
): Promise<void> {
const user = await db.query.users.findFirst({
where: eq(schema.users.id, id),
});
if (!user || !user.password) {
throw new Error('User not found');
}
const isPasswordValid = await compare(currentPassword, user.password);
if (!isPasswordValid) {
throw new Error('Invalid current password');
}
const hashedPassword = await hash(newPassword, 10);
await db
.update(schema.users)
.set({ password: hashedPassword })
.where(eq(schema.users.id, id));
await UserService.auditService.createAuditLog({
actorIdentifier: actor.id,
actionType: 'UPDATE',
targetType: 'User',
targetId: id,
actorIp,
details: {
field: 'password',
},
});
}
/**
* Creates an admin user in the database. The user created will be assigned the 'Super Admin' role.
*

View File

@@ -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) => ({

View File

@@ -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 {

View File

@@ -15,7 +15,6 @@ import { getThreadId } from './helpers/utils';
export class ImapConnector implements IEmailConnector {
private client: ImapFlow;
private newMaxUids: { [mailboxPath: string]: number } = {};
private isConnected = false;
private statusMessage: string | undefined;
constructor(private credentials: GenericImapCredentials) {
@@ -41,7 +40,6 @@ export class ImapConnector implements IEmailConnector {
// Handles client-level errors, like unexpected disconnects, to prevent crashes.
client.on('error', (err) => {
logger.error({ err }, 'IMAP client error');
this.isConnected = false;
});
return client;
@@ -51,20 +49,17 @@ export class ImapConnector implements IEmailConnector {
* Establishes a connection to the IMAP server if not already connected.
*/
private async connect(): Promise<void> {
if (this.isConnected && this.client.usable) {
// If the client is already connected and usable, do nothing.
if (this.client.usable) {
return;
}
// If the client is not usable (e.g., after a logout), create a new one.
if (!this.client.usable) {
this.client = this.createClient();
}
// If the client is not usable (e.g., after a logout or an error), create a new one.
this.client = this.createClient();
try {
await this.client.connect();
this.isConnected = true;
} catch (err: any) {
this.isConnected = false;
logger.error({ err }, 'IMAP connection failed');
if (err.responseText) {
throw new Error(`IMAP Connection Error: ${err.responseText}`);
@@ -77,9 +72,8 @@ export class ImapConnector implements IEmailConnector {
* Disconnects from the IMAP server if the connection is active.
*/
private async disconnect(): Promise<void> {
if (this.isConnected && this.client.usable) {
if (this.client.usable) {
await this.client.logout();
this.isConnected = false;
}
}
@@ -130,7 +124,7 @@ export class ImapConnector implements IEmailConnector {
return await action();
} catch (err: any) {
logger.error({ err, attempt }, `IMAP operation failed on attempt ${attempt}`);
this.isConnected = false; // Force reconnect on next attempt
// The client is no longer usable, a new one will be created on the next attempt.
if (attempt === maxRetries) {
logger.error({ err }, 'IMAP operation failed after all retries.');
throw err;
@@ -148,13 +142,18 @@ 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
const mailboxes = await this.withRetry(async () => await this.client.list());
const processableMailboxes = mailboxes.filter((mailbox) => {
// Exclude mailboxes that cannot be selected.
if (mailbox.flags.has('\\Noselect')) {
return false;
}
if (config.app.allInclusiveArchive) {
return true;
}
@@ -220,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) {

View File

@@ -10,9 +10,47 @@ import { simpleParser, ParsedMail, Attachment, AddressObject } from 'mailparser'
import { logger } from '../../config/logger';
import { getThreadId } from './helpers/utils';
import { StorageService } from '../StorageService';
import { Readable } from 'stream';
import { Readable, Transform } from 'stream';
import { createHash } from 'crypto';
import { streamToBuffer } from '../../helpers/streamToBuffer';
import { promises as fs, createReadStream } from 'fs';
class MboxSplitter extends Transform {
private buffer: Buffer = Buffer.alloc(0);
private delimiter: Buffer = Buffer.from('\nFrom ');
private firstChunk: boolean = true;
_transform(chunk: Buffer, encoding: string, callback: Function) {
if (this.firstChunk) {
// Check if the file starts with "From ". If not, prepend it to the first email.
if (chunk.subarray(0, 5).toString() !== 'From ') {
this.push(Buffer.from('From '));
}
this.firstChunk = false;
}
let currentBuffer = Buffer.concat([this.buffer, chunk]);
let position;
while ((position = currentBuffer.indexOf(this.delimiter)) > -1) {
const email = currentBuffer.subarray(0, position);
if (email.length > 0) {
this.push(email);
}
// The next email starts with "From ", which is what the parser expects.
currentBuffer = currentBuffer.subarray(position + 1);
}
this.buffer = currentBuffer;
callback();
}
_flush(callback: Function) {
if (this.buffer.length > 0) {
this.push(this.buffer);
}
callback();
}
}
export class MboxConnector implements IEmailConnector {
private storage: StorageService;
@@ -23,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 {
@@ -53,48 +123,44 @@ 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> {
try {
const fileStream = await this.storage.get(this.credentials.uploadedFilePath);
const fileBuffer = await streamToBuffer(fileStream as Readable);
const mboxContent = fileBuffer.toString('utf-8');
const emailDelimiter = '\nFrom ';
const emails = mboxContent.split(emailDelimiter);
const filePath = this.getFilePath();
const fileStream = await this.getFileStream();
const mboxSplitter = new MboxSplitter();
const emailStream = fileStream.pipe(mboxSplitter);
// The first split part might be empty or part of the first email's header, so we adjust.
if (emails.length > 0 && !mboxContent.startsWith('From ')) {
emails.shift(); // Adjust if the file doesn't start with "From "
}
logger.info(`Found ${emails.length} potential emails in the mbox file.`);
let emailCount = 0;
for (const email of emails) {
try {
// Re-add the "From " delimiter for the parser, except for the very first email
const emailWithDelimiter =
emailCount > 0 || mboxContent.startsWith('From ') ? `From ${email}` : email;
const emailBuffer = Buffer.from(emailWithDelimiter, 'utf-8');
const emailObject = await this.parseMessage(emailBuffer, '');
yield emailObject;
emailCount++;
} catch (error) {
logger.error(
{ error, file: this.credentials.uploadedFilePath },
'Failed to process a single message from mbox file. Skipping.'
);
}
}
logger.info(`Finished processing mbox file. Total emails processed: ${emailCount}`);
} finally {
for await (const emailBuffer of emailStream) {
try {
await this.storage.delete(this.credentials.uploadedFilePath);
const emailObject = await this.parseMessage(emailBuffer as Buffer, '');
yield emailObject;
} catch (error) {
logger.error(
{ error, file: this.credentials.uploadedFilePath },
{ error, file: filePath },
'Failed to process a single message from mbox file. Skipping.'
);
}
}
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

@@ -13,15 +13,8 @@ import { getThreadId } from './helpers/utils';
import { StorageService } from '../StorageService';
import { Readable } from 'stream';
import { createHash } from 'crypto';
const streamToBuffer = (stream: Readable): Promise<Buffer> => {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
stream.on('data', (chunk) => chunks.push(chunk));
stream.on('error', reject);
stream.on('end', () => resolve(Buffer.concat(chunks)));
});
};
import { join } from 'path';
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([
@@ -113,38 +106,75 @@ const JUNK_FOLDERS = new Set([
export class PSTConnector implements IEmailConnector {
private storage: StorageService;
private pstFile: PSTFile | null = null;
constructor(private credentials: PSTImportCredentials) {
this.storage = new StorageService();
}
private async loadPstFile(): Promise<PSTFile> {
if (this.pstFile) {
return this.pstFile;
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);
}
const fileStream = await this.storage.get(this.credentials.uploadedFilePath);
const buffer = await streamToBuffer(fileStream as Readable);
this.pstFile = new PSTFile(buffer);
return this.pstFile;
return this.storage.getStream(this.getFilePath());
}
private async loadPstFile(): Promise<{ pstFile: PSTFile; tempDir: string }> {
const fileStream = await this.getFileStream();
const tempDir = await fs.mkdtemp(join('/tmp', `pst-import-${new Date().getTime()}`));
const tempFilePath = join(tempDir, 'temp.pst');
await new Promise<void>((resolve, reject) => {
const dest = createWriteStream(tempFilePath);
fileStream.pipe(dest);
dest.on('finish', resolve);
dest.on('error', reject);
});
const pstFile = new PSTFile(tempFilePath);
return { pstFile, tempDir };
}
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);
if (!fileExist) {
throw Error('PST file upload not finished yet, please wait.');
let fileExist = false;
if (this.credentials.localFilePath) {
try {
await fs.access(this.credentials.localFilePath);
fileExist = true;
} catch {
fileExist = false;
}
} else {
fileExist = await this.storage.exists(filePath);
}
if (!fileExist) {
if (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;
}
}
@@ -156,8 +186,11 @@ export class PSTConnector implements IEmailConnector {
*/
public async *listAllUsers(): AsyncGenerator<MailboxUser> {
let pstFile: PSTFile | null = null;
let tempDir: string | null = null;
try {
pstFile = await this.loadPstFile();
const loadResult = await this.loadPstFile();
pstFile = loadResult.pstFile;
tempDir = loadResult.tempDir;
const root = pstFile.getRootFolder();
const displayName: string =
root.displayName || pstFile.pstFilename || String(new Date().getTime());
@@ -171,10 +204,12 @@ export class PSTConnector implements IEmailConnector {
};
} catch (error) {
logger.error({ error }, 'Failed to list users from PST file.');
pstFile?.close();
throw error;
} finally {
pstFile?.close();
if (tempDir) {
await fs.rm(tempDir, { recursive: true, force: true });
}
}
}
@@ -183,23 +218,30 @@ export class PSTConnector implements IEmailConnector {
syncState?: SyncState | null
): AsyncGenerator<EmailObject | null> {
let pstFile: PSTFile | null = null;
let tempDir: string | null = null;
try {
pstFile = await this.loadPstFile();
const loadResult = await this.loadPstFile();
pstFile = loadResult.pstFile;
tempDir = loadResult.tempDir;
const root = pstFile.getRootFolder();
yield* this.processFolder(root, '', userEmail);
} catch (error) {
logger.error({ error }, 'Failed to fetch email.');
pstFile?.close();
throw error;
} finally {
pstFile?.close();
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 (tempDir) {
await fs.rm(tempDir, { recursive: true, force: true });
}
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

@@ -118,6 +118,23 @@
"confirm": "Confirm",
"cancel": "Cancel"
},
"account": {
"title": "Account Settings",
"description": "Manage your profile and security settings.",
"personal_info": "Personal Information",
"personal_info_desc": "Update your personal details.",
"security": "Security",
"security_desc": "Manage your password and security preferences.",
"edit_profile": "Edit Profile",
"change_password": "Change Password",
"edit_profile_desc": "Make changes to your profile here.",
"change_password_desc": "Change your password. You will need to enter your current password.",
"current_password": "Current Password",
"new_password": "New Password",
"confirm_new_password": "Confirm New Password",
"operation_successful": "Operation successful",
"passwords_do_not_match": "Passwords do not match"
},
"system_settings": {
"title": "System Settings",
"system_settings": "System Settings",
@@ -182,6 +199,10 @@
"provider_eml_import": "EML Import",
"provider_mbox_import": "Mbox Import",
"select_provider": "Select a provider",
"import_method": "Import Method",
"upload_file": "Upload File",
"local_path": "Local Path (Recommended for large files)",
"local_file_path": "Local File Path",
"service_account_key": "Service Account Key (JSON)",
"service_account_key_placeholder": "Paste your service account key JSON content",
"impersonated_admin_email": "Impersonated Admin Email",
@@ -234,6 +255,7 @@
"users": "Users",
"roles": "Roles",
"api_keys": "API Keys",
"account": "Account",
"logout": "Logout",
"admin": "Admin"
},

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

@@ -1,289 +1,400 @@
{
"app": {
"auth": {
"login": "Accedi",
"login_tip": "Inserisci la tua email qui sotto per accedere al tuo account.",
"email": "Email",
"password": "Password"
},
"common": {
"working": "In corso"
},
"archive": {
"title": "Archivio",
"no_subject": "Nessun Oggetto",
"from": "Da",
"sent": "Inviato",
"recipients": "Destinatari",
"to": "A",
"meta_data": "Metadati",
"folder": "Cartella",
"tags": "Tag",
"size": "Dimensione",
"email_preview": "Anteprima Email",
"attachments": "Allegati",
"download": "Scarica",
"actions": "Azioni",
"download_eml": "Scarica Email (.eml)",
"delete_email": "Elimina Email",
"email_thread": "Thread Email",
"delete_confirmation_title": "Sei sicuro di voler eliminare questa email?",
"delete_confirmation_description": "Questa azione non può essere annullata e rimuoverà permanentemente l'email e i suoi allegati.",
"deleting": "Eliminazione in corso",
"confirm": "Conferma",
"cancel": "Annulla",
"not_found": "Email non trovata."
},
"ingestions": {
"title": "Sorgenti di Ingestione",
"ingestion_sources": "Sorgenti di Ingestione",
"bulk_actions": "Azioni di Massa",
"force_sync": "Forza Sincronizzazione",
"delete": "Elimina",
"create_new": "Crea Nuovo",
"name": "Nome",
"provider": "Provider",
"status": "Stato",
"active": "Attivo",
"created_at": "Creato il",
"actions": "Azioni",
"last_sync_message": "Ultimo messaggio di sincronizzazione",
"empty": "Vuoto",
"open_menu": "Apri menu",
"edit": "Modifica",
"create": "Crea",
"ingestion_source": "Sorgente di Ingestione",
"edit_description": "Apporta modifiche alla tua sorgente di ingestione qui.",
"create_description": "Aggiungi una nuova sorgente di ingestione per iniziare ad archiviare le email.",
"read": "Leggi",
"docs_here": "documenti qui",
"delete_confirmation_title": "Sei sicuro di voler eliminare questa ingestione?",
"delete_confirmation_description": "Questo cancellerà tutte le email archiviate, gli allegati, l'indicizzazione e i file associati a questa ingestione. Se vuoi solo interrompere la sincronizzazione di nuove email, puoi mettere in pausa l'ingestione.",
"deleting": "Eliminazione in corso",
"confirm": "Conferma",
"cancel": "Annulla",
"bulk_delete_confirmation_title": "Sei sicuro di voler eliminare {{count}} ingestioni selezionate?",
"bulk_delete_confirmation_description": "Questo cancellerà tutte le email archiviate, gli allegati, l'indicizzazione e i file associati a queste ingestioni. Se vuoi solo interrompere la sincronizzazione di nuove email, puoi mettere in pausa le ingestioni."
},
"search": {
"title": "Ricerca",
"description": "Ricerca email archiviate.",
"email_search": "Ricerca Email",
"placeholder": "Cerca per parola chiave, mittente, destinatario...",
"search_button": "Cerca",
"search_options": "Opzioni di ricerca",
"strategy_fuzzy": "Approssimativa",
"strategy_verbatim": "Esatta",
"strategy_frequency": "Frequenza",
"select_strategy": "Seleziona una strategia",
"error": "Errore",
"found_results_in": "Trovati {{total}} risultati in {{seconds}}s",
"found_results": "Trovati {{total}} risultati",
"from": "Da",
"to": "A",
"in_email_body": "Nel corpo dell'email",
"in_attachment": "Nell'allegato: {{filename}}",
"prev": "Prec",
"next": "Succ"
},
"roles": {
"title": "Gestione Ruoli",
"role_management": "Gestione Ruoli",
"create_new": "Crea Nuovo",
"name": "Nome",
"created_at": "Creato il",
"actions": "Azioni",
"open_menu": "Apri menu",
"view_policy": "Visualizza Policy",
"edit": "Modifica",
"delete": "Elimina",
"no_roles_found": "Nessun ruolo trovato.",
"role_policy": "Policy Ruolo",
"viewing_policy_for_role": "Visualizzazione policy per il ruolo: {{name}}",
"create": "Crea",
"role": "Ruolo",
"edit_description": "Apporta modifiche al ruolo qui.",
"create_description": "Aggiungi un nuovo ruolo al sistema.",
"delete_confirmation_title": "Sei sicuro di voler eliminare questo ruolo?",
"delete_confirmation_description": "Questa azione non può essere annullata. Questo eliminerà permanentemente il ruolo.",
"deleting": "Eliminazione in corso",
"confirm": "Conferma",
"cancel": "Annulla"
},
"system_settings": {
"title": "Impostazioni di Sistema",
"system_settings": "Impostazioni di Sistema",
"description": "Gestisci le impostazioni globali dell'applicazione.",
"language": "Lingua",
"default_theme": "Tema predefinito",
"light": "Chiaro",
"dark": "Scuro",
"system": "Sistema",
"support_email": "Email di Supporto",
"saving": "Salvataggio in corso",
"save_changes": "Salva Modifiche"
},
"users": {
"title": "Gestione Utenti",
"user_management": "Gestione Utenti",
"create_new": "Crea Nuovo",
"name": "Nome",
"email": "Email",
"role": "Ruolo",
"created_at": "Creato il",
"actions": "Azioni",
"open_menu": "Apri menu",
"edit": "Modifica",
"delete": "Elimina",
"no_users_found": "Nessun utente trovato.",
"create": "Crea",
"user": "Utente",
"edit_description": "Apporta modifiche all'utente qui.",
"create_description": "Aggiungi un nuovo utente al sistema.",
"delete_confirmation_title": "Sei sicuro di voler eliminare questo utente?",
"delete_confirmation_description": "Questa azione non può essere annullata. Questo eliminerà permanentemente l'utente e rimuoverà i suoi dati dai nostri server.",
"deleting": "Eliminazione in corso",
"confirm": "Conferma",
"cancel": "Annulla"
},
"components": {
"charts": {
"emails_ingested": "Email Acquisite",
"storage_used": "Spazio di Archiviazione Utilizzato",
"emails": "Email"
},
"common": {
"submitting": "Invio in corso...",
"submit": "Invia",
"save": "Salva"
},
"email_preview": {
"loading": "Caricamento anteprima email...",
"render_error": "Impossibile renderizzare l'anteprima dell'email.",
"not_available": "File .eml grezzo non disponibile per questa email."
},
"footer": {
"all_rights_reserved": "Tutti i diritti riservati."
},
"ingestion_source_form": {
"provider_generic_imap": "IMAP Generico",
"provider_google_workspace": "Google Workspace",
"provider_microsoft_365": "Microsoft 365",
"provider_pst_import": "Importazione PST",
"provider_eml_import": "Importazione EML",
"select_provider": "Seleziona un provider",
"service_account_key": "Chiave Account di Servizio (JSON)",
"service_account_key_placeholder": "Incolla il contenuto JSON della chiave del tuo account di servizio",
"impersonated_admin_email": "Email dell'Amministratore Impersonato",
"client_id": "ID Applicazione (Client)",
"client_secret": "Valore Segreto Client",
"client_secret_placeholder": "Inserisci il Valore segreto, non l'ID Segreto",
"tenant_id": "ID Directory (Tenant)",
"host": "Host",
"port": "Porta",
"username": "Nome Utente",
"use_tls": "Usa TLS",
"allow_insecure_cert": "Consenti certificato non sicuro",
"pst_file": "File PST",
"eml_file": "File EML",
"heads_up": "Attenzione!",
"org_wide_warning": "Si prega di notare che questa è un'operazione a livello di organizzazione. Questo tipo di ingestione importerà e indicizzerà <b>tutte</b> le caselle di posta elettronica nella tua organizzazione. Se vuoi importare solo caselle di posta elettronica specifiche, usa il connettore IMAP.",
"upload_failed": "Caricamento Fallito, riprova"
},
"role_form": {
"policies_json": "Policy (JSON)",
"invalid_json": "Formato JSON non valido per le policy."
},
"theme_switcher": {
"toggle_theme": "Cambia tema"
},
"user_form": {
"select_role": "Seleziona un ruolo"
}
},
"setup": {
"title": "Configurazione",
"description": "Configura l'account amministratore iniziale per Open Archiver.",
"welcome": "Benvenuto",
"create_admin_account": "Crea il primo account amministratore per iniziare.",
"first_name": "Nome",
"last_name": "Cognome",
"email": "Email",
"password": "Password",
"creating_account": "Creazione Account",
"create_account": "Crea Account"
},
"layout": {
"dashboard": "Dashboard",
"ingestions": "Ingestioni",
"archived_emails": "Email archiviate",
"search": "Ricerca",
"settings": "Impostazioni",
"system": "Sistema",
"users": "Utenti",
"roles": "Ruoli",
"api_keys": "Chiavi API",
"logout": "Esci"
},
"api_keys_page": {
"title": "Chiavi API",
"header": "Chiavi API",
"generate_new_key": "Genera Nuova Chiave",
"name": "Nome",
"key": "Chiave",
"expires_at": "Scade il",
"created_at": "Creato il",
"actions": "Azioni",
"delete": "Elimina",
"no_keys_found": "Nessuna chiave API trovata.",
"generate_modal_title": "Genera Nuova Chiave API",
"generate_modal_description": "Fornisci un nome e una scadenza per la tua nuova chiave API.",
"expires_in": "Scade Tra",
"select_expiration": "Seleziona una scadenza",
"30_days": "30 Giorni",
"60_days": "60 Giorni",
"6_months": "6 Mesi",
"12_months": "12 Mesi",
"24_months": "24 Mesi",
"generate": "Genera",
"new_api_key": "Nuova Chiave API",
"failed_to_delete": "Impossibile eliminare la chiave API",
"api_key_deleted": "Chiave API eliminata",
"generated_title": "Chiave API Generata",
"generated_message": "La tua chiave API è stata generata, per favore copiala e salvala in un luogo sicuro. Questa chiave verrà mostrata solo una volta."
},
"archived_emails_page": {
"title": "Email archiviate",
"header": "Email Archiviate",
"select_ingestion_source": "Seleziona una sorgente di ingestione",
"date": "Data",
"subject": "Oggetto",
"sender": "Mittente",
"inbox": "Posta in arrivo",
"path": "Percorso",
"actions": "Azioni",
"view": "Visualizza",
"no_emails_found": "Nessuna email archiviata trovata.",
"prev": "Prec",
"next": "Succ"
},
"dashboard_page": {
"title": "Dashboard",
"meta_description": "Panoramica del tuo archivio email.",
"header": "Dashboard",
"create_ingestion": "Crea un'ingestione",
"no_ingestion_header": "Non hai impostato nessuna sorgente di ingestione.",
"no_ingestion_text": "Aggiungi una sorgente di ingestione per iniziare ad archiviare le tue caselle di posta.",
"total_emails_archived": "Totale Email Archiviate",
"total_storage_used": "Spazio di Archiviazione Totale Utilizzato",
"failed_ingestions": "Ingestioni Fallite (Ultimi 7 Giorni)",
"ingestion_history": "Cronologia Ingestioni",
"no_ingestion_history": "Nessuna cronologia delle ingestioni disponibile.",
"storage_by_source": "Spazio di Archiviazione per Sorgente di Ingestione",
"no_ingestion_sources": "Nessuna sorgente di ingestione disponibile.",
"indexed_insights": "Approfondimenti indicizzati",
"top_10_senders": "I 10 Mittenti Principali",
"no_indexed_insights": "Nessun approfondimento indicizzato disponibile."
}
}
}
{
"app": {
"auth": {
"login": "Accedi",
"login_tip": "Inserisci la tua email qui sotto per accedere al tuo account.",
"email": "Email",
"password": "Password"
},
"common": {
"working": "In corso",
"read_docs": "Leggi la documentazione"
},
"archive": {
"title": "Archivio",
"no_subject": "Nessun oggetto",
"from": "Da",
"sent": "Inviato",
"recipients": "Destinatari",
"to": "A",
"meta_data": "Metadati",
"folder": "Cartella",
"tags": "Tag",
"size": "Dimensione",
"email_preview": "Anteprima email",
"attachments": "Allegati",
"download": "Scarica",
"actions": "Azioni",
"download_eml": "Scarica Email (.eml)",
"delete_email": "Elimina Email",
"email_thread": "Thread Email",
"delete_confirmation_title": "Sei sicuro di voler eliminare questa email?",
"delete_confirmation_description": "Questa azione non può essere annullata e rimuoverà definitivamente l'email e i suoi allegati.",
"deleting": "Eliminazione in corso",
"confirm": "Conferma",
"cancel": "Annulla",
"not_found": "Email non trovata.",
"integrity_report": "Rapporto di integrità",
"email_eml": "Email (.eml)",
"valid": "Valido",
"invalid": "Non valido",
"integrity_check_failed_title": "Controllo di integrità non riuscito",
"integrity_check_failed_message": "Impossibile verificare l'integrità dell'email e dei suoi allegati.",
"integrity_report_description": "Questo rapporto verifica che il contenuto delle tue email archiviate non sia stato alterato."
},
"ingestions": {
"title": "Fonti di acquisizione",
"ingestion_sources": "Fonti di acquisizione",
"bulk_actions": "Azioni di massa",
"force_sync": "Forza sincronizzazione",
"delete": "Elimina",
"create_new": "Crea nuovo",
"name": "Nome",
"provider": "Provider",
"status": "Stato",
"active": "Attivo",
"created_at": "Creato il",
"actions": "Azioni",
"last_sync_message": "Ultimo messaggio di sincronizzazione",
"empty": "Vuoto",
"open_menu": "Apri menu",
"edit": "Modifica",
"create": "Crea",
"ingestion_source": "Fonte di acquisizione",
"edit_description": "Apporta modifiche alla tua fonte di acquisizione qui.",
"create_description": "Aggiungi una nuova fonte di acquisizione per iniziare ad archiviare le email.",
"read": "Leggi",
"docs_here": "documentazione qui",
"delete_confirmation_title": "Sei sicuro di voler eliminare questa acquisizione?",
"delete_confirmation_description": "Questo cancellerà tutte le email archiviate, gli allegati, l'indicizzazione e i file associati a questa acquisizione. Se vuoi solo interrompere la sincronizzazione di nuove email, puoi invece mettere in pausa l'acquisizione.",
"deleting": "Eliminazione in corso",
"confirm": "Conferma",
"cancel": "Annulla",
"bulk_delete_confirmation_title": "Sei sicuro di voler eliminare {{count}} acquisizioni selezionate?",
"bulk_delete_confirmation_description": "Questo cancellerà tutte le email archiviate, gli allegati, l'indicizzazione e i file associati a queste acquisizioni. Se vuoi solo interrompere la sincronizzazione di nuove email, puoi invece mettere in pausa le acquisizioni."
},
"search": {
"title": "Cerca",
"description": "Cerca email archiviate.",
"email_search": "Ricerca email",
"placeholder": "Cerca per parola chiave, mittente, destinatario...",
"search_button": "Cerca",
"search_options": "Opzioni di ricerca",
"strategy_fuzzy": "Approssimativa",
"strategy_verbatim": "Testuale",
"strategy_frequency": "Frequenza",
"select_strategy": "Seleziona una strategia",
"error": "Errore",
"found_results_in": "Trovati {{total}} risultati in {{seconds}}s",
"found_results": "Trovati {{total}} risultati",
"from": "Da",
"to": "A",
"in_email_body": "Nel corpo dell'email",
"in_attachment": "Nell'allegato: {{filename}}",
"prev": "Prec",
"next": "Succ"
},
"roles": {
"title": "Gestione ruoli",
"role_management": "Gestione ruoli",
"create_new": "Crea nuovo",
"name": "Nome",
"created_at": "Creato il",
"actions": "Azioni",
"open_menu": "Apri menu",
"view_policy": "Visualizza Policy",
"edit": "Modifica",
"delete": "Elimina",
"no_roles_found": "Nessun ruolo trovato.",
"role_policy": "Policy del ruolo",
"viewing_policy_for_role": "Visualizzazione della policy per il ruolo: {{name}}",
"create": "Crea",
"role": "Ruolo",
"edit_description": "Apporta modifiche al ruolo qui.",
"create_description": "Aggiungi un nuovo ruolo al sistema.",
"delete_confirmation_title": "Sei sicuro di voler eliminare questo ruolo?",
"delete_confirmation_description": "Questa azione non può essere annullata. Questo cancellerà definitivamente il ruolo.",
"deleting": "Eliminazione in corso",
"confirm": "Conferma",
"cancel": "Annulla"
},
"account": {
"title": "Impostazioni account",
"description": "Gestisci il tuo profilo e le impostazioni di sicurezza.",
"personal_info": "Informazioni personali",
"personal_info_desc": "Aggiorna i tuoi dati personali.",
"security": "Sicurezza",
"security_desc": "Gestisci la tua password e le preferenze di sicurezza.",
"edit_profile": "Modifica profilo",
"change_password": "Cambia password",
"edit_profile_desc": "Apporta modifiche al tuo profilo qui.",
"change_password_desc": "Cambia la tua password. Dovrai inserire la tua password attuale.",
"current_password": "Password attuale",
"new_password": "Nuova password",
"confirm_new_password": "Conferma nuova password",
"operation_successful": "Operazione riuscita",
"passwords_do_not_match": "Le password non corrispondono"
},
"system_settings": {
"title": "Impostazioni di sistema",
"system_settings": "Impostazioni di sistema",
"description": "Gestisci le impostazioni globali dell'applicazione.",
"language": "Lingua",
"default_theme": "Tema predefinito",
"light": "Chiaro",
"dark": "Scuro",
"system": "Sistema",
"support_email": "Email di supporto",
"saving": "Salvataggio in corso",
"save_changes": "Salva modifiche"
},
"users": {
"title": "Gestione utenti",
"user_management": "Gestione utenti",
"create_new": "Crea nuovo",
"name": "Nome",
"email": "Email",
"role": "Ruolo",
"created_at": "Creato il",
"actions": "Azioni",
"open_menu": "Apri menu",
"edit": "Modifica",
"delete": "Elimina",
"no_users_found": "Nessun utente trovato.",
"create": "Crea",
"user": "Utente",
"edit_description": "Apporta modifiche all'utente qui.",
"create_description": "Aggiungi un nuovo utente al sistema.",
"delete_confirmation_title": "Sei sicuro di voler eliminare questo utente?",
"delete_confirmation_description": "Questa azione non può essere annullata. Questo cancellerà definitivamente l'utente e rimuoverà i suoi dati dai nostri server.",
"deleting": "Eliminazione in corso",
"confirm": "Conferma",
"cancel": "Annulla"
},
"components": {
"charts": {
"emails_ingested": "Email acquisite",
"storage_used": "Spazio di archiviazione utilizzato",
"emails": "Email"
},
"common": {
"submitting": "Invio in corso...",
"submit": "Invia",
"save": "Salva"
},
"email_preview": {
"loading": "Caricamento anteprima email...",
"render_error": "Impossibile visualizzare l'anteprima dell'email.",
"not_available": "File .eml grezzo non disponibile per questa email."
},
"footer": {
"all_rights_reserved": "Tutti i diritti riservati.",
"new_version_available": "Nuova versione disponibile"
},
"ingestion_source_form": {
"provider_generic_imap": "IMAP generico",
"provider_google_workspace": "Google Workspace",
"provider_microsoft_365": "Microsoft 365",
"provider_pst_import": "Importazione PST",
"provider_eml_import": "Importazione EML",
"provider_mbox_import": "Importazione Mbox",
"select_provider": "Seleziona un provider",
"service_account_key": "Chiave dell'account di servizio (JSON)",
"service_account_key_placeholder": "Incolla il contenuto JSON della chiave del tuo account di servizio",
"impersonated_admin_email": "Email dell'amministratore impersonato",
"client_id": "ID applicazione (client)",
"client_secret": "Valore del segreto client",
"client_secret_placeholder": "Inserisci il valore segreto, non l'ID segreto",
"tenant_id": "ID directory (tenant)",
"host": "Host",
"port": "Porta",
"username": "Nome utente",
"use_tls": "Usa TLS",
"allow_insecure_cert": "Consenti certificato non sicuro",
"pst_file": "File PST",
"eml_file": "File EML",
"mbox_file": "File Mbox",
"heads_up": "Attenzione!",
"org_wide_warning": "Tieni presente che questa è un'operazione a livello di organizzazione. Questo tipo di acquisizione importerà e indicizzerà <b>tutte</b> le caselle di posta nella tua organizzazione. Se vuoi importare solo caselle di posta specifiche, usa il connettore IMAP.",
"upload_failed": "Caricamento non riuscito, riprova"
},
"role_form": {
"policies_json": "Policy (JSON)",
"invalid_json": "Formato JSON non valido per le policy."
},
"theme_switcher": {
"toggle_theme": "Attiva/disattiva tema"
},
"user_form": {
"select_role": "Seleziona un ruolo"
}
},
"setup": {
"title": "Configurazione",
"description": "Configura l'account amministratore iniziale per Open Archiver.",
"welcome": "Benvenuto",
"create_admin_account": "Crea il primo account amministratore per iniziare.",
"first_name": "Nome",
"last_name": "Cognome",
"email": "Email",
"password": "Password",
"creating_account": "Creazione account",
"create_account": "Crea account"
},
"layout": {
"dashboard": "Dashboard",
"ingestions": "Acquisizioni",
"archived_emails": "Email archiviate",
"search": "Cerca",
"settings": "Impostazioni",
"system": "Sistema",
"users": "Utenti",
"roles": "Ruoli",
"api_keys": "Chiavi API",
"account": "Account",
"logout": "Disconnetti",
"admin": "Amministratore"
},
"api_keys_page": {
"title": "Chiavi API",
"header": "Chiavi API",
"generate_new_key": "Genera nuova chiave",
"name": "Nome",
"key": "Chiave",
"expires_at": "Scade il",
"created_at": "Creato il",
"actions": "Azioni",
"delete": "Elimina",
"no_keys_found": "Nessuna chiave API trovata.",
"generate_modal_title": "Genera nuova chiave API",
"generate_modal_description": "Fornisci un nome e una scadenza per la tua nuova chiave API.",
"expires_in": "Scade tra",
"select_expiration": "Seleziona una scadenza",
"30_days": "30 giorni",
"60_days": "60 giorni",
"6_months": "6 mesi",
"12_months": "12 mesi",
"24_months": "24 mesi",
"generate": "Genera",
"new_api_key": "Nuova chiave API",
"failed_to_delete": "Impossibile eliminare la chiave API",
"api_key_deleted": "Chiave API eliminata",
"generated_title": "Chiave API generata",
"generated_message": "La tua chiave API è stata generata, copiala e salvala in un luogo sicuro. Questa chiave verrà mostrata solo una volta."
},
"archived_emails_page": {
"title": "Email archiviate",
"header": "Email archiviate",
"select_ingestion_source": "Seleziona una fonte di acquisizione",
"date": "Data",
"subject": "Oggetto",
"sender": "Mittente",
"inbox": "Posta in arrivo",
"path": "Percorso",
"actions": "Azioni",
"view": "Visualizza",
"no_emails_found": "Nessuna email archiviata trovata.",
"prev": "Prec",
"next": "Succ"
},
"dashboard_page": {
"title": "Dashboard",
"meta_description": "Panoramica del tuo archivio email.",
"header": "Dashboard",
"create_ingestion": "Crea un'acquisizione",
"no_ingestion_header": "Non hai configurato alcuna fonte di acquisizione.",
"no_ingestion_text": "Aggiungi una fonte di acquisizione per iniziare ad archiviare le tue caselle di posta.",
"total_emails_archived": "Email totali archiviate",
"total_storage_used": "Spazio di archiviazione totale utilizzato",
"failed_ingestions": "Acquisizioni non riuscite (ultimi 7 giorni)",
"ingestion_history": "Cronologia acquisizioni",
"no_ingestion_history": "Nessuna cronologia acquisizioni disponibile.",
"storage_by_source": "Spazio di archiviazione per fonte di acquisizione",
"no_ingestion_sources": "Nessuna fonte di acquisizione disponibile.",
"indexed_insights": "Informazioni indicizzate",
"top_10_senders": "I 10 mittenti principali",
"no_indexed_insights": "Nessuna informazione indicizzata disponibile."
},
"audit_log": {
"title": "Registro di audit",
"header": "Registro di audit",
"verify_integrity": "Verifica l'integrità del registro",
"log_entries": "Voci di registro",
"timestamp": "Timestamp",
"actor": "Attore",
"action": "Azione",
"target": "Obiettivo",
"details": "Dettagli",
"ip_address": "Indirizzo IP",
"target_type": "Tipo di obiettivo",
"target_id": "ID obiettivo",
"no_logs_found": "Nessun registro di audit trovato.",
"prev": "Prec",
"next": "Succ",
"log_entry_details": "Dettagli della voce di registro",
"viewing_details_for": "Visualizzazione dei dettagli completi per la voce di registro #",
"actor_id": "ID attore",
"previous_hash": "Hash precedente",
"current_hash": "Hash corrente",
"close": "Chiudi",
"verification_successful_title": "Verifica riuscita",
"verification_successful_message": "Integrità del registro di audit verificata con successo.",
"verification_failed_title": "Verifica non riuscita",
"verification_failed_message": "Il controllo di integrità del registro di audit non è riuscito. Controlla i registri di sistema per maggiori dettagli.",
"verification_error_message": "Si è verificato un errore inatteso durante la verifica. Riprova."
},
"jobs": {
"title": "Code dei lavori",
"queues": "Code dei lavori",
"active": "Attivo",
"completed": "Completato",
"failed": "Fallito",
"delayed": "Ritardato",
"waiting": "In attesa",
"paused": "In pausa",
"back_to_queues": "Torna alle code",
"queue_overview": "Panoramica della coda",
"jobs": "Lavori",
"id": "ID",
"name": "Nome",
"state": "Stato",
"created_at": "Creato il",
"processed_at": "Elaborato il",
"finished_at": "Terminato il",
"showing": "Visualizzazione di",
"of": "di",
"previous": "Precedente",
"next": "Successivo",
"ingestion_source": "Fonte di acquisizione"
},
"license_page": {
"title": "Stato della licenza Enterprise",
"meta_description": "Visualizza lo stato attuale della tua licenza Open Archiver Enterprise.",
"revoked_title": "Licenza revocata",
"revoked_message": "La tua licenza è stata revocata dall'amministratore della licenza. Le funzionalità Enterprise verranno disabilitate {{grace_period}}. Contatta il tuo account manager per assistenza.",
"revoked_grace_period": "il {{date}}",
"revoked_immediately": "immediatamente",
"seat_limit_exceeded_title": "Limite di posti superato",
"seat_limit_exceeded_message": "La tua licenza è per {{planSeats}} utenti, ma ne stai attualmente utilizzando {{activeSeats}}. Contatta il reparto vendite per modificare il tuo abbonamento.",
"customer": "Cliente",
"license_details": "Dettagli licenza",
"license_status": "Stato licenza",
"active": "Attivo",
"expired": "Scaduto",
"revoked": "Revocato",
"unknown": "Sconosciuto",
"expires": "Scade",
"seat_usage": "Utilizzo posti",
"seats_used": "{{activeSeats}} di {{planSeats}} posti utilizzati",
"enabled_features": "Funzionalità abilitate",
"enabled_features_description": "Le seguenti funzionalità enterprise sono attualmente abilitate.",
"feature": "Funzionalità",
"status": "Stato",
"enabled": "Abilitato",
"disabled": "Disabilitato",
"could_not_load_title": "Impossibile caricare la licenza",
"could_not_load_message": "Si è verificato un errore inatteso."
}
}
}

View File

@@ -10,10 +10,20 @@ const handleRequest: RequestHandler = async ({ request, params, fetch }) => {
const targetUrl = `${BACKEND_URL}/${slug}${url.search}`;
try {
let body: ArrayBuffer | null = null;
const headers = new Headers(request.headers);
if (request.method !== 'GET' && request.method !== 'HEAD') {
body = await request.arrayBuffer();
if (body.byteLength > 0) {
headers.set('Content-Length', String(body.byteLength));
}
}
const proxyRequest = new Request(targetUrl, {
method: request.method,
headers: request.headers,
body: request.body,
headers: headers,
body: body,
duplex: 'half',
} as RequestInit);

View File

@@ -64,6 +64,10 @@
href: '/dashboard/settings/api-keys',
label: $t('app.layout.api_keys'),
},
{
href: '/dashboard/settings/account',
label: $t('app.layout.account'),
},
],
position: 5,
},

View File

@@ -58,7 +58,7 @@
<Card.Root>
<Card.Header>
<Card.Title>{$t('app.jobs.jobs')}</Card.Title>
<div class="flex space-x-2">
<div class="flex flex-wrap space-x-2 space-y-2">
{#each jobStatuses as status}
<Button
variant={selectedStatus === status ? 'default' : 'outline'}

View File

@@ -0,0 +1,58 @@
import type { PageServerLoad, Actions } from './$types';
import { api } from '$lib/server/api';
import { fail } from '@sveltejs/kit';
import type { User } from '@open-archiver/types';
export const load: PageServerLoad = async (event) => {
const response = await api('/users/profile', event);
if (!response.ok) {
const error = await response.json();
console.error('Failed to fetch profile:', error);
// Return null user if failed, handle in UI
return { user: null };
}
const user: User = await response.json();
return { user };
};
export const actions: Actions = {
updateProfile: async (event) => {
const data = await event.request.formData();
const first_name = data.get('first_name');
const last_name = data.get('last_name');
const email = data.get('email');
const response = await api('/users/profile', event, {
method: 'PATCH',
body: JSON.stringify({ first_name, last_name, email }),
});
if (!response.ok) {
const error = await response.json();
return fail(response.status, {
profileError: true,
message: error.message || 'Failed to update profile',
});
}
return { success: true };
},
updatePassword: async (event) => {
const data = await event.request.formData();
const currentPassword = data.get('currentPassword');
const newPassword = data.get('newPassword');
const response = await api('/users/profile/password', event, {
method: 'POST',
body: JSON.stringify({ currentPassword, newPassword }),
});
if (!response.ok) {
const error = await response.json();
return fail(response.status, {
passwordError: true,
message: error.message || 'Failed to update password',
});
}
return { success: true };
},
};

View File

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

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

@@ -56,13 +56,15 @@ export interface EmailObject {
}
/**
* Represents an email that has been processed and is ready for indexing.
* Represents an email that has been processed and is ready for indexing.
* This interface defines the shape of the data that is passed to the batch indexing function.
*/
export interface PendingEmail {
email: EmailObject;
sourceId: string;
archivedId: string;
/** The unique identifier of the archived email record in the database.
* This ID is used to retrieve the full email data from the database and storage for indexing.
*/
archivedEmailId: string;
}
// Define the structure of the document to be indexed in Meilisearch

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