mirror of
https://github.com/LogicLabs-OU/OpenArchiver.git
synced 2026-04-06 08:41:57 +02:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b49d8a78ce | ||
|
|
d372ef7566 | ||
|
|
e9a65f9672 | ||
|
|
ce3f379b7a | ||
|
|
37a778cb6d | ||
|
|
26a760b232 | ||
|
|
6be0774bc4 | ||
|
|
4a23f8f29f | ||
|
|
074256ed59 | ||
|
|
7d178d786b | ||
|
|
4b11cd931a | ||
|
|
0a21ad14cd | ||
|
|
63d3960f79 | ||
|
|
85a526d1b6 | ||
|
|
52a1a11973 | ||
|
|
4048f47777 | ||
|
|
22b173cbe4 | ||
|
|
774b0d7a6b | ||
|
|
85607d2ab3 | ||
|
|
94021eab69 | ||
|
|
faefdac44a | ||
|
|
392f51dabc | ||
|
|
baff1195c7 | ||
|
|
f1da17e484 |
17
.env.example
17
.env.example
@@ -19,7 +19,8 @@ DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/$
|
||||
# Meilisearch
|
||||
MEILI_MASTER_KEY=aSampleMasterKey
|
||||
MEILI_HOST=http://meilisearch:7700
|
||||
|
||||
# The number of emails to batch together for indexing. Defaults to 500.
|
||||
MEILI_INDEXING_BATCH=500
|
||||
|
||||
|
||||
# Redis (We use Valkey, which is Redis-compatible and open source)
|
||||
@@ -54,17 +55,27 @@ STORAGE_S3_FORCE_PATH_STYLE=false
|
||||
|
||||
# --- Security & Authentication ---
|
||||
|
||||
# Rate Limiting
|
||||
# The window in milliseconds for which API requests are checked. Defaults to 60000 (1 minute).
|
||||
RATE_LIMIT_WINDOW_MS=60000
|
||||
# The maximum number of API requests allowed from an IP within the window. Defaults to 100.
|
||||
RATE_LIMIT_MAX_REQUESTS=100
|
||||
|
||||
|
||||
|
||||
# JWT
|
||||
# IMPORTANT: Change this to a long, random, and secret string in your .env file
|
||||
JWT_SECRET=a-very-secret-key-that-you-should-change
|
||||
JWT_EXPIRES_IN="7d"
|
||||
|
||||
# Set the credentials for the initial admin user.
|
||||
SUPER_API_KEY=
|
||||
|
||||
# Master Encryption Key for sensitive data (Such as Ingestion source credentials and passwords)
|
||||
# IMPORTANT: Generate a secure, random 32-byte hex string for this
|
||||
# You can use `openssl rand -hex 32` to generate a key.
|
||||
ENCRYPTION_KEY=
|
||||
|
||||
# Apache Tika Integration
|
||||
# ONLY active if TIKA_URL is set
|
||||
TIKA_URL=http://tika:9998
|
||||
|
||||
|
||||
|
||||
1
.github/FUNDING.yml
vendored
Normal file
1
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
github: [wayneshn]
|
||||
32
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
32
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
5. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**System:**
|
||||
- Open Archiver Version:
|
||||
|
||||
**Relevant logs:**
|
||||
Any relevant logs (Redact sensitive information)
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is.
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
@@ -46,12 +46,14 @@ Password: openarchiver_demo
|
||||
- Microsoft 365
|
||||
- PST files
|
||||
- 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.
|
||||
- **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).
|
||||
|
||||
## 🛠️ Tech Stack
|
||||
@@ -78,7 +80,7 @@ Open Archiver is built on a modern, scalable, and maintainable technology stack:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/LogicLabs-OU/OpenArchiver.git
|
||||
cd open-archiver
|
||||
cd OpenArchiver
|
||||
```
|
||||
|
||||
2. **Configure your environment:**
|
||||
|
||||
@@ -6,7 +6,6 @@ services:
|
||||
container_name: open-archiver
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- '4000:4000' # Backend
|
||||
- '3000:3000' # Frontend
|
||||
env_file:
|
||||
- .env
|
||||
@@ -29,8 +28,6 @@ services:
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-password}
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
ports:
|
||||
- '5432:5432'
|
||||
networks:
|
||||
- open-archiver-net
|
||||
|
||||
@@ -39,8 +36,6 @@ services:
|
||||
container_name: valkey
|
||||
restart: unless-stopped
|
||||
command: valkey-server --requirepass ${REDIS_PASSWORD}
|
||||
ports:
|
||||
- '6379:6379'
|
||||
volumes:
|
||||
- valkeydata:/data
|
||||
networks:
|
||||
@@ -52,13 +47,18 @@ services:
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MEILI_MASTER_KEY: ${MEILI_MASTER_KEY:-aSampleMasterKey}
|
||||
ports:
|
||||
- '7700:7700'
|
||||
volumes:
|
||||
- meilidata:/meili_data
|
||||
networks:
|
||||
- open-archiver-net
|
||||
|
||||
tika:
|
||||
image: apache/tika:3.2.2.0-full
|
||||
container_name: tika
|
||||
restart: always
|
||||
networks:
|
||||
- open-archiver-net
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
driver: local
|
||||
|
||||
@@ -12,7 +12,7 @@ export default defineConfig({
|
||||
],
|
||||
['link', { rel: 'icon', href: '/logo-sq.svg' }],
|
||||
],
|
||||
title: 'Open Archiver',
|
||||
title: 'Open Archiver Docs',
|
||||
description: 'Official documentation for the Open Archiver project.',
|
||||
themeConfig: {
|
||||
search: {
|
||||
@@ -52,6 +52,31 @@ export default defineConfig({
|
||||
},
|
||||
{ text: 'EML Import', link: '/user-guides/email-providers/eml' },
|
||||
{ text: 'PST Import', link: '/user-guides/email-providers/pst' },
|
||||
{ text: 'Mbox Import', link: '/user-guides/email-providers/mbox' },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Settings',
|
||||
collapsed: true,
|
||||
items: [
|
||||
{
|
||||
text: 'System',
|
||||
link: '/user-guides/settings/system',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Upgrading and Migration',
|
||||
collapsed: true,
|
||||
items: [
|
||||
{
|
||||
text: 'Upgrading',
|
||||
link: '/user-guides/upgrade-and-migration/upgrade',
|
||||
},
|
||||
{
|
||||
text: 'Meilisearch Upgrade',
|
||||
link: '/user-guides/upgrade-and-migration/meilisearch-upgrade',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -61,6 +86,7 @@ export default defineConfig({
|
||||
items: [
|
||||
{ text: 'Overview', link: '/api/' },
|
||||
{ text: 'Authentication', link: '/api/authentication' },
|
||||
{ text: 'Rate Limiting', link: '/api/rate-limiting' },
|
||||
{ text: 'Auth', link: '/api/auth' },
|
||||
{ text: 'Archived Email', link: '/api/archived-email' },
|
||||
{ text: 'Dashboard', link: '/api/dashboard' },
|
||||
@@ -74,10 +100,10 @@ export default defineConfig({
|
||||
items: [
|
||||
{ text: 'Overview', link: '/services/' },
|
||||
{ text: 'Storage Service', link: '/services/storage-service' },
|
||||
{ text: 'OCR Service', link: '/services/ocr-service' },
|
||||
{
|
||||
text: 'IAM Service', items: [
|
||||
{ text: 'IAM Policies', link: '/services/iam-service/iam-policy' }
|
||||
]
|
||||
text: 'IAM Service',
|
||||
items: [{ text: 'IAM Policies', link: '/services/iam-service/iam-policy' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,60 +1,25 @@
|
||||
# API Authentication
|
||||
|
||||
To access protected API endpoints, you need to include a JSON Web Token (JWT) in the `Authorization` header of your requests.
|
||||
To access protected API endpoints, you need to include a user-generated API key in the `X-API-KEY` header of your requests.
|
||||
|
||||
## Obtaining a JWT
|
||||
## 1. Creating an API Key
|
||||
|
||||
First, you need to authenticate with the `/api/v1/auth/login` endpoint by providing your email and password. If the credentials are correct, the API will return an `accessToken`.
|
||||
You can create, manage, and view your API keys through the application's user interface.
|
||||
|
||||
**Request:**
|
||||
1. Navigate to **Settings > API Keys** in the dashboard.
|
||||
2. Click the **"Generate API Key"** button.
|
||||
3. Provide a descriptive name for your key and select an expiration period.
|
||||
4. The new API key will be displayed. **Copy this key immediately and store it in a secure location. You will not be able to see it again.**
|
||||
|
||||
```http
|
||||
POST /api/v1/auth/login
|
||||
Content-Type: application/json
|
||||
## 2. Making Authenticated Requests
|
||||
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"password": "your-password"
|
||||
}
|
||||
```
|
||||
|
||||
**Successful Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"accessToken": "your.jwt.token",
|
||||
"user": {
|
||||
"id": "user-id",
|
||||
"email": "user@example.com",
|
||||
"role": "user"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Making Authenticated Requests
|
||||
|
||||
Once you have the `accessToken`, you must include it in the `Authorization` header of all subsequent requests to protected endpoints, using the `Bearer` scheme.
|
||||
Once you have your API key, you must include it in the `X-API-KEY` header of all subsequent requests to protected API endpoints.
|
||||
|
||||
**Example:**
|
||||
|
||||
```http
|
||||
GET /api/v1/dashboard/stats
|
||||
Authorization: Bearer your.jwt.token
|
||||
X-API-KEY: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2
|
||||
```
|
||||
|
||||
If the token is missing, expired, or invalid, the API will respond with a `401 Unauthorized` status code.
|
||||
|
||||
## Using a Super API Key
|
||||
|
||||
Alternatively, for server-to-server communication or scripts, you can use a super API key. This key provides unrestricted access to the API and should be kept secret.
|
||||
|
||||
You can set the `SUPER_API_KEY` in your `.env` file.
|
||||
|
||||
To authenticate using the super API key, include it in the `Authorization` header as a Bearer token.
|
||||
|
||||
**Example:**
|
||||
|
||||
```http
|
||||
GET /api/v1/dashboard/stats
|
||||
Authorization: Bearer your-super-secret-api-key
|
||||
```
|
||||
If the API key is missing, expired, or invalid, the API will respond with a `401 Unauthorized` status code.
|
||||
|
||||
51
docs/api/rate-limiting.md
Normal file
51
docs/api/rate-limiting.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# Rate Limiting
|
||||
|
||||
The API implements rate limiting as a security measure to protect your instance from denial-of-service (DoS) and brute-force attacks. This is a crucial feature for maintaining the security and stability of the application.
|
||||
|
||||
## How It Works
|
||||
|
||||
The rate limiter restricts the number of requests an IP address can make within a specific time frame. These limits are configurable via environment variables to suit your security needs.
|
||||
|
||||
By default, the limits are:
|
||||
|
||||
- **100 requests** per **1 minute** per IP address.
|
||||
|
||||
If this limit is exceeded, the API will respond with an HTTP `429 Too Many Requests` status code.
|
||||
|
||||
### Response Body
|
||||
|
||||
When an IP address is rate-limited, the API will return a JSON response with the following format:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": 429,
|
||||
"message": "Too many requests from this IP, please try again after 15 minutes"
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
You can customize the rate-limiting settings by setting the following environment variables in your `.env` file:
|
||||
|
||||
- `RATE_LIMIT_WINDOW_MS`: The time window in milliseconds. Defaults to `60000` (1 minute).
|
||||
- `RATE_LIMIT_MAX_REQUESTS`: The maximum number of requests allowed per IP address within the time window. Defaults to `100`.
|
||||
|
||||
## Handling Rate Limits
|
||||
|
||||
If you are developing a client that interacts with the API, you should handle rate limiting gracefully:
|
||||
|
||||
1. **Check the Status Code**: Monitor for a `429` HTTP status code in responses.
|
||||
2. **Implement a Retry Mechanism**: When you receive a `429` response, it is best practice to wait before retrying the request. Implementing an exponential backoff strategy is recommended.
|
||||
3. **Check Headers**: The response will include the following standard headers to help you manage your request rate:
|
||||
- `RateLimit-Limit`: The maximum number of requests allowed in the current window.
|
||||
- `RateLimit-Remaining`: The number of requests you have left in the current window.
|
||||
- `RateLimit-Reset`: The time when the rate limit window will reset, in UTC epoch seconds.
|
||||
|
||||
## Excluded Endpoints
|
||||
|
||||
Certain essential endpoints are excluded from rate limiting to ensure the application's UI remains responsive. These are:
|
||||
|
||||
- `/auth/status`
|
||||
- `/settings/system`
|
||||
|
||||
These endpoints can be called as needed without affecting your rate limit count.
|
||||
@@ -1,289 +0,0 @@
|
||||
# IAM Policies
|
||||
|
||||
This document provides a guide to creating and managing IAM policies in Open Archiver. It is intended for developers and administrators who need to configure granular access control for users and roles.
|
||||
|
||||
## Policy Structure
|
||||
|
||||
IAM policies are defined as an array of JSON objects, where each object represents a single permission rule. The structure of a policy object is as follows:
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "read" OR ["read", "create"],
|
||||
"subject": "ingestion" OR ["ingestion", "dashboard"],
|
||||
"conditions": {
|
||||
"field_name": "value"
|
||||
},
|
||||
"inverted": false OR true,
|
||||
}
|
||||
```
|
||||
|
||||
- `action`: The action(s) to be performed on the subject. Can be a single string or an array of strings.
|
||||
- `subject`: The resource(s) or entity on which the action is to be performed. Can be a single string or an array of strings.
|
||||
- `conditions`: (Optional) A set of conditions that must be met for the permission to be granted.
|
||||
- `inverted`: (Optional) When set to `true`, this inverts the rule, turning it from a "can" rule into a "cannot" rule. This is useful for creating exceptions to broader permissions.
|
||||
|
||||
## Actions
|
||||
|
||||
The following actions are available for use in IAM policies:
|
||||
|
||||
- `manage`: A wildcard action that grants all permissions on a subject (`create`, `read`, `update`, `delete`, `search`, `sync`).
|
||||
- `create`: Allows the user to create a new resource.
|
||||
- `read`: Allows the user to view a resource.
|
||||
- `update`: Allows the user to modify an existing resource.
|
||||
- `delete`: Allows the user to delete a resource.
|
||||
- `search`: Allows the user to search for resources.
|
||||
- `sync`: Allows the user to synchronize a resource.
|
||||
|
||||
## Subjects
|
||||
|
||||
The following subjects are available for use in IAM policies:
|
||||
|
||||
- `all`: A wildcard subject that represents all resources.
|
||||
- `archive`: Represents archived emails.
|
||||
- `ingestion`: Represents ingestion sources.
|
||||
- `settings`: Represents system settings.
|
||||
- `users`: Represents user accounts.
|
||||
- `roles`: Represents user roles.
|
||||
- `dashboard`: Represents the dashboard.
|
||||
|
||||
## Advanced Conditions with MongoDB-Style Queries
|
||||
|
||||
Conditions are the key to creating fine-grained access control rules. They are defined as a JSON object where each key represents a field on the subject, and the value defines the criteria for that field.
|
||||
|
||||
All conditions within a single rule are implicitly joined with an **AND** logic. This means that for a permission to be granted, the resource must satisfy _all_ specified conditions.
|
||||
|
||||
The power of this system comes from its use of a subset of [MongoDB's query language](https://www.mongodb.com/docs/manual/), which provides a flexible and expressive way to define complex rules. These rules are translated into native queries for both the PostgreSQL database (via Drizzle ORM) and the Meilisearch engine.
|
||||
|
||||
### Supported Operators and Examples
|
||||
|
||||
Here is a detailed breakdown of the supported operators with examples.
|
||||
|
||||
#### `$eq` (Equal)
|
||||
|
||||
This is the default operator. If you provide a simple key-value pair, it is treated as an equality check.
|
||||
|
||||
```json
|
||||
// This rule...
|
||||
{ "status": "active" }
|
||||
|
||||
// ...is equivalent to this:
|
||||
{ "status": { "$eq": "active" } }
|
||||
```
|
||||
|
||||
**Use Case**: Grant access to an ingestion source only if its status is `active`.
|
||||
|
||||
#### `$ne` (Not Equal)
|
||||
|
||||
Matches documents where the field value is not equal to the specified value.
|
||||
|
||||
```json
|
||||
{ "provider": { "$ne": "pst_import" } }
|
||||
```
|
||||
|
||||
**Use Case**: Allow a user to see all ingestion sources except for PST imports.
|
||||
|
||||
#### `$in` (In Array)
|
||||
|
||||
Matches documents where the field value is one of the values in the specified array.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": {
|
||||
"$in": ["INGESTION_ID_1", "INGESTION_ID_2"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Use Case**: Grant an auditor access to a specific list of ingestion sources.
|
||||
|
||||
#### `$nin` (Not In Array)
|
||||
|
||||
Matches documents where the field value is not one of the values in the specified array.
|
||||
|
||||
```json
|
||||
{ "provider": { "$nin": ["pst_import", "eml_import"] } }
|
||||
```
|
||||
|
||||
**Use Case**: Hide all manual import sources from a specific user role.
|
||||
|
||||
#### `$lt` / `$lte` (Less Than / Less Than or Equal)
|
||||
|
||||
Matches documents where the field value is less than (`$lt`) or less than or equal to (`$lte`) the specified value. This is useful for numeric or date-based comparisons.
|
||||
|
||||
```json
|
||||
{ "sentAt": { "$lt": "2024-01-01T00:00:00.000Z" } }
|
||||
```
|
||||
|
||||
#### `$gt` / `$gte` (Greater Than / Greater Than or Equal)
|
||||
|
||||
Matches documents where the field value is greater than (`$gt`) or greater than or equal to (`$gte`) the specified value.
|
||||
|
||||
```json
|
||||
{ "sentAt": { "$lt": "2024-01-01T00:00:00.000Z" } }
|
||||
```
|
||||
|
||||
#### `$exists`
|
||||
|
||||
Matches documents that have (or do not have) the specified field.
|
||||
|
||||
```json
|
||||
// Grant access only if a 'lastSyncStatusMessage' exists
|
||||
{ "lastSyncStatusMessage": { "$exists": true } }
|
||||
```
|
||||
|
||||
## Inverted Rules: Creating Exceptions with `cannot`
|
||||
|
||||
By default, all rules are "can" rules, meaning they grant permissions. However, you can create a "cannot" rule by adding `"inverted": true` to a policy object. This is extremely useful for creating exceptions to broader permissions.
|
||||
|
||||
A common pattern is to grant broad access and then use an inverted rule to carve out a specific restriction.
|
||||
|
||||
**Use Case**: Grant a user access to all ingestion sources _except_ for one specific source.
|
||||
|
||||
This is achieved with two rules:
|
||||
|
||||
1. A "can" rule that grants `read` access to the `ingestion` subject.
|
||||
2. An inverted "cannot" rule that denies `read` access for the specific ingestion `id`.
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"action": "read",
|
||||
"subject": "ingestion"
|
||||
},
|
||||
{
|
||||
"inverted": true,
|
||||
"action": "read",
|
||||
"subject": "ingestion",
|
||||
"conditions": {
|
||||
"id": "SPECIFIC_INGESTION_ID_TO_EXCLUDE"
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## Policy Evaluation Logic
|
||||
|
||||
The system evaluates policies by combining all relevant rules for a user. The logic is simple:
|
||||
|
||||
- A user has permission if at least one `can` rule allows it.
|
||||
- A permission is denied if a `cannot` (`"inverted": true`) rule explicitly forbids it, even if a `can` rule allows it. `cannot` rules always take precedence.
|
||||
|
||||
### Dynamic Policies with Placeholders
|
||||
|
||||
To create dynamic policies that are specific to the current user, you can use the `${user.id}` placeholder in the `conditions` object. This placeholder will be replaced with the ID of the current user at runtime.
|
||||
|
||||
## Special Permissions for User and Role Management
|
||||
|
||||
It is important to note that while `read` access to `users` and `roles` can be granted granularly, any actions that modify these resources (`create`, `update`, `delete`) are restricted to Super Admins.
|
||||
|
||||
A user must have the `{ "action": "manage", "subject": "all" }` permission (Typically a Super Admin role) to manage users and roles. This is a security measure to prevent unauthorized changes to user accounts and permissions.
|
||||
|
||||
## Policy Examples
|
||||
|
||||
Here are several examples based on the default roles in the system, demonstrating how to combine actions, subjects, and conditions to achieve specific access control scenarios.
|
||||
|
||||
### Administrator
|
||||
|
||||
This policy grants a user full access to all resources using wildcards.
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"action": "manage",
|
||||
"subject": "all"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### End-User
|
||||
|
||||
This policy allows a user to view the dashboard, create new ingestion sources, and fully manage the ingestion sources they own.
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"action": "read",
|
||||
"subject": "dashboard"
|
||||
},
|
||||
{
|
||||
"action": "create",
|
||||
"subject": "ingestion"
|
||||
},
|
||||
{
|
||||
"action": "manage",
|
||||
"subject": "ingestion",
|
||||
"conditions": {
|
||||
"userId": "${user.id}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "manage",
|
||||
"subject": "archive",
|
||||
"conditions": {
|
||||
"ingestionSource.userId": "${user.id}" // also needs to give permission to archived emails created by the user
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### Global Read-Only Auditor
|
||||
|
||||
This policy grants read and search access across most of the application's resources, making it suitable for an auditor who needs to view data without modifying it.
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"action": ["read", "search"],
|
||||
"subject": ["ingestion", "archive", "dashboard", "users", "roles"]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### Ingestion Admin
|
||||
|
||||
This policy grants full control over all ingestion sources and archives, but no other resources.
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"action": "manage",
|
||||
"subject": "ingestion"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### Auditor for Specific Ingestion Sources
|
||||
|
||||
This policy demonstrates how to grant access to a specific list of ingestion sources using the `$in` operator.
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"action": ["read", "search"],
|
||||
"subject": "ingestion",
|
||||
"conditions": {
|
||||
"id": {
|
||||
"$in": ["INGESTION_ID_1", "INGESTION_ID_2"]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### Limit Access to a Specific Mailbox
|
||||
|
||||
This policy grants a user access to a specific ingestion source, but only allows them to see emails belonging to a single user within that source.
|
||||
|
||||
This is achieved by defining two specific `can` rules: The rule grants `read` and `search` access to the `archive` subject, but the `userEmail` must match.
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"action": ["read", "search"],
|
||||
"subject": "archive",
|
||||
"conditions": {
|
||||
"userEmail": "user1@example.com"
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
96
docs/services/ocr-service.md
Normal file
96
docs/services/ocr-service.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# OCR Service
|
||||
|
||||
The OCR (Optical Character Recognition) and text extraction service is responsible for extracting plain text content from various file formats, such as PDFs, Office documents, and more. This is a crucial component for making email attachments searchable.
|
||||
|
||||
## Overview
|
||||
|
||||
The system employs a two-pronged approach for text extraction:
|
||||
|
||||
1. **Primary Extractor (Apache Tika)**: A powerful and versatile toolkit that can extract text from a wide variety of file formats. It is the recommended method for its superior performance and format support.
|
||||
2. **Legacy Extractor**: A fallback mechanism that uses a combination of libraries (`pdf2json`, `mammoth`, `xlsx`) for common file types like PDF, DOCX, and XLSX. This is used when Apache Tika is not configured.
|
||||
|
||||
The main logic resides in `packages/backend/src/helpers/textExtractor.ts`, which decides which extraction method to use based on the application's configuration.
|
||||
|
||||
## Configuration
|
||||
|
||||
To enable the primary text extraction method, you must configure the URL of an Apache Tika server instance in your environment variables.
|
||||
|
||||
In your `.env` file, set the `TIKA_URL`:
|
||||
|
||||
```env
|
||||
# .env.example
|
||||
|
||||
# Apache Tika Integration
|
||||
# ONLY active if TIKA_URL is set
|
||||
TIKA_URL=http://tika:9998
|
||||
```
|
||||
|
||||
If `TIKA_URL` is not set, the system will automatically fall back to the legacy extraction methods. The service performs a health check on startup to verify connectivity with the Tika server.
|
||||
|
||||
## File Size Limits
|
||||
|
||||
To prevent excessive memory usage and processing time, the service imposes a general size limit on files submitted for text extraction. Files larger than the configured limit will be skipped.
|
||||
|
||||
- **With Apache Tika**: The maximum file size is **100MB**.
|
||||
- **With Legacy Fallback**: The maximum file size is **50MB**.
|
||||
|
||||
## Supported File Formats
|
||||
|
||||
The service's ability to extract text depends on whether it's using Apache Tika or the legacy fallback methods.
|
||||
|
||||
### With Apache Tika
|
||||
|
||||
When `TIKA_URL` is configured, the service can process a vast range of file formats. Apache Tika is designed for broad compatibility and supports hundreds of file types, including but not limited to:
|
||||
|
||||
- Portable Document Format (PDF)
|
||||
- Microsoft Office formats (DOC, DOCX, PPT, PPTX, XLS, XLSX)
|
||||
- OpenDocument Formats (ODT, ODS, ODP)
|
||||
- Rich Text Format (RTF)
|
||||
- Plain Text (TXT, CSV, JSON, XML, HTML)
|
||||
- Image formats with OCR capabilities (PNG, JPEG, TIFF)
|
||||
- Archive formats (ZIP, TAR, GZ)
|
||||
- Email formats (EML, MSG)
|
||||
|
||||
For a complete and up-to-date list, please refer to the official [Apache Tika documentation](https://tika.apache.org/3.2.3/formats.html).
|
||||
|
||||
### With Legacy Fallback
|
||||
|
||||
When Tika is not configured, text extraction is limited to the following formats:
|
||||
|
||||
- `application/pdf` (PDF)
|
||||
- `application/vnd.openxmlformats-officedocument.wordprocessingml.document` (DOCX)
|
||||
- `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet` (XLSX)
|
||||
- Plain text formats such as `text/*`, `application/json`, and `application/xml`.
|
||||
|
||||
## Features of the Tika Integration (`OcrService`)
|
||||
|
||||
The `OcrService` (`packages/backend/src/services/OcrService.ts`) provides several enhancements to make text extraction efficient and robust.
|
||||
|
||||
### Caching
|
||||
|
||||
To avoid redundant processing of the same file, the service implements a simple LRU (Least Recently Used) cache.
|
||||
|
||||
- **Cache Key**: A SHA-256 hash of the file's buffer is used as the cache key.
|
||||
- **Functionality**: If a file with the same hash is processed again, the text content is served directly from the cache, saving significant processing time.
|
||||
- **Statistics**: The service keeps track of cache hits, misses, and the hit rate for performance monitoring.
|
||||
|
||||
### Concurrency Management (Semaphore)
|
||||
|
||||
Extracting text from large files can be resource-intensive. To prevent the Tika server from being overwhelmed by multiple requests for the _same file_ simultaneously (e.g., during a large import), a semaphore mechanism is used.
|
||||
|
||||
- **Functionality**: If a request for a specific file (identified by its hash) is already in progress, any subsequent requests for the same file will wait for the first one to complete and then use its result.
|
||||
- **Benefit**: This deduplicates parallel processing efforts and reduces unnecessary load on the Tika server.
|
||||
|
||||
### Health Check and DNS Fallback
|
||||
|
||||
- **Availability Check**: The service includes a `checkTikaAvailability` method to verify that the Tika server is reachable and operational. This check is performed on application startup.
|
||||
- **DNS Fallback**: For convenience in Docker environments, if the Tika URL uses the hostname `tika` (e.g., `http://tika:9998`), the service will automatically attempt a fallback to `localhost` if the initial connection fails.
|
||||
|
||||
## Legacy Fallback Methods
|
||||
|
||||
When Tika is not available, the `extractTextLegacy` function in `textExtractor.ts` handles extraction for a limited set of MIME types:
|
||||
|
||||
- `application/pdf`: Processed using `pdf2json`. Includes a 50MB size limit and a 5-second timeout to prevent memory issues.
|
||||
- `application/vnd.openxmlformats-officedocument.wordprocessingml.document` (DOCX): Processed using `mammoth`.
|
||||
- `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet` (XLSX): Processed using `xlsx`.
|
||||
- Plain text formats (`text/*`, `application/json`, `application/xml`): Converted directly from the buffer.
|
||||
@@ -9,3 +9,4 @@ Choose your provider from the list below to get started:
|
||||
- [Generic IMAP Server](./imap.md)
|
||||
- [EML Import](./eml.md)
|
||||
- [PST Import](./pst.md)
|
||||
- [Mbox Import](./mbox.md)
|
||||
|
||||
29
docs/user-guides/email-providers/mbox.md
Normal file
29
docs/user-guides/email-providers/mbox.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# Mbox Ingestion
|
||||
|
||||
Mbox is a common format for storing email messages. This guide will walk you through the process of ingesting mbox files into OpenArchiver.
|
||||
|
||||
## 1. Exporting from Your Email Client
|
||||
|
||||
Most email clients that support mbox exports will allow you to export a folder of emails as a single `.mbox` file. Here are the general steps:
|
||||
|
||||
- **Mozilla Thunderbird**: Right-click on a folder, select **ImportExportTools NG**, and then choose **Export folder**.
|
||||
- **Gmail**: You can use Google Takeout to export your emails in mbox format.
|
||||
- **Other Clients**: Refer to your email client's documentation for instructions on how to export emails to an mbox file.
|
||||
|
||||
## 2. Uploading to OpenArchiver
|
||||
|
||||
Once you have your `.mbox` file, you can upload it to OpenArchiver through the web interface.
|
||||
|
||||
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.
|
||||
|
||||
## 3. Folder Structure
|
||||
|
||||
OpenArchiver will attempt to preserve the original folder structure of your emails. This is done by inspecting the following email headers:
|
||||
|
||||
- `X-Gmail-Labels`: Used by Gmail to store labels.
|
||||
- `X-Folder`: A custom header used by some email clients like Thunderbird.
|
||||
|
||||
If neither of these headers is present, the emails will be ingested into the root of the archive.
|
||||
@@ -76,18 +76,19 @@ Here is a complete list of environment variables available for configuration:
|
||||
|
||||
These variables are used by `docker-compose.yml` to configure the services.
|
||||
|
||||
| Variable | Description | Default Value |
|
||||
| ------------------- | ----------------------------------------------- | -------------------------------------------------------- |
|
||||
| `POSTGRES_DB` | The name of the PostgreSQL database. | `open_archive` |
|
||||
| `POSTGRES_USER` | The username for the PostgreSQL database. | `admin` |
|
||||
| `POSTGRES_PASSWORD` | The password for the PostgreSQL database. | `password` |
|
||||
| `DATABASE_URL` | The connection URL for the PostgreSQL database. | `postgresql://admin:password@postgres:5432/open_archive` |
|
||||
| `MEILI_MASTER_KEY` | The master key for Meilisearch. | `aSampleMasterKey` |
|
||||
| `MEILI_HOST` | The host for the Meilisearch service. | `http://meilisearch:7700` |
|
||||
| `REDIS_HOST` | The host for the Valkey (Redis) service. | `valkey` |
|
||||
| `REDIS_PORT` | The port for the Valkey (Redis) service. | `6379` |
|
||||
| `REDIS_PASSWORD` | The password for the Valkey (Redis) service. | `defaultredispassword` |
|
||||
| `REDIS_TLS_ENABLED` | Enable or disable TLS for Redis. | `false` |
|
||||
| Variable | Description | Default Value |
|
||||
| ---------------------- | ---------------------------------------------------- | -------------------------------------------------------- |
|
||||
| `POSTGRES_DB` | The name of the PostgreSQL database. | `open_archive` |
|
||||
| `POSTGRES_USER` | The username for the PostgreSQL database. | `admin` |
|
||||
| `POSTGRES_PASSWORD` | The password for the PostgreSQL database. | `password` |
|
||||
| `DATABASE_URL` | The connection URL for the PostgreSQL database. | `postgresql://admin:password@postgres:5432/open_archive` |
|
||||
| `MEILI_MASTER_KEY` | The master key for Meilisearch. | `aSampleMasterKey` |
|
||||
| `MEILI_HOST` | The host for the Meilisearch service. | `http://meilisearch:7700` |
|
||||
| `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_PASSWORD` | The password for the Valkey (Redis) service. | `defaultredispassword` |
|
||||
| `REDIS_TLS_ENABLED` | Enable or disable TLS for Redis. | `false` |
|
||||
|
||||
#### Storage Settings
|
||||
|
||||
@@ -105,12 +106,20 @@ These variables are used by `docker-compose.yml` to configure the services.
|
||||
|
||||
#### Security & Authentication
|
||||
|
||||
| Variable | Description | Default Value |
|
||||
| ---------------- | ------------------------------------------------------------------- | ------------------------------------------ |
|
||||
| `JWT_SECRET` | A secret key for signing JWT tokens. | `a-very-secret-key-that-you-should-change` |
|
||||
| `JWT_EXPIRES_IN` | The expiration time for JWT tokens. | `7d` |
|
||||
| `SUPER_API_KEY` | An API key with super admin privileges. | |
|
||||
| `ENCRYPTION_KEY` | A 32-byte hex string for encrypting sensitive data in the database. | |
|
||||
| Variable | Description | Default Value |
|
||||
| -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ |
|
||||
| `JWT_SECRET` | A secret key for signing JWT tokens. | `a-very-secret-key-that-you-should-change` |
|
||||
| `JWT_EXPIRES_IN` | The expiration time for JWT tokens. | `7d` |
|
||||
| ~~`SUPER_API_KEY`~~ (Deprecated) | An API key with super admin privileges. (The SUPER_API_KEY is deprecated since v0.3.0 after we roll out the role-based access control system.) | |
|
||||
| `RATE_LIMIT_WINDOW_MS` | The window in milliseconds for which API requests are checked. | `900000` (15 minutes) |
|
||||
| `RATE_LIMIT_MAX_REQUESTS` | The maximum number of API requests allowed from an IP within the window. | `100` |
|
||||
| `ENCRYPTION_KEY` | A 32-byte hex string for encrypting sensitive data in the database. | |
|
||||
|
||||
#### Apache Tika Integration
|
||||
|
||||
| Variable | Description | Default Value |
|
||||
| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------ |
|
||||
| `TIKA_URL` | Optional. The URL of an Apache Tika server for advanced text extraction from attachments. If not set, the application falls back to built-in parsers for PDF, Word, and Excel files. | `http://tika:9998` |
|
||||
|
||||
## 3. Run the Application
|
||||
|
||||
@@ -136,7 +145,9 @@ docker compose ps
|
||||
|
||||
Once the services are running, you can access the Open Archiver web interface by navigating to `http://localhost:3000` in your web browser.
|
||||
|
||||
You can log in with the `ADMIN_EMAIL` and `ADMIN_PASSWORD` you configured in your `.env` file.
|
||||
Upon first visit, you will be redirected to the `/setup` page where you can set up your admin account. Make sure you are the first person who accesses the instance.
|
||||
|
||||
If you are not redirected to the `/setup` page but instead see the login page, there might be something wrong with the database. Restart the service and try again.
|
||||
|
||||
## 5. Next Steps
|
||||
|
||||
@@ -210,9 +221,9 @@ If you are using local storage to store your emails, based on your `docker-compo
|
||||
|
||||
Run this command to see all the volumes on your system:
|
||||
|
||||
```bash
|
||||
docker volume ls
|
||||
```
|
||||
```bash
|
||||
docker volume ls
|
||||
```
|
||||
|
||||
2. **Identify the correct volume**:
|
||||
|
||||
@@ -222,28 +233,28 @@ Look through the list for a volume name that ends with `_archiver-data`. The par
|
||||
|
||||
Once you've identified the correct volume name, use it in the `inspect` command. For example:
|
||||
|
||||
```bash
|
||||
docker volume inspect <your_volume_name_here>
|
||||
```
|
||||
```bash
|
||||
docker volume inspect <your_volume_name_here>
|
||||
```
|
||||
|
||||
This will give you the correct `Mountpoint` path where your data is being stored. It will look something like this (the exact path will vary depending on your system):
|
||||
|
||||
```json
|
||||
{
|
||||
"CreatedAt": "2025-07-25T11:22:19Z",
|
||||
"Driver": "local",
|
||||
"Labels": {
|
||||
"com.docker.compose.config-hash": "---",
|
||||
"com.docker.compose.project": "---",
|
||||
"com.docker.compose.version": "2.38.2",
|
||||
"com.docker.compose.volume": "us8wwos0o4ok4go4gc8cog84_archiver-data"
|
||||
},
|
||||
"Mountpoint": "/var/lib/docker/volumes/us8wwos0o4ok4go4gc8cog84_archiver-data/_data",
|
||||
"Name": "us8wwos0o4ok4go4gc8cog84_archiver-data",
|
||||
"Options": null,
|
||||
"Scope": "local"
|
||||
}
|
||||
```
|
||||
```json
|
||||
{
|
||||
"CreatedAt": "2025-07-25T11:22:19Z",
|
||||
"Driver": "local",
|
||||
"Labels": {
|
||||
"com.docker.compose.config-hash": "---",
|
||||
"com.docker.compose.project": "---",
|
||||
"com.docker.compose.version": "2.38.2",
|
||||
"com.docker.compose.volume": "us8wwos0o4ok4go4gc8cog84_archiver-data"
|
||||
},
|
||||
"Mountpoint": "/var/lib/docker/volumes/us8wwos0o4ok4go4gc8cog84_archiver-data/_data",
|
||||
"Name": "us8wwos0o4ok4go4gc8cog84_archiver-data",
|
||||
"Options": null,
|
||||
"Scope": "local"
|
||||
}
|
||||
```
|
||||
|
||||
In this example, the data is located at `/var/lib/docker/volumes/us8wwos0o4ok4go4gc8cog84_archiver-data/_data`. You can then `cd` into that directory to see your files.
|
||||
|
||||
@@ -257,43 +268,71 @@ Here’s how you can do it:
|
||||
|
||||
Open the `docker-compose.yml` file and find the `open-archiver` service. You're going to change the `volumes` section.
|
||||
|
||||
**Change this:**
|
||||
**Change this:**
|
||||
|
||||
```yaml
|
||||
services:
|
||||
open-archiver:
|
||||
# ... other config
|
||||
volumes:
|
||||
- archiver-data:/var/data/open-archiver
|
||||
```
|
||||
```yaml
|
||||
services:
|
||||
open-archiver:
|
||||
# ... other config
|
||||
volumes:
|
||||
- archiver-data:/var/data/open-archiver
|
||||
```
|
||||
|
||||
**To this:**
|
||||
**To this:**
|
||||
|
||||
```yaml
|
||||
services:
|
||||
open-archiver:
|
||||
# ... other config
|
||||
volumes:
|
||||
- ./data/open-archiver:/var/data/open-archiver
|
||||
```
|
||||
```yaml
|
||||
services:
|
||||
open-archiver:
|
||||
# ... other config
|
||||
volumes:
|
||||
- ./data/open-archiver:/var/data/open-archiver
|
||||
```
|
||||
|
||||
You'll also want to remove the `archiver-data` volume definition at the bottom of the file, since it's no longer needed.
|
||||
|
||||
**Remove this whole block:**
|
||||
**Remove this whole block:**
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
# ... other volumes
|
||||
archiver-data:
|
||||
driver: local
|
||||
```
|
||||
```yaml
|
||||
volumes:
|
||||
# ... other volumes
|
||||
archiver-data:
|
||||
driver: local
|
||||
```
|
||||
|
||||
2. **Restart your containers**:
|
||||
|
||||
After you've saved the changes, run the following command in your terminal to apply them. The `--force-recreate` flag will ensure the container is recreated with the new volume settings.
|
||||
|
||||
```bash
|
||||
docker-compose up -d --force-recreate
|
||||
```
|
||||
```bash
|
||||
docker-compose up -d --force-recreate
|
||||
```
|
||||
|
||||
After this, any new data will be saved directly into the `./data/open-archiver` folder in your project directory.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### 403 Cross-Site POST Forbidden Error
|
||||
|
||||
If you are running the application behind a reverse proxy or have mapped the application to a different port (e.g., `3005:3000`), you may encounter a `403 Cross-site POST from submissions are forbidden` error when uploading files.
|
||||
|
||||
To resolve this, you must set the `ORIGIN` environment variable to the URL of your application. This ensures that the backend can verify the origin of requests and prevent cross-site request forgery (CSRF) attacks.
|
||||
|
||||
Add the following line to your `.env` file, replacing `<your_host>` and `<your_port>` with your specific values:
|
||||
|
||||
```bash
|
||||
ORIGIN=http://<your_host>:<your_port>
|
||||
```
|
||||
|
||||
For example, if your application is accessible at `http://localhost:3005`, you would set the variable as follows:
|
||||
|
||||
```bash
|
||||
ORIGIN=http://localhost:3005
|
||||
```
|
||||
|
||||
After adding the `ORIGIN` variable, restart your Docker containers for the changes to take effect:
|
||||
|
||||
```bash
|
||||
docker-compose up -d --force-recreate
|
||||
```
|
||||
|
||||
This will ensure that your file uploads are correctly authorized.
|
||||
|
||||
32
docs/user-guides/settings/system.md
Normal file
32
docs/user-guides/settings/system.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# System Settings
|
||||
|
||||
System settings allow administrators to configure the global look and theme of the application. These settings apply to all users.
|
||||
|
||||
## Configuration
|
||||
|
||||
### Language
|
||||
|
||||
This setting determines the default display language for the application UI. The selected language will be used for all interface elements, including menus, labels, and messages.
|
||||
|
||||
> **Important:** When the language is changed, the backend (API) language will only change after a restart of the server. The frontend will update immediately.
|
||||
|
||||
Supported languages:
|
||||
|
||||
- English
|
||||
- German
|
||||
- French
|
||||
- Estonian
|
||||
- Spanish
|
||||
- Italian
|
||||
- Portuguese
|
||||
- Dutch
|
||||
- Greek
|
||||
- Japanese
|
||||
|
||||
### Default Theme
|
||||
|
||||
This setting controls the default color theme for the application. Users can choose between light, dark, or system default. The system default theme will sync with the user's operating system theme.
|
||||
|
||||
### Support Email
|
||||
|
||||
This setting allows administrators to provide a public-facing email address for user support inquiries. This email address may be displayed on error pages or in other areas where users may need to contact support.
|
||||
@@ -0,0 +1,93 @@
|
||||
# Upgrading Meilisearch
|
||||
|
||||
Meilisearch, the search engine used by Open Archiver, requires a manual data migration process when upgrading to a new version. This is because Meilisearch databases are only compatible with the specific version that created them.
|
||||
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
### Step 1: Create a Dump
|
||||
|
||||
Before upgrading, you must create a dump of your existing Meilisearch data. You can do this by sending a POST request to the `/dumps` endpoint of the Meilisearch API.
|
||||
|
||||
1. **Find your Meilisearch container name**:
|
||||
|
||||
```bash
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
Look for the service name that corresponds to Meilisearch, usually `meilisearch`.
|
||||
|
||||
2. **Execute the dump command**:
|
||||
You will need your Meilisearch Admin API key, which can be found in your `.env` file as `MEILI_MASTER_KEY`.
|
||||
|
||||
```bash
|
||||
curl -X POST 'http://localhost:7700/dumps' \
|
||||
-H "Authorization: Bearer YOUR_MEILI_MASTER_KEY"
|
||||
```
|
||||
|
||||
This will start the dump creation process. The dump file will be created inside the `meili_data` volume used by the Meilisearch container.
|
||||
|
||||
3. **Monitor the dump status**:
|
||||
The dump creation request returns a `taskUid`. You can use this to check the status of the dump.
|
||||
|
||||
For more details on dump and import, see the [official Meilisearch documentation](https://www.meilisearch.com/docs/learn/update_and_migration/updating).
|
||||
|
||||
### Step 2: Upgrade Your Open Archiver Instance
|
||||
|
||||
Once the dump is successfully created, you can proceed with the standard Open Archiver upgrade process.
|
||||
|
||||
1. **Pull the latest changes and Docker images**:
|
||||
|
||||
```bash
|
||||
git pull
|
||||
docker compose pull
|
||||
```
|
||||
|
||||
2. **Stop the running services**:
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
|
||||
### Step 3: Import the Dump
|
||||
|
||||
Now, you need to restart the services while telling Meilisearch to import from your dump file.
|
||||
|
||||
1. **Modify `docker-compose.yml`**:
|
||||
You need to temporarily add the `--import-dump` flag to the Meilisearch service command. Find the `meilisearch` service in your `docker-compose.yml` and modify the `command` section.
|
||||
|
||||
You will need the name of your dump file. It will be a `.dump` file located in the directory mapped to `/meili_data` inside the container.
|
||||
|
||||
```yaml
|
||||
services:
|
||||
meilisearch:
|
||||
# ... other service config
|
||||
command:
|
||||
[
|
||||
'--master-key=${MEILI_MASTER_KEY}',
|
||||
'--env=production',
|
||||
'--import-dump=/meili_data/dumps/YOUR_DUMP_FILE.dump',
|
||||
]
|
||||
```
|
||||
|
||||
2. **Restart the services**:
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
Meilisearch will now start and import the data from the dump file. This may take some time depending on the size of your index.
|
||||
|
||||
### Step 4: Clean Up
|
||||
|
||||
Once the import is complete and you have verified that your search is working correctly, you should remove the `--import-dump` flag from your `docker-compose.yml` to prevent it from running on every startup.
|
||||
|
||||
1. **Remove the `--import-dump` line** from the `command` section of the `meilisearch` service in `docker-compose.yml`.
|
||||
2. **Restart the services** one last time:
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Your Meilisearch instance is now upgraded and running with your migrated data.
|
||||
|
||||
For more advanced scenarios or troubleshooting, please refer to the **[official Meilisearch migration guide](https://www.meilisearch.com/docs/learn/update_and_migration/updating)**.
|
||||
42
docs/user-guides/upgrade-and-migration/upgrade.md
Normal file
42
docs/user-guides/upgrade-and-migration/upgrade.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# Upgrading Your Instance
|
||||
|
||||
This guide provides instructions for upgrading your Open Archiver instance to the latest version.
|
||||
|
||||
## Checking for New Versions
|
||||
|
||||
Open Archiver automatically checks for new versions and will display a notification in the footer of the web interface when an update is available. You can find a list of all releases and their release notes on the [GitHub Releases](https://github.com/LogicLabs-OU/OpenArchiver/releases) page.
|
||||
|
||||
## Upgrading Your Instance
|
||||
|
||||
To upgrade your Open Archiver instance, follow these steps:
|
||||
|
||||
1. **Pull the latest changes from the repository**:
|
||||
|
||||
```bash
|
||||
git pull
|
||||
```
|
||||
|
||||
2. **Pull the latest Docker images**:
|
||||
|
||||
```bash
|
||||
docker compose pull
|
||||
```
|
||||
|
||||
3. **Restart the services with the new images**:
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
This will restart your Open Archiver instance with the latest version of the application.
|
||||
|
||||
## Migrating Data
|
||||
|
||||
When you upgrade to a new version, database migrations are applied automatically when the application starts up. This ensures that your database schema is always up-to-date with the latest version of the application.
|
||||
|
||||
No manual intervention is required for database migrations.
|
||||
|
||||
## Upgrading Meilisearch
|
||||
|
||||
When an Open Archiver update includes a major version change for Meilisearch, you will need to manually migrate your search data. This process is not covered by the standard upgrade commands.
|
||||
|
||||
For detailed instructions, please see the [Meilisearch Upgrade Guide](./meilisearch-upgrade.md).
|
||||
77
open-archiver.yml
Normal file
77
open-archiver.yml
Normal file
@@ -0,0 +1,77 @@
|
||||
# documentation: https://openarchiver.com
|
||||
# slogan: A self-hosted, open-source email archiving solution with full-text search capability.
|
||||
# tags: email archiving,email,compliance,search
|
||||
# logo: svgs/openarchiver.svg
|
||||
# port: 3000
|
||||
|
||||
services:
|
||||
open-archiver:
|
||||
image: logiclabshq/open-archiver:latest
|
||||
environment:
|
||||
- SERVICE_URL_3000
|
||||
- SERVICE_URL=${SERVICE_URL_3000}
|
||||
- PORT_BACKEND=${PORT_BACKEND:-4000}
|
||||
- PORT_FRONTEND=${PORT_FRONTEND:-3000}
|
||||
- NODE_ENV=${NODE_ENV:-production}
|
||||
- SYNC_FREQUENCY=${SYNC_FREQUENCY:-* * * * *}
|
||||
- POSTGRES_DB=${POSTGRES_DB:-open_archive}
|
||||
- POSTGRES_USER=${POSTGRES_USER:-admin}
|
||||
- POSTGRES_PASSWORD=${SERVICE_PASSWORD_POSTGRES}
|
||||
- DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
|
||||
- MEILI_MASTER_KEY=${SERVICE_PASSWORD_MEILISEARCH}
|
||||
- MEILI_HOST=http://meilisearch:7700
|
||||
- REDIS_HOST=valkey
|
||||
- REDIS_PORT=6379
|
||||
- REDIS_PASSWORD=${SERVICE_PASSWORD_VALKEY}
|
||||
- REDIS_TLS_ENABLED=false
|
||||
- STORAGE_TYPE=${STORAGE_TYPE:-local}
|
||||
- STORAGE_LOCAL_ROOT_PATH=${STORAGE_LOCAL_ROOT_PATH:-/var/data/open-archiver}
|
||||
- BODY_SIZE_LIMIT=${BODY_SIZE_LIMIT:-100M}
|
||||
- STORAGE_S3_ENDPOINT=${STORAGE_S3_ENDPOINT}
|
||||
- STORAGE_S3_BUCKET=${STORAGE_S3_BUCKET}
|
||||
- STORAGE_S3_ACCESS_KEY_ID=${STORAGE_S3_ACCESS_KEY_ID}
|
||||
- STORAGE_S3_SECRET_ACCESS_KEY=${STORAGE_S3_SECRET_ACCESS_KEY}
|
||||
- STORAGE_S3_REGION=${STORAGE_S3_REGION}
|
||||
- STORAGE_S3_FORCE_PATH_STYLE=${STORAGE_S3_FORCE_PATH_STYLE:-false}
|
||||
- JWT_SECRET=${SERVICE_BASE64_128_JWT}
|
||||
- JWT_EXPIRES_IN=${JWT_EXPIRES_IN:-7d}
|
||||
- ENCRYPTION_KEY=${SERVICE_BASE64_64_ENCRYPTIONKEY}
|
||||
- RATE_LIMIT_WINDOW_MS=${RATE_LIMIT_WINDOW_MS:-60000}
|
||||
- RATE_LIMIT_MAX_REQUESTS=${RATE_LIMIT_MAX_REQUESTS:-100}
|
||||
volumes:
|
||||
- archiver-data:/var/data/open-archiver
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
valkey:
|
||||
condition: service_started
|
||||
meilisearch:
|
||||
condition: service_started
|
||||
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
environment:
|
||||
- POSTGRES_DB=${POSTGRES_DB}
|
||||
- POSTGRES_USER=${POSTGRES_USER}
|
||||
- POSTGRES_PASSWORD=${SERVICE_PASSWORD_POSTGRES}
|
||||
- LC_ALL=C
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}']
|
||||
interval: 10s
|
||||
timeout: 20s
|
||||
retries: 10
|
||||
|
||||
valkey:
|
||||
image: valkey/valkey:8-alpine
|
||||
command: valkey-server --requirepass ${SERVICE_PASSWORD_VALKEY}
|
||||
volumes:
|
||||
- valkeydata:/data
|
||||
|
||||
meilisearch:
|
||||
image: getmeili/meilisearch:v1.15
|
||||
environment:
|
||||
- MEILI_MASTER_KEY=${SERVICE_PASSWORD_MEILISEARCH}
|
||||
volumes:
|
||||
- meilidata:/meili_data
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"name": "open-archiver",
|
||||
"version": "0.3.3",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "dotenv -- pnpm --filter \"./packages/*\" --parallel dev",
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"dev": "ts-node-dev --respawn --transpile-only src/index.ts ",
|
||||
"build": "tsc",
|
||||
"build": "tsc && pnpm copy-assets",
|
||||
"copy-assets": "cp -r src/locales dist/locales",
|
||||
"start": "node dist/index.js",
|
||||
"start:ingestion-worker": "node dist/workers/ingestion.worker.js",
|
||||
"start:indexing-worker": "node dist/workers/indexing.worker.js",
|
||||
@@ -40,6 +41,9 @@
|
||||
"express-validator": "^7.2.1",
|
||||
"google-auth-library": "^10.1.0",
|
||||
"googleapis": "^152.0.0",
|
||||
"i18next": "^25.4.2",
|
||||
"i18next-fs-backend": "^2.6.0",
|
||||
"i18next-http-middleware": "^3.8.0",
|
||||
"imapflow": "^1.0.191",
|
||||
"jose": "^6.0.11",
|
||||
"mailparser": "^3.7.4",
|
||||
@@ -55,8 +59,9 @@
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"sqlite3": "^5.1.7",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"xlsx": "^0.18.5",
|
||||
"yauzl": "^3.2.0"
|
||||
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
|
||||
"yauzl": "^3.2.0",
|
||||
"zod": "^4.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@bull-board/api": "^6.11.0",
|
||||
@@ -69,7 +74,6 @@
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^24.0.12",
|
||||
"@types/yauzl": "^2.10.3",
|
||||
"bull-board": "^2.1.3",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
|
||||
66
packages/backend/src/api/controllers/api-key.controller.ts
Normal file
66
packages/backend/src/api/controllers/api-key.controller.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { ApiKeyService } from '../../services/ApiKeyService';
|
||||
import { z } from 'zod';
|
||||
import { config } from '../../config';
|
||||
|
||||
const generateApiKeySchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1, 'API kay name must be more than 1 characters')
|
||||
.max(255, 'API kay name must not be more than 255 characters'),
|
||||
expiresInDays: z
|
||||
.number()
|
||||
.int()
|
||||
.positive('Only positive number is allowed')
|
||||
.max(730, 'The API key must expire within 2 years / 730 days.'),
|
||||
});
|
||||
|
||||
export class ApiKeyController {
|
||||
public async generateApiKey(req: Request, res: Response) {
|
||||
if (config.app.isDemo) {
|
||||
return res.status(403).json({ message: req.t('errors.demoMode') });
|
||||
}
|
||||
try {
|
||||
const { name, expiresInDays } = generateApiKeySchema.parse(req.body);
|
||||
if (!req.user || !req.user.sub) {
|
||||
return res.status(401).json({ message: 'Unauthorized' });
|
||||
}
|
||||
const userId = req.user.sub;
|
||||
|
||||
const key = await ApiKeyService.generate(userId, name, expiresInDays);
|
||||
|
||||
res.status(201).json({ key });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ message: req.t('api.requestBodyInvalid'), errors: error.message });
|
||||
}
|
||||
res.status(500).json({ message: req.t('errors.internalServerError') });
|
||||
}
|
||||
}
|
||||
|
||||
public async getApiKeys(req: Request, res: Response) {
|
||||
if (!req.user || !req.user.sub) {
|
||||
return res.status(401).json({ message: 'Unauthorized' });
|
||||
}
|
||||
const userId = req.user.sub;
|
||||
const keys = await ApiKeyService.getKeys(userId);
|
||||
|
||||
res.status(200).json(keys);
|
||||
}
|
||||
|
||||
public async deleteApiKey(req: Request, res: Response) {
|
||||
if (config.app.isDemo) {
|
||||
return res.status(403).json({ message: req.t('errors.demoMode') });
|
||||
}
|
||||
const { id } = req.params;
|
||||
if (!req.user || !req.user.sub) {
|
||||
return res.status(401).json({ message: 'Unauthorized' });
|
||||
}
|
||||
const userId = req.user.sub;
|
||||
await ApiKeyService.deleteKey(id, userId);
|
||||
|
||||
res.status(204).send({ message: req.t('apiKeys.deleteSuccess') });
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ export class ArchivedEmailController {
|
||||
const userId = req.user?.sub;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(401).json({ message: 'Unauthorized' });
|
||||
return res.status(401).json({ message: req.t('errors.unauthorized') });
|
||||
}
|
||||
|
||||
const result = await ArchivedEmailService.getArchivedEmails(
|
||||
@@ -23,7 +23,7 @@ export class ArchivedEmailController {
|
||||
return res.status(200).json(result);
|
||||
} catch (error) {
|
||||
console.error('Get archived emails error:', error);
|
||||
return res.status(500).json({ message: 'An internal server error occurred' });
|
||||
return res.status(500).json({ message: req.t('errors.internalServerError') });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -33,23 +33,23 @@ export class ArchivedEmailController {
|
||||
const userId = req.user?.sub;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(401).json({ message: 'Unauthorized' });
|
||||
return res.status(401).json({ message: req.t('errors.unauthorized') });
|
||||
}
|
||||
|
||||
const email = await ArchivedEmailService.getArchivedEmailById(id, userId);
|
||||
if (!email) {
|
||||
return res.status(404).json({ message: 'Archived email not found' });
|
||||
return res.status(404).json({ message: req.t('archivedEmail.notFound') });
|
||||
}
|
||||
return res.status(200).json(email);
|
||||
} catch (error) {
|
||||
console.error(`Get archived email by id ${req.params.id} error:`, error);
|
||||
return res.status(500).json({ message: 'An internal server error occurred' });
|
||||
return res.status(500).json({ message: req.t('errors.internalServerError') });
|
||||
}
|
||||
};
|
||||
|
||||
public deleteArchivedEmail = async (req: Request, res: Response): Promise<Response> => {
|
||||
if (config.app.isDemo) {
|
||||
return res.status(403).json({ message: 'This operation is not allowed in demo mode.' });
|
||||
return res.status(403).json({ message: req.t('errors.demoMode') });
|
||||
}
|
||||
try {
|
||||
const { id } = req.params;
|
||||
@@ -59,11 +59,11 @@ export class ArchivedEmailController {
|
||||
console.error(`Delete archived email ${req.params.id} error:`, error);
|
||||
if (error instanceof Error) {
|
||||
if (error.message === 'Archived email not found') {
|
||||
return res.status(404).json({ message: error.message });
|
||||
return res.status(404).json({ message: req.t('archivedEmail.notFound') });
|
||||
}
|
||||
return res.status(500).json({ message: error.message });
|
||||
}
|
||||
return res.status(500).json({ message: 'An internal server error occurred' });
|
||||
return res.status(500).json({ message: req.t('errors.internalServerError') });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ export class AuthController {
|
||||
const { email, password, first_name, last_name } = req.body;
|
||||
|
||||
if (!email || !password || !first_name || !last_name) {
|
||||
return res.status(400).json({ message: 'Email, password, and name are required' });
|
||||
return res.status(400).json({ message: req.t('auth.setup.allFieldsRequired') });
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -37,7 +37,7 @@ export class AuthController {
|
||||
const userCount = Number(userCountResult[0].count);
|
||||
|
||||
if (userCount > 0) {
|
||||
return res.status(403).json({ message: 'Setup has already been completed.' });
|
||||
return res.status(403).json({ message: req.t('auth.setup.alreadyCompleted') });
|
||||
}
|
||||
|
||||
const newUser = await this.#userService.createAdminUser(
|
||||
@@ -48,7 +48,7 @@ export class AuthController {
|
||||
return res.status(201).json(result);
|
||||
} catch (error) {
|
||||
console.error('Setup error:', error);
|
||||
return res.status(500).json({ message: 'An internal server error occurred' });
|
||||
return res.status(500).json({ message: req.t('errors.internalServerError') });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -56,20 +56,20 @@ export class AuthController {
|
||||
const { email, password } = req.body;
|
||||
|
||||
if (!email || !password) {
|
||||
return res.status(400).json({ message: 'Email and password are required' });
|
||||
return res.status(400).json({ message: req.t('auth.login.emailAndPasswordRequired') });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.#authService.login(email, password);
|
||||
|
||||
if (!result) {
|
||||
return res.status(401).json({ message: 'Invalid credentials' });
|
||||
return res.status(401).json({ message: req.t('auth.login.invalidCredentials') });
|
||||
}
|
||||
|
||||
return res.status(200).json(result);
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
return res.status(500).json({ message: 'An internal server error occurred' });
|
||||
return res.status(500).json({ message: req.t('errors.internalServerError') });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -121,10 +121,10 @@ export class AuthController {
|
||||
);
|
||||
return res.status(200).json({ needsSetup: false });
|
||||
}
|
||||
return res.status(200).json({ needsSetupUser });
|
||||
return res.status(200).json({ needsSetup: needsSetupUser });
|
||||
} catch (error) {
|
||||
console.error('Status check error:', error);
|
||||
return res.status(500).json({ message: 'An internal server error occurred' });
|
||||
return res.status(500).json({ message: req.t('errors.internalServerError') });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ export class IamController {
|
||||
}
|
||||
res.status(200).json(roles);
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: 'Failed to get roles.' });
|
||||
res.status(500).json({ message: req.t('iam.failedToGetRoles') });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -34,21 +34,21 @@ export class IamController {
|
||||
if (role) {
|
||||
res.status(200).json(role);
|
||||
} else {
|
||||
res.status(404).json({ message: 'Role not found.' });
|
||||
res.status(404).json({ message: req.t('iam.roleNotFound') });
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: 'Failed to get role.' });
|
||||
res.status(500).json({ message: req.t('iam.failedToGetRole') });
|
||||
}
|
||||
};
|
||||
|
||||
public createRole = async (req: Request, res: Response) => {
|
||||
if (config.app.isDemo) {
|
||||
return res.status(403).json({ message: 'This operation is not allowed in demo mode.' });
|
||||
return res.status(403).json({ message: req.t('errors.demoMode') });
|
||||
}
|
||||
const { name, policies } = req.body;
|
||||
|
||||
if (!name || !policies) {
|
||||
res.status(400).json({ message: 'Missing required fields: name and policy.' });
|
||||
res.status(400).json({ message: req.t('iam.missingRoleFields') });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ export class IamController {
|
||||
for (const statement of policies) {
|
||||
const { valid, reason } = PolicyValidator.isValid(statement as CaslPolicy);
|
||||
if (!valid) {
|
||||
res.status(400).json({ message: `Invalid policy statement: ${reason}` });
|
||||
res.status(400).json({ message: `${req.t('iam.invalidPolicy')} ${reason}` });
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -64,13 +64,13 @@ export class IamController {
|
||||
res.status(201).json(role);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
res.status(500).json({ message: 'Failed to create role.' });
|
||||
res.status(500).json({ message: req.t('iam.failedToCreateRole') });
|
||||
}
|
||||
};
|
||||
|
||||
public deleteRole = async (req: Request, res: Response) => {
|
||||
if (config.app.isDemo) {
|
||||
return res.status(403).json({ message: 'This operation is not allowed in demo mode.' });
|
||||
return res.status(403).json({ message: req.t('errors.demoMode') });
|
||||
}
|
||||
const { id } = req.params;
|
||||
|
||||
@@ -78,19 +78,19 @@ export class IamController {
|
||||
await this.#iamService.deleteRole(id);
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: 'Failed to delete role.' });
|
||||
res.status(500).json({ message: req.t('iam.failedToDeleteRole') });
|
||||
}
|
||||
};
|
||||
|
||||
public updateRole = async (req: Request, res: Response) => {
|
||||
if (config.app.isDemo) {
|
||||
return res.status(403).json({ message: 'This operation is not allowed in demo mode.' });
|
||||
return res.status(403).json({ message: req.t('errors.demoMode') });
|
||||
}
|
||||
const { id } = req.params;
|
||||
const { name, policies } = req.body;
|
||||
|
||||
if (!name && !policies) {
|
||||
res.status(400).json({ message: 'Missing fields to update: name or policies.' });
|
||||
res.status(400).json({ message: req.t('iam.missingUpdateFields') });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ export class IamController {
|
||||
for (const statement of policies) {
|
||||
const { valid, reason } = PolicyValidator.isValid(statement as CaslPolicy);
|
||||
if (!valid) {
|
||||
res.status(400).json({ message: `Invalid policy statement: ${reason}` });
|
||||
res.status(400).json({ message: `${req.t('iam.invalidPolicy')} ${reason}` });
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -108,7 +108,7 @@ export class IamController {
|
||||
const role = await this.#iamService.updateRole(id, { name, policies });
|
||||
res.status(200).json(role);
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: 'Failed to update role.' });
|
||||
res.status(500).json({ message: req.t('iam.failedToUpdateRole') });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -23,13 +23,13 @@ export class IngestionController {
|
||||
|
||||
public create = async (req: Request, res: Response): Promise<Response> => {
|
||||
if (config.app.isDemo) {
|
||||
return res.status(403).json({ message: 'This operation is not allowed in demo mode.' });
|
||||
return res.status(403).json({ message: req.t('errors.demoMode') });
|
||||
}
|
||||
try {
|
||||
const dto: CreateIngestionSourceDto = req.body;
|
||||
const userId = req.user?.sub;
|
||||
if (!userId) {
|
||||
return res.status(401).json({ message: 'Unauthorized' });
|
||||
return res.status(401).json({ message: req.t('errors.unauthorized') });
|
||||
}
|
||||
const newSource = await IngestionService.create(dto, userId);
|
||||
const safeSource = this.toSafeIngestionSource(newSource);
|
||||
@@ -38,8 +38,7 @@ export class IngestionController {
|
||||
logger.error({ err: error }, 'Create ingestion source error');
|
||||
// Return a 400 Bad Request for connection errors
|
||||
return res.status(400).json({
|
||||
message:
|
||||
error.message || 'Failed to create ingestion source due to a connection error.',
|
||||
message: error.message || req.t('ingestion.failedToCreate'),
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -48,14 +47,14 @@ export class IngestionController {
|
||||
try {
|
||||
const userId = req.user?.sub;
|
||||
if (!userId) {
|
||||
return res.status(401).json({ message: 'Unauthorized' });
|
||||
return res.status(401).json({ message: req.t('errors.unauthorized') });
|
||||
}
|
||||
const sources = await IngestionService.findAll(userId);
|
||||
const safeSources = sources.map(this.toSafeIngestionSource);
|
||||
return res.status(200).json(safeSources);
|
||||
} catch (error) {
|
||||
console.error('Find all ingestion sources error:', error);
|
||||
return res.status(500).json({ message: 'An internal server error occurred' });
|
||||
return res.status(500).json({ message: req.t('errors.internalServerError') });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -68,15 +67,15 @@ export class IngestionController {
|
||||
} catch (error) {
|
||||
console.error(`Find ingestion source by id ${req.params.id} error:`, error);
|
||||
if (error instanceof Error && error.message === 'Ingestion source not found') {
|
||||
return res.status(404).json({ message: error.message });
|
||||
return res.status(404).json({ message: req.t('ingestion.notFound') });
|
||||
}
|
||||
return res.status(500).json({ message: 'An internal server error occurred' });
|
||||
return res.status(500).json({ message: req.t('errors.internalServerError') });
|
||||
}
|
||||
};
|
||||
|
||||
public update = async (req: Request, res: Response): Promise<Response> => {
|
||||
if (config.app.isDemo) {
|
||||
return res.status(403).json({ message: 'This operation is not allowed in demo mode.' });
|
||||
return res.status(403).json({ message: req.t('errors.demoMode') });
|
||||
}
|
||||
try {
|
||||
const { id } = req.params;
|
||||
@@ -87,15 +86,15 @@ export class IngestionController {
|
||||
} catch (error) {
|
||||
console.error(`Update ingestion source ${req.params.id} error:`, error);
|
||||
if (error instanceof Error && error.message === 'Ingestion source not found') {
|
||||
return res.status(404).json({ message: error.message });
|
||||
return res.status(404).json({ message: req.t('ingestion.notFound') });
|
||||
}
|
||||
return res.status(500).json({ message: 'An internal server error occurred' });
|
||||
return res.status(500).json({ message: req.t('errors.internalServerError') });
|
||||
}
|
||||
};
|
||||
|
||||
public delete = async (req: Request, res: Response): Promise<Response> => {
|
||||
if (config.app.isDemo) {
|
||||
return res.status(403).json({ message: 'This operation is not allowed in demo mode.' });
|
||||
return res.status(403).json({ message: req.t('errors.demoMode') });
|
||||
}
|
||||
try {
|
||||
const { id } = req.params;
|
||||
@@ -104,32 +103,32 @@ export class IngestionController {
|
||||
} catch (error) {
|
||||
console.error(`Delete ingestion source ${req.params.id} error:`, error);
|
||||
if (error instanceof Error && error.message === 'Ingestion source not found') {
|
||||
return res.status(404).json({ message: error.message });
|
||||
return res.status(404).json({ message: req.t('ingestion.notFound') });
|
||||
}
|
||||
return res.status(500).json({ message: 'An internal server error occurred' });
|
||||
return res.status(500).json({ message: req.t('errors.internalServerError') });
|
||||
}
|
||||
};
|
||||
|
||||
public triggerInitialImport = async (req: Request, res: Response): Promise<Response> => {
|
||||
if (config.app.isDemo) {
|
||||
return res.status(403).json({ message: 'This operation is not allowed in demo mode.' });
|
||||
return res.status(403).json({ message: req.t('errors.demoMode') });
|
||||
}
|
||||
try {
|
||||
const { id } = req.params;
|
||||
await IngestionService.triggerInitialImport(id);
|
||||
return res.status(202).json({ message: 'Initial import triggered successfully.' });
|
||||
return res.status(202).json({ message: req.t('ingestion.initialImportTriggered') });
|
||||
} catch (error) {
|
||||
console.error(`Trigger initial import for ${req.params.id} error:`, error);
|
||||
if (error instanceof Error && error.message === 'Ingestion source not found') {
|
||||
return res.status(404).json({ message: error.message });
|
||||
return res.status(404).json({ message: req.t('ingestion.notFound') });
|
||||
}
|
||||
return res.status(500).json({ message: 'An internal server error occurred' });
|
||||
return res.status(500).json({ message: req.t('errors.internalServerError') });
|
||||
}
|
||||
};
|
||||
|
||||
public pause = async (req: Request, res: Response): Promise<Response> => {
|
||||
if (config.app.isDemo) {
|
||||
return res.status(403).json({ message: 'This operation is not allowed in demo mode.' });
|
||||
return res.status(403).json({ message: req.t('errors.demoMode') });
|
||||
}
|
||||
try {
|
||||
const { id } = req.params;
|
||||
@@ -139,26 +138,26 @@ export class IngestionController {
|
||||
} catch (error) {
|
||||
console.error(`Pause ingestion source ${req.params.id} error:`, error);
|
||||
if (error instanceof Error && error.message === 'Ingestion source not found') {
|
||||
return res.status(404).json({ message: error.message });
|
||||
return res.status(404).json({ message: req.t('ingestion.notFound') });
|
||||
}
|
||||
return res.status(500).json({ message: 'An internal server error occurred' });
|
||||
return res.status(500).json({ message: req.t('errors.internalServerError') });
|
||||
}
|
||||
};
|
||||
|
||||
public triggerForceSync = async (req: Request, res: Response): Promise<Response> => {
|
||||
if (config.app.isDemo) {
|
||||
return res.status(403).json({ message: 'This operation is not allowed in demo mode.' });
|
||||
return res.status(403).json({ message: req.t('errors.demoMode') });
|
||||
}
|
||||
try {
|
||||
const { id } = req.params;
|
||||
await IngestionService.triggerForceSync(id);
|
||||
return res.status(202).json({ message: 'Force sync triggered successfully.' });
|
||||
return res.status(202).json({ message: req.t('ingestion.forceSyncTriggered') });
|
||||
} catch (error) {
|
||||
console.error(`Trigger force sync for ${req.params.id} error:`, error);
|
||||
if (error instanceof Error && error.message === 'Ingestion source not found') {
|
||||
return res.status(404).json({ message: error.message });
|
||||
return res.status(404).json({ message: req.t('ingestion.notFound') });
|
||||
}
|
||||
return res.status(500).json({ message: 'An internal server error occurred' });
|
||||
return res.status(500).json({ message: req.t('errors.internalServerError') });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -15,12 +15,12 @@ export class SearchController {
|
||||
const userId = req.user?.sub;
|
||||
|
||||
if (!userId) {
|
||||
res.status(401).json({ message: 'Unauthorized' });
|
||||
res.status(401).json({ message: req.t('errors.unauthorized') });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!keywords) {
|
||||
res.status(400).json({ message: 'Keywords are required' });
|
||||
res.status(400).json({ message: req.t('search.keywordsRequired') });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ export class SearchController {
|
||||
|
||||
res.status(200).json(results);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'An unknown error occurred';
|
||||
const message = error instanceof Error ? error.message : req.t('errors.unknown');
|
||||
res.status(500).json({ message });
|
||||
}
|
||||
};
|
||||
|
||||
29
packages/backend/src/api/controllers/settings.controller.ts
Normal file
29
packages/backend/src/api/controllers/settings.controller.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { SettingsService } from '../../services/SettingsService';
|
||||
import { config } from '../../config';
|
||||
|
||||
const settingsService = new SettingsService();
|
||||
|
||||
export const getSystemSettings = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const settings = await settingsService.getSystemSettings();
|
||||
res.status(200).json(settings);
|
||||
} catch (error) {
|
||||
// A more specific error could be logged here
|
||||
res.status(500).json({ message: req.t('settings.failedToRetrieve') });
|
||||
}
|
||||
};
|
||||
|
||||
export const updateSystemSettings = async (req: Request, res: Response) => {
|
||||
try {
|
||||
// Basic validation can be performed here if necessary
|
||||
if (config.app.isDemo) {
|
||||
return res.status(403).json({ message: req.t('errors.demoMode') });
|
||||
}
|
||||
const updatedSettings = await settingsService.updateSystemSettings(req.body);
|
||||
res.status(200).json(updatedSettings);
|
||||
} catch (error) {
|
||||
// A more specific error could be logged here
|
||||
res.status(500).json({ message: req.t('settings.failedToUpdate') });
|
||||
}
|
||||
};
|
||||
@@ -10,7 +10,7 @@ export class StorageController {
|
||||
const unsafePath = req.query.path as string;
|
||||
|
||||
if (!unsafePath) {
|
||||
res.status(400).send('File path is required');
|
||||
res.status(400).send(req.t('storage.filePathRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ export class StorageController {
|
||||
const fullPath = path.join(basePath, normalizedPath);
|
||||
|
||||
if (!fullPath.startsWith(basePath)) {
|
||||
res.status(400).send('Invalid file path');
|
||||
res.status(400).send(req.t('storage.invalidFilePath'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ export class StorageController {
|
||||
try {
|
||||
const fileExists = await this.storageService.exists(safePath);
|
||||
if (!fileExists) {
|
||||
res.status(404).send('File not found');
|
||||
res.status(404).send(req.t('storage.fileNotFound'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ export class StorageController {
|
||||
fileStream.pipe(res);
|
||||
} catch (error) {
|
||||
console.error('Error downloading file:', error);
|
||||
res.status(500).send('Error downloading file');
|
||||
res.status(500).send(req.t('storage.downloadError'));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { config } from '../../config/index';
|
||||
export const uploadFile = async (req: Request, res: Response) => {
|
||||
const storage = new StorageService();
|
||||
const bb = busboy({ headers: req.headers });
|
||||
const uploads: Promise<void>[] = [];
|
||||
let filePath = '';
|
||||
let originalFilename = '';
|
||||
|
||||
@@ -14,10 +15,11 @@ export const uploadFile = async (req: Request, res: Response) => {
|
||||
originalFilename = filename.filename;
|
||||
const uuid = randomUUID();
|
||||
filePath = `${config.storage.openArchiverFolderName}/tmp/${uuid}-${originalFilename}`;
|
||||
storage.put(filePath, file);
|
||||
uploads.push(storage.put(filePath, file));
|
||||
});
|
||||
|
||||
bb.on('finish', () => {
|
||||
bb.on('finish', async () => {
|
||||
await Promise.all(uploads);
|
||||
res.json({ filePath });
|
||||
});
|
||||
|
||||
|
||||
@@ -15,14 +15,14 @@ export const getUsers = async (req: Request, res: Response) => {
|
||||
export const getUser = async (req: Request, res: Response) => {
|
||||
const user = await userService.findById(req.params.id);
|
||||
if (!user) {
|
||||
return res.status(404).json({ message: 'User not found' });
|
||||
return res.status(404).json({ message: req.t('user.notFound') });
|
||||
}
|
||||
res.json(user);
|
||||
};
|
||||
|
||||
export const createUser = async (req: Request, res: Response) => {
|
||||
if (config.app.isDemo) {
|
||||
return res.status(403).json({ message: 'This operation is not allowed in demo mode.' });
|
||||
return res.status(403).json({ message: req.t('errors.demoMode') });
|
||||
}
|
||||
const { email, first_name, last_name, password, roleId } = req.body;
|
||||
|
||||
@@ -35,7 +35,7 @@ export const createUser = async (req: Request, res: Response) => {
|
||||
|
||||
export const updateUser = async (req: Request, res: Response) => {
|
||||
if (config.app.isDemo) {
|
||||
return res.status(403).json({ message: 'This operation is not allowed in demo mode.' });
|
||||
return res.status(403).json({ message: req.t('errors.demoMode') });
|
||||
}
|
||||
const { email, first_name, last_name, roleId } = req.body;
|
||||
const updatedUser = await userService.updateUser(
|
||||
@@ -44,21 +44,21 @@ export const updateUser = async (req: Request, res: Response) => {
|
||||
roleId
|
||||
);
|
||||
if (!updatedUser) {
|
||||
return res.status(404).json({ message: 'User not found' });
|
||||
return res.status(404).json({ message: req.t('user.notFound') });
|
||||
}
|
||||
res.json(updatedUser);
|
||||
};
|
||||
|
||||
export const deleteUser = async (req: Request, res: Response) => {
|
||||
if (config.app.isDemo) {
|
||||
return res.status(403).json({ message: 'This operation is not allowed in demo mode.' });
|
||||
return res.status(403).json({ message: req.t('errors.demoMode') });
|
||||
}
|
||||
const userCountResult = await db.select({ count: sql<number>`count(*)` }).from(schema.users);
|
||||
console.log('iusercount,', userCountResult[0].count);
|
||||
|
||||
const isOnlyUser = Number(userCountResult[0].count) === 1;
|
||||
if (isOnlyUser) {
|
||||
return res.status(400).json({
|
||||
message: 'You are trying to delete the only user in the database, this is not allowed.',
|
||||
message: req.t('user.cannotDeleteOnlyUser'),
|
||||
});
|
||||
}
|
||||
await userService.deleteUser(req.params.id);
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import { config } from '../../config';
|
||||
|
||||
// Rate limiter to prevent brute-force attacks on the login endpoint
|
||||
export const loginRateLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 10, // Limit each IP to 10 login requests per windowMs
|
||||
message: 'Too many login attempts from this IP, please try again after 15 minutes',
|
||||
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
|
||||
legacyHeaders: false, // Disable the `X-RateLimit-*` headers
|
||||
const windowInMinutes = Math.ceil(config.api.rateLimit.windowMs / 60000);
|
||||
|
||||
export const rateLimiter = rateLimit({
|
||||
windowMs: config.api.rateLimit.windowMs,
|
||||
max: config.api.rateLimit.max,
|
||||
message: {
|
||||
status: 429,
|
||||
message: `Too many requests from this IP, please try again after ${windowInMinutes} minutes`,
|
||||
},
|
||||
statusCode: 429,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
});
|
||||
|
||||
@@ -2,6 +2,9 @@ import type { Request, Response, NextFunction } from 'express';
|
||||
import type { AuthService } from '../../services/AuthService';
|
||||
import type { AuthTokenPayload } from '@open-archiver/types';
|
||||
import 'dotenv/config';
|
||||
import { ApiKeyService } from '../../services/ApiKeyService';
|
||||
import { UserService } from '../../services/UserService';
|
||||
|
||||
// By using module augmentation, we can add our custom 'user' property
|
||||
// to the Express Request interface in a type-safe way.
|
||||
declare global {
|
||||
@@ -15,16 +18,30 @@ declare global {
|
||||
export const requireAuth = (authService: AuthService) => {
|
||||
return async (req: Request, res: Response, next: NextFunction) => {
|
||||
const authHeader = req.headers.authorization;
|
||||
const apiKeyHeader = req.headers['x-api-key'];
|
||||
|
||||
if (apiKeyHeader) {
|
||||
const userId = await ApiKeyService.validateKey(apiKeyHeader as string);
|
||||
if (!userId) {
|
||||
return res.status(401).json({ message: 'Unauthorized: Invalid API key' });
|
||||
}
|
||||
const user = await new UserService().findById(userId);
|
||||
if (!user) {
|
||||
return res.status(401).json({ message: 'Unauthorized: Invalid user' });
|
||||
}
|
||||
req.user = {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
roles: user.role ? [user.role.name] : [],
|
||||
};
|
||||
return next();
|
||||
}
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ message: 'Unauthorized: No token provided' });
|
||||
}
|
||||
const token = authHeader.split(' ')[1];
|
||||
try {
|
||||
// use a SUPER_API_KEY for all authentications. add process.env.SUPER_API_KEY conditional check in case user didn't set a SUPER_API_KEY.
|
||||
if (process.env.SUPER_API_KEY && token === process.env.SUPER_API_KEY) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
const payload = await authService.verifyToken(token);
|
||||
if (!payload) {
|
||||
return res.status(401).json({ message: 'Unauthorized: Invalid token' });
|
||||
|
||||
@@ -25,9 +25,11 @@ export const requirePermission = (
|
||||
);
|
||||
|
||||
if (!hasPermission) {
|
||||
const message = rejectMessage
|
||||
? req.t(rejectMessage)
|
||||
: req.t('errors.noPermissionToAction');
|
||||
return res.status(403).json({
|
||||
message:
|
||||
rejectMessage || `You don't have the permission to perform the current action.`,
|
||||
message,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
15
packages/backend/src/api/routes/api-key.routes.ts
Normal file
15
packages/backend/src/api/routes/api-key.routes.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Router } from 'express';
|
||||
import { ApiKeyController } from '../controllers/api-key.controller';
|
||||
import { requireAuth } from '../middleware/requireAuth';
|
||||
import { AuthService } from '../../services/AuthService';
|
||||
|
||||
export const apiKeyRoutes = (authService: AuthService) => {
|
||||
const router = Router();
|
||||
const controller = new ApiKeyController();
|
||||
|
||||
router.post('/', requireAuth(authService), controller.generateApiKey);
|
||||
router.get('/', requireAuth(authService), controller.getApiKeys);
|
||||
router.delete('/:id', requireAuth(authService), controller.deleteApiKey);
|
||||
|
||||
return router;
|
||||
};
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Router } from 'express';
|
||||
import { loginRateLimiter } from '../middleware/rateLimiter';
|
||||
import type { AuthController } from '../controllers/auth.controller';
|
||||
|
||||
export const createAuthRouter = (authController: AuthController): Router => {
|
||||
@@ -10,14 +9,14 @@ export const createAuthRouter = (authController: AuthController): Router => {
|
||||
* @description Creates the initial administrator user.
|
||||
* @access Public
|
||||
*/
|
||||
router.post('/setup', loginRateLimiter, authController.setup);
|
||||
router.post('/setup', authController.setup);
|
||||
|
||||
/**
|
||||
* @route POST /api/v1/auth/login
|
||||
* @description Authenticates a user and returns a JWT.
|
||||
* @access Public
|
||||
*/
|
||||
router.post('/login', loginRateLimiter, authController.login);
|
||||
router.post('/login', authController.login);
|
||||
|
||||
/**
|
||||
* @route GET /api/v1/auth/status
|
||||
|
||||
@@ -11,47 +11,27 @@ export const createDashboardRouter = (authService: AuthService): Router => {
|
||||
|
||||
router.get(
|
||||
'/stats',
|
||||
requirePermission(
|
||||
'read',
|
||||
'dashboard',
|
||||
'You need the dashboard read permission to view dashboard stats.'
|
||||
),
|
||||
requirePermission('read', 'dashboard', 'dashboard.permissionRequired'),
|
||||
dashboardController.getStats
|
||||
);
|
||||
router.get(
|
||||
'/ingestion-history',
|
||||
requirePermission(
|
||||
'read',
|
||||
'dashboard',
|
||||
'You need the dashboard read permission to view dashboard data.'
|
||||
),
|
||||
requirePermission('read', 'dashboard', 'dashboard.permissionRequired'),
|
||||
dashboardController.getIngestionHistory
|
||||
);
|
||||
router.get(
|
||||
'/ingestion-sources',
|
||||
requirePermission(
|
||||
'read',
|
||||
'dashboard',
|
||||
'You need the dashboard read permission to view dashboard data.'
|
||||
),
|
||||
requirePermission('read', 'dashboard', 'dashboard.permissionRequired'),
|
||||
dashboardController.getIngestionSources
|
||||
);
|
||||
router.get(
|
||||
'/recent-syncs',
|
||||
requirePermission(
|
||||
'read',
|
||||
'dashboard',
|
||||
'You need the dashboard read permission to view dashboard data.'
|
||||
),
|
||||
requirePermission('read', 'dashboard', 'dashboard.permissionRequired'),
|
||||
dashboardController.getRecentSyncs
|
||||
);
|
||||
router.get(
|
||||
'/indexed-insights',
|
||||
requirePermission(
|
||||
'read',
|
||||
'dashboard',
|
||||
'You need the dashboard read permission to view dashboard data.'
|
||||
),
|
||||
requirePermission('read', 'dashboard', 'dashboard.permissionRequired'),
|
||||
dashboardController.getIndexedInsights
|
||||
);
|
||||
|
||||
|
||||
@@ -23,19 +23,19 @@ export const createIamRouter = (iamController: IamController, authService: AuthS
|
||||
*/
|
||||
router.post(
|
||||
'/roles',
|
||||
requirePermission('manage', 'all', 'Super Admin role is required to manage roles.'),
|
||||
requirePermission('manage', 'all', 'iam.requiresSuperAdminRole'),
|
||||
iamController.createRole
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/roles/:id',
|
||||
requirePermission('manage', 'all', 'Super Admin role is required to manage roles.'),
|
||||
requirePermission('manage', 'all', 'iam.requiresSuperAdminRole'),
|
||||
iamController.deleteRole
|
||||
);
|
||||
|
||||
router.put(
|
||||
'/roles/:id',
|
||||
requirePermission('manage', 'all', 'Super Admin role is required to manage roles.'),
|
||||
requirePermission('manage', 'all', 'iam.requiresSuperAdminRole'),
|
||||
iamController.updateRole
|
||||
);
|
||||
return router;
|
||||
|
||||
25
packages/backend/src/api/routes/settings.routes.ts
Normal file
25
packages/backend/src/api/routes/settings.routes.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Router } from 'express';
|
||||
import * as settingsController from '../controllers/settings.controller';
|
||||
import { requireAuth } from '../middleware/requireAuth';
|
||||
import { requirePermission } from '../middleware/requirePermission';
|
||||
import { AuthService } from '../../services/AuthService';
|
||||
|
||||
export const createSettingsRouter = (authService: AuthService): Router => {
|
||||
const router = Router();
|
||||
|
||||
// Public route to get non-sensitive settings. settings read should not be scoped with a permission because all end users need the settings data in the frontend. However, for sensitive settings data, we need to add a new permission subject to limit access. So this route should only expose non-sensitive settings data.
|
||||
/**
|
||||
* @returns SystemSettings
|
||||
*/
|
||||
router.get('/system', settingsController.getSystemSettings);
|
||||
|
||||
// Protected route to update settings
|
||||
router.put(
|
||||
'/system',
|
||||
requireAuth(authService),
|
||||
requirePermission('manage', 'settings', 'settings.noPermissionToUpdate'),
|
||||
settingsController.updateSystemSettings
|
||||
);
|
||||
|
||||
return router;
|
||||
};
|
||||
@@ -1,6 +0,0 @@
|
||||
import { Router } from 'express';
|
||||
import { ingestionQueue } from '../../jobs/queues';
|
||||
|
||||
const router: Router = Router();
|
||||
|
||||
export default router;
|
||||
@@ -18,19 +18,19 @@ export const createUserRouter = (authService: AuthService): Router => {
|
||||
*/
|
||||
router.post(
|
||||
'/',
|
||||
requirePermission('manage', 'all', 'Super Admin role is required to manage users.'),
|
||||
requirePermission('manage', 'all', 'user.requiresSuperAdminRole'),
|
||||
userController.createUser
|
||||
);
|
||||
|
||||
router.put(
|
||||
'/:id',
|
||||
requirePermission('manage', 'all', 'Super Admin role is required to manage users.'),
|
||||
requirePermission('manage', 'all', 'user.requiresSuperAdminRole'),
|
||||
userController.updateUser
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/:id',
|
||||
requirePermission('manage', 'all', 'Super Admin role is required to manage users.'),
|
||||
requirePermission('manage', 'all', 'user.requiresSuperAdminRole'),
|
||||
userController.deleteUser
|
||||
);
|
||||
|
||||
|
||||
12
packages/backend/src/config/api.ts
Normal file
12
packages/backend/src/config/api.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import 'dotenv/config';
|
||||
|
||||
export const apiConfig = {
|
||||
rateLimit: {
|
||||
windowMs: process.env.RATE_LIMIT_WINDOW_MS
|
||||
? parseInt(process.env.RATE_LIMIT_WINDOW_MS, 10)
|
||||
: 1 * 60 * 1000, // 1 minutes
|
||||
max: process.env.RATE_LIMIT_MAX_REQUESTS
|
||||
? parseInt(process.env.RATE_LIMIT_MAX_REQUESTS, 10)
|
||||
: 100, // limit each IP to 100 requests per windowMs
|
||||
},
|
||||
};
|
||||
@@ -1,11 +1,14 @@
|
||||
import { storage } from './storage';
|
||||
import { app } from './app';
|
||||
import { searchConfig } from './search';
|
||||
import { searchConfig, meiliConfig } from './search';
|
||||
import { connection as redisConfig } from './redis';
|
||||
import { apiConfig } from './api';
|
||||
|
||||
export const config = {
|
||||
storage,
|
||||
app,
|
||||
search: searchConfig,
|
||||
meili: meiliConfig,
|
||||
redis: redisConfig,
|
||||
api: apiConfig,
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import pino from 'pino';
|
||||
|
||||
export const logger = pino({
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
redact: ['password'],
|
||||
transport: {
|
||||
target: 'pino-pretty',
|
||||
options: {
|
||||
|
||||
@@ -4,3 +4,9 @@ export const searchConfig = {
|
||||
host: process.env.MEILI_HOST || 'http://127.0.0.1:7700',
|
||||
apiKey: process.env.MEILI_MASTER_KEY || '',
|
||||
};
|
||||
|
||||
export const meiliConfig = {
|
||||
indexingBatchSize: process.env.MEILI_INDEXING_BATCH
|
||||
? parseInt(process.env.MEILI_INDEXING_BATCH)
|
||||
: 500,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
CREATE TABLE "system_settings" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"config" jsonb NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE "api_keys" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"key" text NOT NULL,
|
||||
"expires_at" timestamp with time zone NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "api_keys" ADD CONSTRAINT "api_keys_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "api_keys" ADD COLUMN "key_hash" text NOT NULL;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TYPE "public"."ingestion_provider" ADD VALUE 'mbox_import';
|
||||
1103
packages/backend/src/database/migrations/meta/0017_snapshot.json
Normal file
1103
packages/backend/src/database/migrations/meta/0017_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1171
packages/backend/src/database/migrations/meta/0018_snapshot.json
Normal file
1171
packages/backend/src/database/migrations/meta/0018_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1177
packages/backend/src/database/migrations/meta/0019_snapshot.json
Normal file
1177
packages/backend/src/database/migrations/meta/0019_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1245
packages/backend/src/database/migrations/meta/0020_snapshot.json
Normal file
1245
packages/backend/src/database/migrations/meta/0020_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,125 +1,153 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1752225352591,
|
||||
"tag": "0000_amusing_namora",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1752326803882,
|
||||
"tag": "0001_odd_night_thrasher",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1752332648392,
|
||||
"tag": "0002_lethal_quentin_quire",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "7",
|
||||
"when": 1752332967084,
|
||||
"tag": "0003_petite_wrecker",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "7",
|
||||
"when": 1752606108876,
|
||||
"tag": "0004_sleepy_paper_doll",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "7",
|
||||
"when": 1752606327253,
|
||||
"tag": "0005_chunky_sue_storm",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "7",
|
||||
"when": 1753112018514,
|
||||
"tag": "0006_majestic_caretaker",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "7",
|
||||
"when": 1753190159356,
|
||||
"tag": "0007_handy_archangel",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "7",
|
||||
"when": 1753370737317,
|
||||
"tag": "0008_eminent_the_spike",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "7",
|
||||
"when": 1754337938241,
|
||||
"tag": "0009_late_lenny_balinger",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "7",
|
||||
"when": 1754420780849,
|
||||
"tag": "0010_perpetual_lightspeed",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "7",
|
||||
"when": 1754422064158,
|
||||
"tag": "0011_tan_blackheart",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "7",
|
||||
"when": 1754476962901,
|
||||
"tag": "0012_warm_the_stranger",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "7",
|
||||
"when": 1754659373517,
|
||||
"tag": "0013_classy_talkback",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "7",
|
||||
"when": 1754831765718,
|
||||
"tag": "0014_foamy_vapor",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 15,
|
||||
"version": "7",
|
||||
"when": 1755443936046,
|
||||
"tag": "0015_wakeful_norman_osborn",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 16,
|
||||
"version": "7",
|
||||
"when": 1755780572342,
|
||||
"tag": "0016_lonely_mariko_yashida",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1752225352591,
|
||||
"tag": "0000_amusing_namora",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1752326803882,
|
||||
"tag": "0001_odd_night_thrasher",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1752332648392,
|
||||
"tag": "0002_lethal_quentin_quire",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "7",
|
||||
"when": 1752332967084,
|
||||
"tag": "0003_petite_wrecker",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "7",
|
||||
"when": 1752606108876,
|
||||
"tag": "0004_sleepy_paper_doll",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "7",
|
||||
"when": 1752606327253,
|
||||
"tag": "0005_chunky_sue_storm",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "7",
|
||||
"when": 1753112018514,
|
||||
"tag": "0006_majestic_caretaker",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "7",
|
||||
"when": 1753190159356,
|
||||
"tag": "0007_handy_archangel",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "7",
|
||||
"when": 1753370737317,
|
||||
"tag": "0008_eminent_the_spike",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "7",
|
||||
"when": 1754337938241,
|
||||
"tag": "0009_late_lenny_balinger",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "7",
|
||||
"when": 1754420780849,
|
||||
"tag": "0010_perpetual_lightspeed",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "7",
|
||||
"when": 1754422064158,
|
||||
"tag": "0011_tan_blackheart",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "7",
|
||||
"when": 1754476962901,
|
||||
"tag": "0012_warm_the_stranger",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "7",
|
||||
"when": 1754659373517,
|
||||
"tag": "0013_classy_talkback",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "7",
|
||||
"when": 1754831765718,
|
||||
"tag": "0014_foamy_vapor",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 15,
|
||||
"version": "7",
|
||||
"when": 1755443936046,
|
||||
"tag": "0015_wakeful_norman_osborn",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 16,
|
||||
"version": "7",
|
||||
"when": 1755780572342,
|
||||
"tag": "0016_lonely_mariko_yashida",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 17,
|
||||
"version": "7",
|
||||
"when": 1755961566627,
|
||||
"tag": "0017_tranquil_shooting_star",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 18,
|
||||
"version": "7",
|
||||
"when": 1756911118035,
|
||||
"tag": "0018_flawless_owl",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 19,
|
||||
"version": "7",
|
||||
"when": 1756937533843,
|
||||
"tag": "0019_confused_scream",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 20,
|
||||
"version": "7",
|
||||
"when": 1757860242528,
|
||||
"tag": "0020_panoramic_wolverine",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -5,3 +5,5 @@ export * from './schema/compliance';
|
||||
export * from './schema/custodians';
|
||||
export * from './schema/ingestion-sources';
|
||||
export * from './schema/users';
|
||||
export * from './schema/system-settings';
|
||||
export * from './schema/api-keys';
|
||||
|
||||
15
packages/backend/src/database/schema/api-keys.ts
Normal file
15
packages/backend/src/database/schema/api-keys.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
|
||||
import { users } from './users';
|
||||
|
||||
export const apiKeys = pgTable('api_keys', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
name: text('name').notNull(),
|
||||
userId: uuid('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
key: text('key').notNull(), // Encrypted API key
|
||||
keyHash: text('key_hash').notNull(),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true, mode: 'date' }).notNull(),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||
});
|
||||
@@ -8,6 +8,7 @@ export const ingestionProviderEnum = pgEnum('ingestion_provider', [
|
||||
'generic_imap',
|
||||
'pst_import',
|
||||
'eml_import',
|
||||
'mbox_import',
|
||||
]);
|
||||
|
||||
export const ingestionStatusEnum = pgEnum('ingestion_status', [
|
||||
|
||||
7
packages/backend/src/database/schema/system-settings.ts
Normal file
7
packages/backend/src/database/schema/system-settings.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { pgTable, serial, jsonb } from 'drizzle-orm/pg-core';
|
||||
import type { SystemSettings } from '@open-archiver/types';
|
||||
|
||||
export const systemSettings = pgTable('system_settings', {
|
||||
id: serial('id').primaryKey(),
|
||||
config: jsonb('config').$type<SystemSettings>().notNull(),
|
||||
});
|
||||
@@ -1,7 +1,10 @@
|
||||
import PDFParser from 'pdf2json';
|
||||
import mammoth from 'mammoth';
|
||||
import xlsx from 'xlsx';
|
||||
import { logger } from '../config/logger';
|
||||
import { OcrService } from '../services/OcrService';
|
||||
|
||||
// Legacy PDF extraction (with improved memory management)
|
||||
function extractTextFromPdf(buffer: Buffer): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
const pdfParser = new PDFParser(null, true);
|
||||
@@ -10,34 +13,60 @@ function extractTextFromPdf(buffer: Buffer): Promise<string> {
|
||||
const finish = (text: string) => {
|
||||
if (completed) return;
|
||||
completed = true;
|
||||
pdfParser.removeAllListeners();
|
||||
|
||||
// explicit cleanup
|
||||
try {
|
||||
pdfParser.removeAllListeners();
|
||||
} catch (e) {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
|
||||
resolve(text);
|
||||
};
|
||||
|
||||
pdfParser.on('pdfParser_dataError', () => finish(''));
|
||||
pdfParser.on('pdfParser_dataReady', () => finish(pdfParser.getRawTextContent()));
|
||||
pdfParser.on('pdfParser_dataError', (err: any) => {
|
||||
logger.warn('PDF parsing error:', err?.parserError || 'Unknown error');
|
||||
finish('');
|
||||
});
|
||||
|
||||
pdfParser.on('pdfParser_dataReady', () => {
|
||||
try {
|
||||
const text = pdfParser.getRawTextContent();
|
||||
finish(text || '');
|
||||
} catch (err) {
|
||||
logger.warn('Error getting PDF text content:', err);
|
||||
finish('');
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
pdfParser.parseBuffer(buffer);
|
||||
} catch (err) {
|
||||
console.error('Error parsing PDF buffer', err);
|
||||
logger.error('Error parsing PDF buffer:', err);
|
||||
finish('');
|
||||
}
|
||||
|
||||
// Prevent hanging if the parser never emits events
|
||||
setTimeout(() => finish(''), 10000);
|
||||
// reduced Timeout for better performance
|
||||
setTimeout(() => {
|
||||
logger.warn('PDF parsing timed out');
|
||||
finish('');
|
||||
}, 5000);
|
||||
});
|
||||
}
|
||||
|
||||
export async function extractText(buffer: Buffer, mimeType: string): Promise<string> {
|
||||
// Legacy text extraction for various formats
|
||||
async function extractTextLegacy(buffer: Buffer, mimeType: string): Promise<string> {
|
||||
try {
|
||||
if (mimeType === 'application/pdf') {
|
||||
// Check PDF size (memory protection)
|
||||
if (buffer.length > 50 * 1024 * 1024) { // 50MB Limit
|
||||
logger.warn('PDF too large for legacy extraction, skipping');
|
||||
return '';
|
||||
}
|
||||
return await extractTextFromPdf(buffer);
|
||||
}
|
||||
|
||||
if (
|
||||
mimeType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
||||
) {
|
||||
if (mimeType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') {
|
||||
const { value } = await mammoth.extractRawText({ buffer });
|
||||
return value;
|
||||
}
|
||||
@@ -50,7 +79,7 @@ export async function extractText(buffer: Buffer, mimeType: string): Promise<str
|
||||
const sheetText = xlsx.utils.sheet_to_txt(sheet);
|
||||
fullText += sheetText + '\n';
|
||||
}
|
||||
return fullText;
|
||||
return fullText.trim();
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -60,11 +89,54 @@ export async function extractText(buffer: Buffer, mimeType: string): Promise<str
|
||||
) {
|
||||
return buffer.toString('utf-8');
|
||||
}
|
||||
|
||||
return '';
|
||||
} catch (error) {
|
||||
console.error(`Error extracting text from attachment with MIME type ${mimeType}:`, error);
|
||||
return ''; // Return empty string on failure
|
||||
logger.error(`Error extracting text from attachment with MIME type ${mimeType}:`, error);
|
||||
|
||||
// Force garbage collection if available
|
||||
if (global.gc) {
|
||||
global.gc();
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// Main extraction function
|
||||
export async function extractText(buffer: Buffer, mimeType: string): Promise<string> {
|
||||
// Input validation
|
||||
if (!buffer || buffer.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
console.warn(`Unsupported MIME type for text extraction: ${mimeType}`);
|
||||
return ''; // Return empty string for unsupported types
|
||||
if (!mimeType) {
|
||||
logger.warn('No MIME type provided for text extraction');
|
||||
return '';
|
||||
}
|
||||
|
||||
// General size limit
|
||||
const maxSize = process.env.TIKA_URL ? 100 * 1024 * 1024 : 50 * 1024 * 1024; // 100MB for Tika, 50MB for Legacy
|
||||
if (buffer.length > maxSize) {
|
||||
logger.warn(`File too large for text extraction: ${buffer.length} bytes (limit: ${maxSize})`);
|
||||
return '';
|
||||
}
|
||||
|
||||
// Decide between Tika and legacy
|
||||
const tikaUrl = process.env.TIKA_URL;
|
||||
|
||||
if (tikaUrl) {
|
||||
// Tika decides what it can parse
|
||||
logger.debug(`Using Tika for text extraction: ${mimeType}`);
|
||||
const ocrService = new OcrService()
|
||||
try {
|
||||
return await ocrService.extractTextWithTika(buffer, mimeType);
|
||||
} catch (error) {
|
||||
logger.error({ error }, "OCR text extraction failed, returning empty string")
|
||||
return ''
|
||||
}
|
||||
} else {
|
||||
// extract using legacy mode
|
||||
return await extractTextLegacy(buffer, mimeType);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,12 +16,20 @@ import { createSearchRouter } from './api/routes/search.routes';
|
||||
import { createDashboardRouter } from './api/routes/dashboard.routes';
|
||||
import { createUploadRouter } from './api/routes/upload.routes';
|
||||
import { createUserRouter } from './api/routes/user.routes';
|
||||
import testRouter from './api/routes/test.routes';
|
||||
import { createSettingsRouter } from './api/routes/settings.routes';
|
||||
import { apiKeyRoutes } from './api/routes/api-key.routes';
|
||||
import { AuthService } from './services/AuthService';
|
||||
import { UserService } from './services/UserService';
|
||||
import { IamService } from './services/IamService';
|
||||
import { StorageService } from './services/StorageService';
|
||||
import { SearchService } from './services/SearchService';
|
||||
import { SettingsService } from './services/SettingsService';
|
||||
import i18next from 'i18next';
|
||||
import FsBackend from 'i18next-fs-backend';
|
||||
import i18nextMiddleware from 'i18next-http-middleware';
|
||||
import path from 'path';
|
||||
import { logger } from './config/logger';
|
||||
import { rateLimiter } from './api/middleware/rateLimiter';
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
@@ -35,6 +43,22 @@ if (!PORT_BACKEND || !JWT_SECRET || !JWT_EXPIRES_IN) {
|
||||
);
|
||||
}
|
||||
|
||||
// --- i18next Initialization ---
|
||||
const initializeI18next = async () => {
|
||||
const systemSettings = await settingsService.getSystemSettings();
|
||||
const defaultLanguage = systemSettings?.language || 'en';
|
||||
logger.info({ language: defaultLanguage }, 'Default language');
|
||||
await i18next.use(FsBackend).init({
|
||||
lng: defaultLanguage,
|
||||
fallbackLng: defaultLanguage,
|
||||
ns: ['translation'],
|
||||
defaultNS: 'translation',
|
||||
backend: {
|
||||
loadPath: path.resolve(__dirname, './locales/{{lng}}/{{ns}}.json'),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// --- Dependency Injection Setup ---
|
||||
|
||||
const userService = new UserService();
|
||||
@@ -48,6 +72,7 @@ const searchService = new SearchService();
|
||||
const searchController = new SearchController();
|
||||
const iamService = new IamService();
|
||||
const iamController = new IamController(iamService);
|
||||
const settingsService = new SettingsService();
|
||||
|
||||
// --- Express App Initialization ---
|
||||
const app = express();
|
||||
@@ -62,13 +87,28 @@ const dashboardRouter = createDashboardRouter(authService);
|
||||
const iamRouter = createIamRouter(iamController, authService);
|
||||
const uploadRouter = createUploadRouter(authService);
|
||||
const userRouter = createUserRouter(authService);
|
||||
const settingsRouter = createSettingsRouter(authService);
|
||||
const apiKeyRouter = apiKeyRoutes(authService);
|
||||
// upload route is added before middleware because it doesn't use the json middleware.
|
||||
app.use('/v1/upload', uploadRouter);
|
||||
|
||||
// Middleware for all other routes
|
||||
app.use((req, res, next) => {
|
||||
// exclude certain API endpoints from the rate limiter, for example status, system settings
|
||||
const excludedPatterns = [/^\/v\d+\/auth\/status$/, /^\/v\d+\/settings\/system$/];
|
||||
for (const pattern of excludedPatterns) {
|
||||
if (pattern.test(req.path)) {
|
||||
return next();
|
||||
}
|
||||
}
|
||||
rateLimiter(req, res, next);
|
||||
});
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// i18n middleware
|
||||
app.use(i18nextMiddleware.handle(i18next));
|
||||
|
||||
app.use('/v1/auth', authRouter);
|
||||
app.use('/v1/iam', iamRouter);
|
||||
app.use('/v1/ingestion-sources', ingestionRouter);
|
||||
@@ -77,7 +117,8 @@ app.use('/v1/storage', storageRouter);
|
||||
app.use('/v1/search', searchRouter);
|
||||
app.use('/v1/dashboard', dashboardRouter);
|
||||
app.use('/v1/users', userRouter);
|
||||
app.use('/v1/test', testRouter);
|
||||
app.use('/v1/settings', settingsRouter);
|
||||
app.use('/v1/api-keys', apiKeyRouter);
|
||||
|
||||
// Example of a protected route
|
||||
app.get('/v1/protected', requireAuth(authService), (req, res) => {
|
||||
@@ -94,15 +135,19 @@ app.get('/', (req, res) => {
|
||||
// --- Server Start ---
|
||||
const startServer = async () => {
|
||||
try {
|
||||
// Initialize i18next
|
||||
await initializeI18next();
|
||||
logger.info({}, 'i18next initialized');
|
||||
|
||||
// Configure the Meilisearch index on startup
|
||||
console.log('Configuring email index...');
|
||||
logger.info({}, 'Configuring email index...');
|
||||
await searchService.configureEmailIndex();
|
||||
|
||||
app.listen(PORT_BACKEND, () => {
|
||||
console.log(`Backend listening at http://localhost:${PORT_BACKEND}`);
|
||||
logger.info({}, `Backend listening at http://localhost:${PORT_BACKEND}`);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to start the server:', error);
|
||||
logger.error({ error }, 'Failed to start the server:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3,14 +3,15 @@ import { IndexingService } from '../../services/IndexingService';
|
||||
import { SearchService } from '../../services/SearchService';
|
||||
import { StorageService } from '../../services/StorageService';
|
||||
import { DatabaseService } from '../../services/DatabaseService';
|
||||
import { PendingEmail } from '@open-archiver/types';
|
||||
|
||||
const searchService = new SearchService();
|
||||
const storageService = new StorageService();
|
||||
const databaseService = new DatabaseService();
|
||||
const indexingService = new IndexingService(databaseService, searchService, storageService);
|
||||
|
||||
export default async function (job: Job<{ emailId: string }>) {
|
||||
const { emailId } = job.data;
|
||||
console.log(`Indexing email with ID: ${emailId}`);
|
||||
await indexingService.indexEmailById(emailId);
|
||||
export default async function (job: Job<{ emails: PendingEmail[] }>) {
|
||||
const { emails } = job.data;
|
||||
console.log(`Indexing email batch with ${emails.length} emails`);
|
||||
await indexingService.indexEmailBatch(emails);
|
||||
}
|
||||
@@ -1,9 +1,19 @@
|
||||
import { Job } from 'bullmq';
|
||||
import { IProcessMailboxJob, SyncState, ProcessMailboxError } from '@open-archiver/types';
|
||||
import {
|
||||
IProcessMailboxJob,
|
||||
SyncState,
|
||||
ProcessMailboxError,
|
||||
PendingEmail,
|
||||
} from '@open-archiver/types';
|
||||
import { IngestionService } from '../../services/IngestionService';
|
||||
import { logger } from '../../config/logger';
|
||||
import { EmailProviderFactory } from '../../services/EmailProviderFactory';
|
||||
import { StorageService } from '../../services/StorageService';
|
||||
import { IndexingService } from '../../services/IndexingService';
|
||||
import { SearchService } from '../../services/SearchService';
|
||||
import { DatabaseService } from '../../services/DatabaseService';
|
||||
import { config } from '../../config';
|
||||
|
||||
|
||||
/**
|
||||
* This processor handles the ingestion of emails for a single user's mailbox.
|
||||
@@ -15,9 +25,16 @@ import { StorageService } from '../../services/StorageService';
|
||||
*/
|
||||
export const processMailboxProcessor = async (job: Job<IProcessMailboxJob, SyncState, string>) => {
|
||||
const { ingestionSourceId, userEmail } = job.data;
|
||||
const BATCH_SIZE: number = config.meili.indexingBatchSize;
|
||||
let emailBatch: PendingEmail[] = [];
|
||||
|
||||
logger.info({ ingestionSourceId, userEmail }, `Processing mailbox for user`);
|
||||
|
||||
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);
|
||||
if (!source) {
|
||||
@@ -26,22 +43,38 @@ export const processMailboxProcessor = async (job: Job<IProcessMailboxJob, SyncS
|
||||
|
||||
const connector = EmailProviderFactory.createConnector(source);
|
||||
const ingestionService = new IngestionService();
|
||||
const storageService = new StorageService();
|
||||
|
||||
// Pass the sync state for the entire source, the connector will handle per-user logic if necessary
|
||||
for await (const email of connector.fetchEmails(userEmail, source.syncState)) {
|
||||
if (email) {
|
||||
await ingestionService.processEmail(email, source, storageService, userEmail);
|
||||
const processedEmail = await ingestionService.processEmail(
|
||||
email,
|
||||
source,
|
||||
storageService,
|
||||
userEmail
|
||||
);
|
||||
if (processedEmail) {
|
||||
emailBatch.push(processedEmail);
|
||||
if (emailBatch.length >= BATCH_SIZE) {
|
||||
await indexingService.indexEmailBatch(emailBatch);
|
||||
emailBatch = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (emailBatch.length > 0) {
|
||||
await indexingService.indexEmailBatch(emailBatch);
|
||||
emailBatch = [];
|
||||
}
|
||||
|
||||
const newSyncState = connector.getUpdatedSyncState(userEmail);
|
||||
|
||||
logger.info({ ingestionSourceId, userEmail }, `Finished processing mailbox for user`);
|
||||
|
||||
// Return the new sync state to be aggregated by the parent flow
|
||||
return newSyncState;
|
||||
} catch (error) {
|
||||
if (emailBatch.length > 0) {
|
||||
await indexingService.indexEmailBatch(emailBatch);
|
||||
}
|
||||
|
||||
logger.error({ err: error, ingestionSourceId, userEmail }, 'Error processing mailbox');
|
||||
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred';
|
||||
const processMailboxError: ProcessMailboxError = {
|
||||
|
||||
@@ -8,6 +8,7 @@ const scheduleContinuousSync = async () => {
|
||||
'schedule-continuous-sync',
|
||||
{},
|
||||
{
|
||||
jobId: 'schedule-continuous-sync',
|
||||
repeat: {
|
||||
pattern: config.app.syncFrequency,
|
||||
},
|
||||
|
||||
62
packages/backend/src/locales/de/translation.json
Normal file
62
packages/backend/src/locales/de/translation.json
Normal file
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"auth": {
|
||||
"setup": {
|
||||
"allFieldsRequired": "E-Mail, Passwort und Name sind erforderlich",
|
||||
"alreadyCompleted": "Die Einrichtung wurde bereits abgeschlossen."
|
||||
},
|
||||
"login": {
|
||||
"emailAndPasswordRequired": "E-Mail und Passwort sind erforderlich",
|
||||
"invalidCredentials": "Ungültige Anmeldeinformationen"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"internalServerError": "Ein interner Serverfehler ist aufgetreten",
|
||||
"demoMode": "Dieser Vorgang ist im Demo-Modus nicht zulässig.",
|
||||
"unauthorized": "Unbefugt",
|
||||
"unknown": "Ein unbekannter Fehler ist aufgetreten",
|
||||
"noPermissionToAction": "Sie haben keine Berechtigung, die aktuelle Aktion auszuführen."
|
||||
},
|
||||
"user": {
|
||||
"notFound": "Benutzer nicht gefunden",
|
||||
"cannotDeleteOnlyUser": "Sie versuchen, den einzigen Benutzer in der Datenbank zu löschen, dies ist nicht gestattet.",
|
||||
"requiresSuperAdminRole": "Die Rolle des Super-Admins ist erforderlich, um Benutzer zu verwalten."
|
||||
},
|
||||
"iam": {
|
||||
"failedToGetRoles": "Rollen konnten nicht abgerufen werden.",
|
||||
"roleNotFound": "Rolle nicht gefunden.",
|
||||
"failedToGetRole": "Rolle konnte nicht abgerufen werden.",
|
||||
"missingRoleFields": "Fehlende erforderliche Felder: Name und Richtlinie.",
|
||||
"invalidPolicy": "Ungültige Richtlinienanweisung:",
|
||||
"failedToCreateRole": "Rolle konnte nicht erstellt werden.",
|
||||
"failedToDeleteRole": "Rolle konnte nicht gelöscht werden.",
|
||||
"missingUpdateFields": "Fehlende Felder zum Aktualisieren: Name oder Richtlinien.",
|
||||
"failedToUpdateRole": "Rolle konnte nicht aktualisiert werden.",
|
||||
"requiresSuperAdminRole": "Die Rolle des Super-Admins ist erforderlich, um Rollen zu verwalten."
|
||||
},
|
||||
"settings": {
|
||||
"failedToRetrieve": "Einstellungen konnten nicht abgerufen werden",
|
||||
"failedToUpdate": "Einstellungen konnten nicht aktualisiert werden",
|
||||
"noPermissionToUpdate": "Sie haben keine Berechtigung, die Systemeinstellungen zu aktualisieren."
|
||||
},
|
||||
"dashboard": {
|
||||
"permissionRequired": "Sie benötigen die Leseberechtigung für das Dashboard, um Dashboard-Daten anzuzeigen."
|
||||
},
|
||||
"ingestion": {
|
||||
"failedToCreate": "Die Erfassungsquelle konnte aufgrund eines Verbindungsfehlers nicht erstellt werden.",
|
||||
"notFound": "Erfassungsquelle nicht gefunden",
|
||||
"initialImportTriggered": "Erstimport erfolgreich ausgelöst.",
|
||||
"forceSyncTriggered": "Erzwungene Synchronisierung erfolgreich ausgelöst."
|
||||
},
|
||||
"archivedEmail": {
|
||||
"notFound": "Archivierte E-Mail nicht gefunden"
|
||||
},
|
||||
"search": {
|
||||
"keywordsRequired": "Schlüsselwörter sind erforderlich"
|
||||
},
|
||||
"storage": {
|
||||
"filePathRequired": "Dateipfad ist erforderlich",
|
||||
"invalidFilePath": "Ungültiger Dateipfad",
|
||||
"fileNotFound": "Datei nicht gefunden",
|
||||
"downloadError": "Fehler beim Herunterladen der Datei"
|
||||
}
|
||||
}
|
||||
62
packages/backend/src/locales/el/translation.json
Normal file
62
packages/backend/src/locales/el/translation.json
Normal file
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"auth": {
|
||||
"setup": {
|
||||
"allFieldsRequired": "Το email, ο κωδικός πρόσβασης και το όνομα είναι υποχρεωτικά",
|
||||
"alreadyCompleted": "Η εγκατάσταση έχει ήδη ολοκληρωθεί."
|
||||
},
|
||||
"login": {
|
||||
"emailAndPasswordRequired": "Το email και ο κωδικός πρόσβασης είναι υποχρεωτικά",
|
||||
"invalidCredentials": "Μη έγκυρα διαπιστευτήρια"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"internalServerError": "Παρουσιάστηκε ένα εσωτερικό σφάλμα διακομιστή",
|
||||
"demoMode": "Αυτή η λειτουργία δεν επιτρέπεται σε λειτουργία επίδειξης.",
|
||||
"unauthorized": "Μη εξουσιοδοτημένο",
|
||||
"unknown": "Παρουσιάστηκε ένα άγνωστο σφάλμα",
|
||||
"noPermissionToAction": "Δεν έχετε την άδεια να εκτελέσετε την τρέχουσα ενέργεια."
|
||||
},
|
||||
"user": {
|
||||
"notFound": "Ο χρήστης δεν βρέθηκε",
|
||||
"cannotDeleteOnlyUser": "Προσπαθείτε να διαγράψετε τον μοναδικό χρήστη στη βάση δεδομένων, αυτό δεν επιτρέπεται.",
|
||||
"requiresSuperAdminRole": "Απαιτείται ο ρόλος του Super Admin για τη διαχείριση των χρηστών."
|
||||
},
|
||||
"iam": {
|
||||
"failedToGetRoles": "Η λήψη των ρόλων απέτυχε.",
|
||||
"roleNotFound": "Ο ρόλος δεν βρέθηκε.",
|
||||
"failedToGetRole": "Η λήψη του ρόλου απέτυχε.",
|
||||
"missingRoleFields": "Λείπουν τα απαιτούμενα πεδία: όνομα και πολιτική.",
|
||||
"invalidPolicy": "Μη έγκυρη δήλωση πολιτικής:",
|
||||
"failedToCreateRole": "Η δημιουργία του ρόλου απέτυχε.",
|
||||
"failedToDeleteRole": "Η διαγραφή του ρόλου απέτυχε.",
|
||||
"missingUpdateFields": "Λείπουν πεδία για ενημέρωση: όνομα ή πολιτικές.",
|
||||
"failedToUpdateRole": "Η ενημέρωση του ρόλου απέτυχε.",
|
||||
"requiresSuperAdminRole": "Απαιτείται ο ρόλος του Super Admin για τη διαχείριση των ρόλων."
|
||||
},
|
||||
"settings": {
|
||||
"failedToRetrieve": "Η ανάκτηση των ρυθμίσεων απέτυχε",
|
||||
"failedToUpdate": "Η ενημέρωση των ρυθμίσεων απέτυχε",
|
||||
"noPermissionToUpdate": "Δεν έχετε άδεια να ενημερώσετε τις ρυθμίσεις του συστήματος."
|
||||
},
|
||||
"dashboard": {
|
||||
"permissionRequired": "Χρειάζεστε την άδεια ανάγνωσης του πίνακα ελέγχου για να δείτε τα δεδομένα του πίνακα ελέγχου."
|
||||
},
|
||||
"ingestion": {
|
||||
"failedToCreate": "Η δημιουργία της πηγής πρόσληψης απέτυχε λόγω σφάλματος σύνδεσης.",
|
||||
"notFound": "Η πηγή πρόσληψης δεν βρέθηκε",
|
||||
"initialImportTriggered": "Η αρχική εισαγωγή ενεργοποιήθηκε με επιτυχία.",
|
||||
"forceSyncTriggered": "Ο εξαναγκασμένος συγχρονισμός ενεργοποιήθηκε με επιτυχία."
|
||||
},
|
||||
"archivedEmail": {
|
||||
"notFound": "Το αρχειοθετημένο email δεν βρέθηκε"
|
||||
},
|
||||
"search": {
|
||||
"keywordsRequired": "Οι λέξεις-κλειδιά είναι υποχρεωτικές"
|
||||
},
|
||||
"storage": {
|
||||
"filePathRequired": "Η διαδρομή του αρχείου είναι υποχρεωτική",
|
||||
"invalidFilePath": "Μη έγκυρη διαδρομή αρχείου",
|
||||
"fileNotFound": "Το αρχείο δεν βρέθηκε",
|
||||
"downloadError": "Σφάλμα κατά τη λήψη του αρχείου"
|
||||
}
|
||||
}
|
||||
69
packages/backend/src/locales/en/translation.json
Normal file
69
packages/backend/src/locales/en/translation.json
Normal file
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"auth": {
|
||||
"setup": {
|
||||
"allFieldsRequired": "Email, password, and name are required",
|
||||
"alreadyCompleted": "Setup has already been completed."
|
||||
},
|
||||
"login": {
|
||||
"emailAndPasswordRequired": "Email and password are required",
|
||||
"invalidCredentials": "Invalid credentials"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"internalServerError": "An internal server error occurred",
|
||||
"demoMode": "This operation is not allowed in demo mode.",
|
||||
"unauthorized": "Unauthorized",
|
||||
"unknown": "An unknown error occurred",
|
||||
"noPermissionToAction": "You don't have the permission to perform the current action."
|
||||
},
|
||||
"user": {
|
||||
"notFound": "User not found",
|
||||
"cannotDeleteOnlyUser": "You are trying to delete the only user in the database, this is not allowed.",
|
||||
"requiresSuperAdminRole": "Super Admin role is required to manage users."
|
||||
},
|
||||
"iam": {
|
||||
"failedToGetRoles": "Failed to get roles.",
|
||||
"roleNotFound": "Role not found.",
|
||||
"failedToGetRole": "Failed to get role.",
|
||||
"missingRoleFields": "Missing required fields: name and policy.",
|
||||
"invalidPolicy": "Invalid policy statement:",
|
||||
"failedToCreateRole": "Failed to create role.",
|
||||
"failedToDeleteRole": "Failed to delete role.",
|
||||
"missingUpdateFields": "Missing fields to update: name or policies.",
|
||||
"failedToUpdateRole": "Failed to update role.",
|
||||
"requiresSuperAdminRole": "Super Admin role is required to manage roles."
|
||||
},
|
||||
"settings": {
|
||||
"failedToRetrieve": "Failed to retrieve settings",
|
||||
"failedToUpdate": "Failed to update settings",
|
||||
"noPermissionToUpdate": "You do not have permission to update system settings."
|
||||
},
|
||||
"dashboard": {
|
||||
"permissionRequired": "You need the dashboard read permission to view dashboard data."
|
||||
},
|
||||
"ingestion": {
|
||||
"failedToCreate": "Failed to create ingestion source due to a connection error.",
|
||||
"notFound": "Ingestion source not found",
|
||||
"initialImportTriggered": "Initial import triggered successfully.",
|
||||
"forceSyncTriggered": "Force sync triggered successfully."
|
||||
},
|
||||
"archivedEmail": {
|
||||
"notFound": "Archived email not found"
|
||||
},
|
||||
"search": {
|
||||
"keywordsRequired": "Keywords are required"
|
||||
},
|
||||
"storage": {
|
||||
"filePathRequired": "File path is required",
|
||||
"invalidFilePath": "Invalid file path",
|
||||
"fileNotFound": "File not found",
|
||||
"downloadError": "Error downloading file"
|
||||
},
|
||||
"apiKeys": {
|
||||
"generateSuccess": "API key generated successfully.",
|
||||
"deleteSuccess": "API key deleted successfully."
|
||||
},
|
||||
"api": {
|
||||
"requestBodyInvalid": "Invalid request body."
|
||||
}
|
||||
}
|
||||
62
packages/backend/src/locales/es/translation.json
Normal file
62
packages/backend/src/locales/es/translation.json
Normal file
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"auth": {
|
||||
"setup": {
|
||||
"allFieldsRequired": "Se requieren correo electrónico, contraseña y nombre",
|
||||
"alreadyCompleted": "La configuración ya se ha completado."
|
||||
},
|
||||
"login": {
|
||||
"emailAndPasswordRequired": "Se requieren correo electrónico y contraseña",
|
||||
"invalidCredentials": "Credenciales no válidas"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"internalServerError": "Ocurrió un error interno del servidor",
|
||||
"demoMode": "Esta operación no está permitida en modo de demostración.",
|
||||
"unauthorized": "No autorizado",
|
||||
"unknown": "Ocurrió un error desconocido",
|
||||
"noPermissionToAction": "No tienes permiso para realizar la acción actual."
|
||||
},
|
||||
"user": {
|
||||
"notFound": "Usuario no encontrado",
|
||||
"cannotDeleteOnlyUser": "Estás intentando eliminar al único usuario de la base de datos, esto no está permitido.",
|
||||
"requiresSuperAdminRole": "Se requiere el rol de Superadministrador para gestionar usuarios."
|
||||
},
|
||||
"iam": {
|
||||
"failedToGetRoles": "Error al obtener los roles.",
|
||||
"roleNotFound": "Rol no encontrado.",
|
||||
"failedToGetRole": "Error al obtener el rol.",
|
||||
"missingRoleFields": "Faltan campos obligatorios: nombre y política.",
|
||||
"invalidPolicy": "Declaración de política no válida:",
|
||||
"failedToCreateRole": "Error al crear el rol.",
|
||||
"failedToDeleteRole": "Error al eliminar el rol.",
|
||||
"missingUpdateFields": "Faltan campos para actualizar: nombre o políticas.",
|
||||
"failedToUpdateRole": "Error al actualizar el rol.",
|
||||
"requiresSuperAdminRole": "Se requiere el rol de Superadministrador para gestionar los roles."
|
||||
},
|
||||
"settings": {
|
||||
"failedToRetrieve": "Error al recuperar la configuración",
|
||||
"failedToUpdate": "Error al actualizar la configuración",
|
||||
"noPermissionToUpdate": "No tienes permiso para actualizar la configuración del sistema."
|
||||
},
|
||||
"dashboard": {
|
||||
"permissionRequired": "Necesitas el permiso de lectura del panel de control para ver los datos del panel."
|
||||
},
|
||||
"ingestion": {
|
||||
"failedToCreate": "Error al crear la fuente de ingesta debido a un error de conexión.",
|
||||
"notFound": "Fuente de ingesta no encontrada",
|
||||
"initialImportTriggered": "Importación inicial activada correctamente.",
|
||||
"forceSyncTriggered": "Sincronización forzada activada correctamente."
|
||||
},
|
||||
"archivedEmail": {
|
||||
"notFound": "Correo electrónico archivado no encontrado"
|
||||
},
|
||||
"search": {
|
||||
"keywordsRequired": "Se requieren palabras clave"
|
||||
},
|
||||
"storage": {
|
||||
"filePathRequired": "Se requiere la ruta del archivo",
|
||||
"invalidFilePath": "Ruta de archivo no válida",
|
||||
"fileNotFound": "Archivo no encontrado",
|
||||
"downloadError": "Error al descargar el archivo"
|
||||
}
|
||||
}
|
||||
62
packages/backend/src/locales/et/translation.json
Normal file
62
packages/backend/src/locales/et/translation.json
Normal file
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"auth": {
|
||||
"setup": {
|
||||
"allFieldsRequired": "E-post, parool ja nimi on kohustuslikud",
|
||||
"alreadyCompleted": "Seadistamine on juba lõpule viidud."
|
||||
},
|
||||
"login": {
|
||||
"emailAndPasswordRequired": "E-post ja parool on kohustuslikud",
|
||||
"invalidCredentials": "Valed sisselogimisandmed"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"internalServerError": "Ilmnes sisemine serveriviga",
|
||||
"demoMode": "See toiming pole demorežiimis lubatud.",
|
||||
"unauthorized": "Volitamata",
|
||||
"unknown": "Ilmnes tundmatu viga",
|
||||
"noPermissionToAction": "Teil pole praeguse toimingu tegemiseks luba."
|
||||
},
|
||||
"user": {
|
||||
"notFound": "Kasutajat ei leitud",
|
||||
"cannotDeleteOnlyUser": "Püüate kustutada andmebaasi ainsat kasutajat, see pole lubatud.",
|
||||
"requiresSuperAdminRole": "Kasutajate haldamiseks on vajalik superadministraatori roll."
|
||||
},
|
||||
"iam": {
|
||||
"failedToGetRoles": "Rollide hankimine ebaõnnestus.",
|
||||
"roleNotFound": "Rolli ei leitud.",
|
||||
"failedToGetRole": "Rolli hankimine ebaõnnestus.",
|
||||
"missingRoleFields": "Puuduvad kohustuslikud väljad: nimi ja poliitika.",
|
||||
"invalidPolicy": "Kehtetu poliitika avaldus:",
|
||||
"failedToCreateRole": "Rolli loomine ebaõnnestus.",
|
||||
"failedToDeleteRole": "Rolli kustutamine ebaõnnestus.",
|
||||
"missingUpdateFields": "Uuendamiseks puuduvad väljad: nimi või poliitikad.",
|
||||
"failedToUpdateRole": "Rolli värskendamine ebaõnnestus.",
|
||||
"requiresSuperAdminRole": "Rollide haldamiseks on vajalik superadministraatori roll."
|
||||
},
|
||||
"settings": {
|
||||
"failedToRetrieve": "Seadete toomine ebaõnnestus",
|
||||
"failedToUpdate": "Seadete värskendamine ebaõnnestus",
|
||||
"noPermissionToUpdate": "Teil pole süsteemi seadete värskendamiseks luba."
|
||||
},
|
||||
"dashboard": {
|
||||
"permissionRequired": "Armatuurlaua andmete vaatamiseks on teil vaja armatuurlaua lugemisluba."
|
||||
},
|
||||
"ingestion": {
|
||||
"failedToCreate": "Söötmeallika loomine ebaõnnestus ühenduse vea tõttu.",
|
||||
"notFound": "Söötmeallikat ei leitud",
|
||||
"initialImportTriggered": "Esialgne import käivitati edukalt.",
|
||||
"forceSyncTriggered": "Sundsünkroonimine käivitati edukalt."
|
||||
},
|
||||
"archivedEmail": {
|
||||
"notFound": "Arhiveeritud e-kirja ei leitud"
|
||||
},
|
||||
"search": {
|
||||
"keywordsRequired": "Märksõnad on kohustuslikud"
|
||||
},
|
||||
"storage": {
|
||||
"filePathRequired": "Faili tee on kohustuslik",
|
||||
"invalidFilePath": "Kehtetu faili tee",
|
||||
"fileNotFound": "Faili ei leitud",
|
||||
"downloadError": "Faili allalaadimisel ilmnes viga"
|
||||
}
|
||||
}
|
||||
62
packages/backend/src/locales/fr/translation.json
Normal file
62
packages/backend/src/locales/fr/translation.json
Normal file
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"auth": {
|
||||
"setup": {
|
||||
"allFieldsRequired": "L'e-mail, le mot de passe et le nom sont requis",
|
||||
"alreadyCompleted": "La configuration est déjà terminée."
|
||||
},
|
||||
"login": {
|
||||
"emailAndPasswordRequired": "L'e-mail et le mot de passe sont requis",
|
||||
"invalidCredentials": "Identifiants invalides"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"internalServerError": "Une erreur interne du serveur s'est produite",
|
||||
"demoMode": "Cette opération n'est pas autorisée en mode démo.",
|
||||
"unauthorized": "Non autorisé",
|
||||
"unknown": "Une erreur inconnue s'est produite",
|
||||
"noPermissionToAction": "Vous n'avez pas la permission d'effectuer l'action en cours."
|
||||
},
|
||||
"user": {
|
||||
"notFound": "Utilisateur non trouvé",
|
||||
"cannotDeleteOnlyUser": "Vous essayez de supprimer le seul utilisateur de la base de données, ce n'est pas autorisé.",
|
||||
"requiresSuperAdminRole": "Le rôle de Super Admin est requis pour gérer les utilisateurs."
|
||||
},
|
||||
"iam": {
|
||||
"failedToGetRoles": "Échec de la récupération des rôles.",
|
||||
"roleNotFound": "Rôle non trouvé.",
|
||||
"failedToGetRole": "Échec de la récupération du rôle.",
|
||||
"missingRoleFields": "Champs obligatoires manquants : nom et politique.",
|
||||
"invalidPolicy": "Déclaration de politique invalide :",
|
||||
"failedToCreateRole": "Échec de la création du rôle.",
|
||||
"failedToDeleteRole": "Échec de la suppression du rôle.",
|
||||
"missingUpdateFields": "Champs à mettre à jour manquants : nom ou politiques.",
|
||||
"failedToUpdateRole": "Échec de la mise à jour du rôle.",
|
||||
"requiresSuperAdminRole": "Le rôle de Super Admin est requis pour gérer les rôles."
|
||||
},
|
||||
"settings": {
|
||||
"failedToRetrieve": "Échec de la récupération des paramètres",
|
||||
"failedToUpdate": "Échec de la mise à jour des paramètres",
|
||||
"noPermissionToUpdate": "Vous n'avez pas la permission de mettre à jour les paramètres système."
|
||||
},
|
||||
"dashboard": {
|
||||
"permissionRequired": "Vous avez besoin de la permission de lecture du tableau de bord pour afficher les données du tableau de bord."
|
||||
},
|
||||
"ingestion": {
|
||||
"failedToCreate": "Échec de la création de la source d'ingestion en raison d'une erreur de connexion.",
|
||||
"notFound": "Source d'ingestion non trouvée",
|
||||
"initialImportTriggered": "Importation initiale déclenchée avec succès.",
|
||||
"forceSyncTriggered": "Synchronisation forcée déclenchée avec succès."
|
||||
},
|
||||
"archivedEmail": {
|
||||
"notFound": "E-mail archivé non trouvé"
|
||||
},
|
||||
"search": {
|
||||
"keywordsRequired": "Des mots-clés sont requis"
|
||||
},
|
||||
"storage": {
|
||||
"filePathRequired": "Le chemin du fichier est requis",
|
||||
"invalidFilePath": "Chemin de fichier invalide",
|
||||
"fileNotFound": "Fichier non trouvé",
|
||||
"downloadError": "Erreur lors du téléchargement du fichier"
|
||||
}
|
||||
}
|
||||
62
packages/backend/src/locales/it/translation.json
Normal file
62
packages/backend/src/locales/it/translation.json
Normal file
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"auth": {
|
||||
"setup": {
|
||||
"allFieldsRequired": "Email, password e nome sono obbligatori",
|
||||
"alreadyCompleted": "La configurazione è già stata completata."
|
||||
},
|
||||
"login": {
|
||||
"emailAndPasswordRequired": "Email and password are required",
|
||||
"invalidCredentials": "Credenziali non valide"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"internalServerError": "Si è verificato un errore interno del server",
|
||||
"demoMode": "Questa operazione non è consentita in modalità demo.",
|
||||
"unauthorized": "Non autorizzato",
|
||||
"unknown": "Si è verificato un errore sconosciuto",
|
||||
"noPermissionToAction": "Non hai il permesso di eseguire l'azione corrente."
|
||||
},
|
||||
"user": {
|
||||
"notFound": "Utente non trovato",
|
||||
"cannotDeleteOnlyUser": "Stai tentando di eliminare l'unico utente nel database, ciò non è consentito.",
|
||||
"requiresSuperAdminRole": "È richiesto il ruolo di Super Admin per gestire gli utenti."
|
||||
},
|
||||
"iam": {
|
||||
"failedToGetRoles": "Impossibile ottenere i ruoli.",
|
||||
"roleNotFound": "Ruolo non trovato.",
|
||||
"failedToGetRole": "Impossibile ottenere il ruolo.",
|
||||
"missingRoleFields": "Campi obbligatori mancanti: nome e policy.",
|
||||
"invalidPolicy": "Dichiarazione di policy non valida:",
|
||||
"failedToCreateRole": "Impossibile creare il ruolo.",
|
||||
"failedToDeleteRole": "Impossibile eliminare il ruolo.",
|
||||
"missingUpdateFields": "Campi da aggiornare mancanti: nome o policy.",
|
||||
"failedToUpdateRole": "Impossibile aggiornare il ruolo.",
|
||||
"requiresSuperAdminRole": "È richiesto il ruolo di Super Admin per gestire i ruoli."
|
||||
},
|
||||
"settings": {
|
||||
"failedToRetrieve": "Impossibile recuperare le impostazioni",
|
||||
"failedToUpdate": "Impossibile aggiornare le impostazioni",
|
||||
"noPermissionToUpdate": "Non hai il permesso di aggiornare le impostazioni di sistema."
|
||||
},
|
||||
"dashboard": {
|
||||
"permissionRequired": "È necessaria l'autorizzazione di lettura della dashboard per visualizzare i dati della dashboard."
|
||||
},
|
||||
"ingestion": {
|
||||
"failedToCreate": "Impossibile creare l'origine di inserimento a causa di un errore di connessione.",
|
||||
"notFound": "Origine di inserimento non trovata",
|
||||
"initialImportTriggered": "Importazione iniziale attivata con successo.",
|
||||
"forceSyncTriggered": "Sincronizzazione forzata attivata con successo."
|
||||
},
|
||||
"archivedEmail": {
|
||||
"notFound": "Email archiviata non trovata"
|
||||
},
|
||||
"search": {
|
||||
"keywordsRequired": "Le parole chiave sono obbligatorie"
|
||||
},
|
||||
"storage": {
|
||||
"filePathRequired": "Il percorso del file è obbligatorio",
|
||||
"invalidFilePath": "Percorso del file non valido",
|
||||
"fileNotFound": "File non trovato",
|
||||
"downloadError": "Errore durante il download del file"
|
||||
}
|
||||
}
|
||||
62
packages/backend/src/locales/ja/translation.json
Normal file
62
packages/backend/src/locales/ja/translation.json
Normal file
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"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": "ファイルのダウンロード中にエラーが発生しました"
|
||||
}
|
||||
}
|
||||
62
packages/backend/src/locales/nl/translation.json
Normal file
62
packages/backend/src/locales/nl/translation.json
Normal file
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"auth": {
|
||||
"setup": {
|
||||
"allFieldsRequired": "E-mail, wachtwoord en naam zijn verplicht",
|
||||
"alreadyCompleted": "De installatie is al voltooid."
|
||||
},
|
||||
"login": {
|
||||
"emailAndPasswordRequired": "E-mail en wachtwoord zijn verplicht",
|
||||
"invalidCredentials": "Ongeldige inloggegevens"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"internalServerError": "Er is een interne serverfout opgetreden",
|
||||
"demoMode": "Deze bewerking is niet toegestaan in de demomodus.",
|
||||
"unauthorized": "Ongeautoriseerd",
|
||||
"unknown": "Er is een onbekende fout opgetreden",
|
||||
"noPermissionToAction": "U heeft geen toestemming om de huidige actie uit te voeren."
|
||||
},
|
||||
"user": {
|
||||
"notFound": "Gebruiker niet gevonden",
|
||||
"cannotDeleteOnlyUser": "U probeert de enige gebruiker in de database te verwijderen, dit is niet toegestaan.",
|
||||
"requiresSuperAdminRole": "De rol van Super Admin is vereist om gebruikers te beheren."
|
||||
},
|
||||
"iam": {
|
||||
"failedToGetRoles": "Kan rollen niet ophalen.",
|
||||
"roleNotFound": "Rol niet gevonden.",
|
||||
"failedToGetRole": "Kan rol niet ophalen.",
|
||||
"missingRoleFields": "Ontbrekende verplichte velden: naam en beleid.",
|
||||
"invalidPolicy": "Ongeldige beleidsverklaring:",
|
||||
"failedToCreateRole": "Kan rol niet aanmaken.",
|
||||
"failedToDeleteRole": "Kan rol niet verwijderen.",
|
||||
"missingUpdateFields": "Ontbrekende velden om bij te werken: naam of beleid.",
|
||||
"failedToUpdateRole": "Kan rol niet bijwerken.",
|
||||
"requiresSuperAdminRole": "De rol van Super Admin is vereist om rollen te beheren."
|
||||
},
|
||||
"settings": {
|
||||
"failedToRetrieve": "Kan instellingen niet ophalen",
|
||||
"failedToUpdate": "Kan instellingen niet bijwerken",
|
||||
"noPermissionToUpdate": "U heeft geen toestemming om de systeeminstellingen bij te werken."
|
||||
},
|
||||
"dashboard": {
|
||||
"permissionRequired": "U heeft de leesrechten voor het dashboard nodig om dashboardgegevens te bekijken."
|
||||
},
|
||||
"ingestion": {
|
||||
"failedToCreate": "Kan de opnamebron niet aanmaken vanwege een verbindingsfout.",
|
||||
"notFound": "Opnamebron niet gevonden",
|
||||
"initialImportTriggered": "Initiële import succesvol geactiveerd.",
|
||||
"forceSyncTriggered": "Geforceerde synchronisatie succesvol geactiveerd."
|
||||
},
|
||||
"archivedEmail": {
|
||||
"notFound": "Gearchiveerde e-mail niet gevonden"
|
||||
},
|
||||
"search": {
|
||||
"keywordsRequired": "Trefwoorden zijn verplicht"
|
||||
},
|
||||
"storage": {
|
||||
"filePathRequired": "Bestandspad is verplicht",
|
||||
"invalidFilePath": "Ongeldig bestandspad",
|
||||
"fileNotFound": "Bestand niet gevonden",
|
||||
"downloadError": "Fout bij het downloaden van het bestand"
|
||||
}
|
||||
}
|
||||
62
packages/backend/src/locales/pt/translation.json
Normal file
62
packages/backend/src/locales/pt/translation.json
Normal file
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"auth": {
|
||||
"setup": {
|
||||
"allFieldsRequired": "E-mail, senha e nome são obrigatórios",
|
||||
"alreadyCompleted": "A configuração já foi concluída."
|
||||
},
|
||||
"login": {
|
||||
"emailAndPasswordRequired": "E-mail e senha são obrigatórios",
|
||||
"invalidCredentials": "Credenciais inválidas"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"internalServerError": "Ocorreu um erro interno do servidor",
|
||||
"demoMode": "Esta operação não é permitida no modo de demonstração.",
|
||||
"unauthorized": "Não autorizado",
|
||||
"unknown": "Ocorreu um erro desconhecido",
|
||||
"noPermissionToAction": "Você não tem permissão para executar a ação atual."
|
||||
},
|
||||
"user": {
|
||||
"notFound": "Usuário não encontrado",
|
||||
"cannotDeleteOnlyUser": "Você está tentando excluir o único usuário no banco de dados, isso não é permitido.",
|
||||
"requiresSuperAdminRole": "A função de Super Admin é necessária para gerenciar usuários."
|
||||
},
|
||||
"iam": {
|
||||
"failedToGetRoles": "Falha ao obter as funções.",
|
||||
"roleNotFound": "Função não encontrada.",
|
||||
"failedToGetRole": "Falha ao obter a função.",
|
||||
"missingRoleFields": "Campos obrigatórios ausentes: nome e política.",
|
||||
"invalidPolicy": "Declaração de política inválida:",
|
||||
"failedToCreateRole": "Falha ao criar a função.",
|
||||
"failedToDeleteRole": "Falha ao excluir a função.",
|
||||
"missingUpdateFields": "Campos ausentes para atualização: nome ou políticas.",
|
||||
"failedToUpdateRole": "Falha ao atualizar a função.",
|
||||
"requiresSuperAdminRole": "A função de Super Admin é necessária para gerenciar as funções."
|
||||
},
|
||||
"settings": {
|
||||
"failedToRetrieve": "Falha ao recuperar as configurações",
|
||||
"failedToUpdate": "Falha ao atualizar as configurações",
|
||||
"noPermissionToUpdate": "Você não tem permissão para atualizar as configurações do sistema."
|
||||
},
|
||||
"dashboard": {
|
||||
"permissionRequired": "Você precisa da permissão de leitura do painel para visualizar os dados do painel."
|
||||
},
|
||||
"ingestion": {
|
||||
"failedToCreate": "Falha ao criar a fonte de ingestão devido a um erro de conexão.",
|
||||
"notFound": "Fonte de ingestão não encontrada",
|
||||
"initialImportTriggered": "Importação inicial acionada com sucesso.",
|
||||
"forceSyncTriggered": "Sincronização forçada acionada com sucesso."
|
||||
},
|
||||
"archivedEmail": {
|
||||
"notFound": "E-mail arquivado não encontrado"
|
||||
},
|
||||
"search": {
|
||||
"keywordsRequired": "Palavras-chave são obrigatórias"
|
||||
},
|
||||
"storage": {
|
||||
"filePathRequired": "O caminho do arquivo é obrigatório",
|
||||
"invalidFilePath": "Caminho de arquivo inválido",
|
||||
"fileNotFound": "Arquivo não encontrado",
|
||||
"downloadError": "Erro ao baixar o arquivo"
|
||||
}
|
||||
}
|
||||
72
packages/backend/src/services/ApiKeyService.ts
Normal file
72
packages/backend/src/services/ApiKeyService.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { randomBytes, createHash } from 'crypto';
|
||||
import { db } from '../database';
|
||||
import { apiKeys } from '../database/schema/api-keys';
|
||||
import { CryptoService } from './CryptoService';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { ApiKey } from '@open-archiver/types';
|
||||
|
||||
export class ApiKeyService {
|
||||
public static async generate(
|
||||
userId: string,
|
||||
name: string,
|
||||
expiresInDays: number
|
||||
): Promise<string> {
|
||||
const key = randomBytes(32).toString('hex');
|
||||
const expiresAt = new Date();
|
||||
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,
|
||||
});
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
public static async getKeys(userId: string): Promise<ApiKey[]> {
|
||||
const keys = await db.select().from(apiKeys).where(eq(apiKeys.userId, userId));
|
||||
|
||||
return keys
|
||||
.map((apiKey) => {
|
||||
const decryptedKey = CryptoService.decrypt(apiKey.key);
|
||||
if (!decryptedKey) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...apiKey,
|
||||
key: decryptedKey.slice(0, 5) + '*****',
|
||||
expiresAt: apiKey.expiresAt.toISOString(),
|
||||
createdAt: apiKey.createdAt.toISOString(),
|
||||
};
|
||||
})
|
||||
.filter((k): k is NonNullable<typeof k> => k !== null);
|
||||
}
|
||||
|
||||
public static async deleteKey(id: string, userId: string) {
|
||||
await db.delete(apiKeys).where(and(eq(apiKeys.id, id), eq(apiKeys.userId, userId)));
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @param key API key
|
||||
* @returns The owner user ID or null. null means the API key is not found.
|
||||
*/
|
||||
public static async validateKey(key: string): Promise<string | null> {
|
||||
const keyHash = createHash('sha256').update(key).digest('hex');
|
||||
const [apiKey] = await db.select().from(apiKeys).where(eq(apiKeys.keyHash, keyHash));
|
||||
if (!apiKey || apiKey.expiresAt < new Date()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const decryptedKey = CryptoService.decrypt(apiKey.key);
|
||||
if (decryptedKey !== key) {
|
||||
// This should not happen if the hash matches, but as a security measure, we double-check.
|
||||
return null;
|
||||
}
|
||||
|
||||
return apiKey.userId;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
GenericImapCredentials,
|
||||
PSTImportCredentials,
|
||||
EMLImportCredentials,
|
||||
MboxImportCredentials,
|
||||
EmailObject,
|
||||
SyncState,
|
||||
MailboxUser,
|
||||
@@ -14,6 +15,7 @@ import { MicrosoftConnector } from './ingestion-connectors/MicrosoftConnector';
|
||||
import { ImapConnector } from './ingestion-connectors/ImapConnector';
|
||||
import { PSTConnector } from './ingestion-connectors/PSTConnector';
|
||||
import { EMLConnector } from './ingestion-connectors/EMLConnector';
|
||||
import { MboxConnector } from './ingestion-connectors/MboxConnector';
|
||||
|
||||
// Define a common interface for all connectors
|
||||
export interface IEmailConnector {
|
||||
@@ -43,6 +45,8 @@ export class EmailProviderFactory {
|
||||
return new PSTConnector(credentials as PSTImportCredentials);
|
||||
case 'eml_import':
|
||||
return new EMLConnector(credentials as EMLImportCredentials);
|
||||
case 'mbox_import':
|
||||
return new MboxConnector(credentials as MboxImportCredentials);
|
||||
default:
|
||||
throw new Error(`Unsupported provider: ${source.provider}`);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { Attachment, EmailAddress, EmailDocument, EmailObject } from '@open-archiver/types';
|
||||
import {
|
||||
Attachment,
|
||||
EmailAddress,
|
||||
EmailDocument,
|
||||
EmailObject,
|
||||
PendingEmail,
|
||||
} from '@open-archiver/types';
|
||||
import { SearchService } from './SearchService';
|
||||
import { StorageService } from './StorageService';
|
||||
import { extractText } from '../helpers/textExtractor';
|
||||
@@ -7,6 +13,7 @@ import { archivedEmails, attachments, emailAttachments } from '../database/schem
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { streamToBuffer } from '../helpers/streamToBuffer';
|
||||
import { simpleParser } from 'mailparser';
|
||||
import { logger } from '../config/logger';
|
||||
|
||||
interface DbRecipients {
|
||||
to: { name: string; address: string }[];
|
||||
@@ -20,14 +27,45 @@ type AttachmentsType = {
|
||||
mimeType: string;
|
||||
}[];
|
||||
|
||||
/**
|
||||
* Sanitizes text content by removing invalid characters that could cause JSON serialization issues
|
||||
*/
|
||||
function sanitizeText(text: string): string {
|
||||
if (!text) return '';
|
||||
|
||||
// Remove control characters and invalid UTF-8 sequences
|
||||
return text
|
||||
.replace(/\uFFFD/g, '') // Replacement character for invalid UTF-8 sequences
|
||||
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '') // Remove control characters
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively sanitize all string values in an object to prevent JSON issues
|
||||
*/
|
||||
function sanitizeObject<T>(obj: T): T {
|
||||
if (typeof obj === 'string') {
|
||||
return sanitizeText(obj) as unknown as T;
|
||||
} else if (Array.isArray(obj)) {
|
||||
return obj.map(sanitizeObject) as unknown as T;
|
||||
} else if (obj !== null && typeof obj === 'object') {
|
||||
const sanitized: any = {};
|
||||
for (const key in obj) {
|
||||
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
||||
sanitized[key] = sanitizeObject((obj as any)[key]);
|
||||
}
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
|
||||
export class IndexingService {
|
||||
private dbService: DatabaseService;
|
||||
private searchService: SearchService;
|
||||
private storageService: StorageService;
|
||||
|
||||
/**
|
||||
* Initializes the service with its dependencies.
|
||||
*/
|
||||
constructor(
|
||||
dbService: DatabaseService,
|
||||
searchService: SearchService,
|
||||
@@ -39,9 +77,129 @@ export class IndexingService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches an email by its ID from the database, creates a search document, and indexes it.
|
||||
* Index multiple emails in a single batch operation for better performance
|
||||
*/
|
||||
public async indexEmailById(emailId: string): Promise<void> {
|
||||
public async indexEmailBatch(emails: PendingEmail[]): Promise<void> {
|
||||
if (emails.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info({ batchSize: emails.length }, 'Starting batch indexing of emails');
|
||||
|
||||
try {
|
||||
const CONCURRENCY_LIMIT = 10;
|
||||
const rawDocuments: EmailDocument[] = [];
|
||||
|
||||
for (let i = 0; i < emails.length; i += CONCURRENCY_LIMIT) {
|
||||
const batch = emails.slice(i, i + CONCURRENCY_LIMIT);
|
||||
|
||||
const batchDocuments = await Promise.allSettled(
|
||||
batch.map(async ({ email, sourceId, archivedId }) => {
|
||||
try {
|
||||
return await this.createEmailDocumentFromRawForBatch(
|
||||
email,
|
||||
sourceId,
|
||||
archivedId,
|
||||
email.userEmail || ''
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{
|
||||
emailId: archivedId,
|
||||
sourceId,
|
||||
userEmail: email.userEmail || '',
|
||||
rawEmailData: JSON.stringify(email, null, 2),
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
'Failed to create document for email in batch'
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
for (const result of batchDocuments) {
|
||||
if (result.status === 'fulfilled') {
|
||||
rawDocuments.push(result.value);
|
||||
} else {
|
||||
logger.error({ error: result.reason }, 'Failed to process email in batch');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (rawDocuments.length === 0) {
|
||||
logger.warn('No documents created from email batch');
|
||||
return;
|
||||
}
|
||||
|
||||
// Sanitize all documents
|
||||
const sanitizedDocuments = rawDocuments.map((doc) => sanitizeObject(doc));
|
||||
|
||||
// Ensure all required fields are present
|
||||
const completeDocuments = sanitizedDocuments.map((doc) =>
|
||||
this.ensureEmailDocumentFields(doc)
|
||||
);
|
||||
|
||||
// Validate each document and separate valid from invalid ones
|
||||
const validDocuments: EmailDocument[] = [];
|
||||
const invalidDocuments: { doc: any; reason: string }[] = [];
|
||||
|
||||
for (const doc of completeDocuments) {
|
||||
if (this.isValidEmailDocument(doc)) {
|
||||
validDocuments.push(doc);
|
||||
} else {
|
||||
invalidDocuments.push({ doc, reason: 'JSON.stringify failed' });
|
||||
logger.warn({ document: doc }, 'Skipping invalid EmailDocument');
|
||||
}
|
||||
}
|
||||
|
||||
// Log detailed information for invalid documents
|
||||
if (invalidDocuments.length > 0) {
|
||||
for (const { doc } of invalidDocuments) {
|
||||
logger.error(
|
||||
{
|
||||
emailId: doc.id,
|
||||
document: JSON.stringify(doc, null, 2),
|
||||
},
|
||||
'Invalid EmailDocument details'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (validDocuments.length === 0) {
|
||||
logger.warn('No valid documents to index in batch.');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug({ documentCount: validDocuments.length }, 'Sending batch to Meilisearch');
|
||||
|
||||
await this.searchService.addDocuments('emails', validDocuments, 'id');
|
||||
|
||||
logger.info(
|
||||
{
|
||||
batchSize: emails.length,
|
||||
successfulDocuments: validDocuments.length,
|
||||
failedDocuments: emails.length - validDocuments.length,
|
||||
invalidDocuments: invalidDocuments.length,
|
||||
},
|
||||
'Successfully indexed email batch'
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{
|
||||
batchSize: emails.length,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
'Failed to index email batch'
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
private async indexEmailById(emailId: string): Promise<void> {
|
||||
const email = await this.dbService.db.query.archivedEmails.findFirst({
|
||||
where: eq(archivedEmails.id, emailId),
|
||||
});
|
||||
@@ -75,16 +233,14 @@ export class IndexingService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Indexes an email object directly, creates a search document, and indexes it.
|
||||
* @deprecated
|
||||
*/
|
||||
public async indexByEmail(
|
||||
email: EmailObject,
|
||||
ingestionSourceId: string,
|
||||
archivedEmailId: string
|
||||
private async indexByEmail(
|
||||
pendingEmail: PendingEmail
|
||||
): Promise<void> {
|
||||
const attachments: AttachmentsType = [];
|
||||
if (email.attachments && email.attachments.length > 0) {
|
||||
for (const attachment of email.attachments) {
|
||||
if (pendingEmail.email.attachments && pendingEmail.email.attachments.length > 0) {
|
||||
for (const attachment of pendingEmail.email.attachments) {
|
||||
attachments.push({
|
||||
buffer: attachment.content,
|
||||
filename: attachment.filename,
|
||||
@@ -93,19 +249,96 @@ export class IndexingService {
|
||||
}
|
||||
}
|
||||
const document = await this.createEmailDocumentFromRaw(
|
||||
email,
|
||||
pendingEmail.email,
|
||||
attachments,
|
||||
ingestionSourceId,
|
||||
archivedEmailId,
|
||||
email.userEmail || ''
|
||||
pendingEmail.sourceId,
|
||||
pendingEmail.archivedId,
|
||||
pendingEmail.email.userEmail || ''
|
||||
);
|
||||
console.log(document);
|
||||
// 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(
|
||||
email: EmailObject,
|
||||
ingestionSourceId: string,
|
||||
archivedEmailId: string,
|
||||
userEmail: string
|
||||
): Promise<EmailDocument> {
|
||||
const extractedAttachments: { filename: string; content: string }[] = [];
|
||||
|
||||
if (email.attachments && email.attachments.length > 0) {
|
||||
const ATTACHMENT_CONCURRENCY = 3;
|
||||
|
||||
for (let i = 0; i < email.attachments.length; i += ATTACHMENT_CONCURRENCY) {
|
||||
const attachmentBatch = email.attachments.slice(i, i + ATTACHMENT_CONCURRENCY);
|
||||
|
||||
const attachmentResults = await Promise.allSettled(
|
||||
attachmentBatch.map(async (attachment) => {
|
||||
try {
|
||||
if (!this.shouldExtractText(attachment.contentType)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const textContent = await extractText(
|
||||
attachment.content,
|
||||
attachment.contentType || ''
|
||||
);
|
||||
|
||||
return {
|
||||
filename: attachment.filename,
|
||||
content: textContent || '',
|
||||
};
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
{
|
||||
filename: attachment.filename,
|
||||
mimeType: attachment.contentType,
|
||||
emailId: archivedEmailId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
'Failed to extract text from attachment'
|
||||
);
|
||||
return null;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
for (const result of attachmentResults) {
|
||||
if (result.status === 'fulfilled' && result.value) {
|
||||
extractedAttachments.push(result.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const allAttachmentText = extractedAttachments
|
||||
.map((att) => sanitizeText(att.content))
|
||||
.join(' ');
|
||||
|
||||
const enhancedBody = [sanitizeText(email.body || email.html || ''), allAttachmentText]
|
||||
.filter(Boolean)
|
||||
.join('\n\n--- Attachments ---\n\n');
|
||||
|
||||
return {
|
||||
id: archivedEmailId,
|
||||
userEmail: userEmail,
|
||||
from: email.from[0]?.address || '',
|
||||
to: email.to?.map((addr: EmailAddress) => addr.address) || [],
|
||||
cc: email.cc?.map((addr: EmailAddress) => addr.address) || [],
|
||||
bcc: email.bcc?.map((addr: EmailAddress) => addr.address) || [],
|
||||
subject: email.subject || '',
|
||||
body: enhancedBody,
|
||||
attachments: extractedAttachments,
|
||||
timestamp: new Date(email.receivedAt).getTime(),
|
||||
ingestionSourceId: ingestionSourceId,
|
||||
};
|
||||
}
|
||||
|
||||
private async createEmailDocumentFromRaw(
|
||||
email: EmailObject,
|
||||
attachments: AttachmentsType,
|
||||
@@ -126,10 +359,9 @@ export class IndexingService {
|
||||
`Failed to extract text from attachment: ${attachment.filename}`,
|
||||
error
|
||||
);
|
||||
// skip attachment or fail the job
|
||||
}
|
||||
}
|
||||
console.log('email.userEmail', userEmail);
|
||||
// console.log('email.userEmail', userEmail);
|
||||
return {
|
||||
id: archivedEmailId,
|
||||
userEmail: userEmail,
|
||||
@@ -145,9 +377,6 @@ export class IndexingService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a search document from a database email record and its attachments.
|
||||
*/
|
||||
private async createEmailDocument(
|
||||
email: typeof archivedEmails.$inferSelect,
|
||||
attachments: Attachment[],
|
||||
@@ -165,7 +394,7 @@ export class IndexingService {
|
||||
'';
|
||||
|
||||
const recipients = email.recipients as DbRecipients;
|
||||
console.log('email.userEmail', email.userEmail);
|
||||
// console.log('email.userEmail', email.userEmail);
|
||||
return {
|
||||
id: email.id,
|
||||
userEmail: userEmail,
|
||||
@@ -181,9 +410,6 @@ export class IndexingService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts text content from a list of attachments.
|
||||
*/
|
||||
private async extractAttachmentContents(
|
||||
attachments: Attachment[]
|
||||
): Promise<{ filename: string; content: string }[]> {
|
||||
@@ -202,9 +428,90 @@ export class IndexingService {
|
||||
`Failed to extract text from attachment: ${attachment.filename}`,
|
||||
error
|
||||
);
|
||||
// skip attachment or fail the job
|
||||
}
|
||||
}
|
||||
return extractedAttachments;
|
||||
}
|
||||
|
||||
private shouldExtractText(mimeType: string): boolean {
|
||||
if (process.env.TIKA_URL) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!mimeType) return false;
|
||||
// Tika supported mime types: https://tika.apache.org/2.4.1/formats.html
|
||||
const extractableTypes = [
|
||||
'application/pdf',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/vnd.ms-powerpoint',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
'text/plain',
|
||||
'text/html',
|
||||
'application/rss+xml',
|
||||
'application/xml',
|
||||
'application/json',
|
||||
'text/rtf',
|
||||
'application/rtf',
|
||||
'text/csv',
|
||||
'text/tsv',
|
||||
'application/csv',
|
||||
'image/bpg',
|
||||
'image/png',
|
||||
'image/vnd.wap.wbmp',
|
||||
'image/x-jbig2',
|
||||
'image/bmp',
|
||||
'image/x-xcf',
|
||||
'image/gif',
|
||||
'image/x-icon',
|
||||
'image/jpeg',
|
||||
'image/x-ms-bmp',
|
||||
'image/webp',
|
||||
'image/tiff',
|
||||
'image/svg+xml',
|
||||
'application/vnd.apple.pages',
|
||||
'application/vnd.apple.numbers',
|
||||
'application/vnd.apple.keynote',
|
||||
'image/heic',
|
||||
'image/heif',
|
||||
];
|
||||
|
||||
|
||||
|
||||
return extractableTypes.some((type) => mimeType.toLowerCase().includes(type));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures all required fields are present in EmailDocument
|
||||
*/
|
||||
private ensureEmailDocumentFields(doc: Partial<EmailDocument>): EmailDocument {
|
||||
return {
|
||||
id: doc.id || 'missing-id',
|
||||
userEmail: doc.userEmail || 'unknown',
|
||||
from: doc.from || '',
|
||||
to: Array.isArray(doc.to) ? doc.to : [],
|
||||
cc: Array.isArray(doc.cc) ? doc.cc : [],
|
||||
bcc: Array.isArray(doc.bcc) ? doc.bcc : [],
|
||||
subject: doc.subject || '',
|
||||
body: doc.body || '',
|
||||
attachments: Array.isArray(doc.attachments) ? doc.attachments : [],
|
||||
timestamp: typeof doc.timestamp === 'number' ? doc.timestamp : Date.now(),
|
||||
ingestionSourceId: doc.ingestionSourceId || 'unknown',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if the given object is a valid EmailDocument that can be serialized to JSON
|
||||
*/
|
||||
private isValidEmailDocument(doc: any): boolean {
|
||||
try {
|
||||
JSON.stringify(doc);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error({ doc, error: (error as Error).message }, 'Invalid EmailDocument detected');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
IngestionSource,
|
||||
IngestionCredentials,
|
||||
IngestionProvider,
|
||||
PendingEmail,
|
||||
} from '@open-archiver/types';
|
||||
import { and, desc, eq } from 'drizzle-orm';
|
||||
import { CryptoService } from './CryptoService';
|
||||
@@ -26,6 +27,7 @@ import { SearchService } from './SearchService';
|
||||
import { DatabaseService } from './DatabaseService';
|
||||
import { config } from '../config/index';
|
||||
import { FilterBuilder } from './FilterBuilder';
|
||||
import e from 'express';
|
||||
|
||||
export class IngestionService {
|
||||
private static decryptSource(
|
||||
@@ -47,7 +49,7 @@ export class IngestionService {
|
||||
}
|
||||
|
||||
public static returnFileBasedIngestions(): IngestionProvider[] {
|
||||
return ['pst_import', 'eml_import'];
|
||||
return ['pst_import', 'eml_import', 'mbox_import'];
|
||||
}
|
||||
|
||||
public static async create(
|
||||
@@ -76,9 +78,13 @@ export class IngestionService {
|
||||
const connector = EmailProviderFactory.createConnector(decryptedSource);
|
||||
|
||||
try {
|
||||
await connector.testConnection();
|
||||
const connectionValid = await connector.testConnection();
|
||||
// If connection succeeds, update status to auth_success, which triggers the initial import.
|
||||
return await this.update(decryptedSource.id, { status: 'auth_success' });
|
||||
if (connectionValid) {
|
||||
return await this.update(decryptedSource.id, { status: 'auth_success' });
|
||||
} else {
|
||||
throw Error('Ingestion authentication failed.')
|
||||
}
|
||||
} catch (error) {
|
||||
// If connection fails, delete the newly created source and throw the error.
|
||||
await this.delete(decryptedSource.id);
|
||||
@@ -297,7 +303,7 @@ export class IngestionService {
|
||||
source: IngestionSource,
|
||||
storage: StorageService,
|
||||
userEmail: string
|
||||
): Promise<void> {
|
||||
): Promise<PendingEmail | null> {
|
||||
try {
|
||||
// Generate a unique message ID for the email. If the email already has a message-id header, use that.
|
||||
// Otherwise, generate a new one based on the email's hash, source ID, and email ID.
|
||||
@@ -326,7 +332,7 @@ export class IngestionService {
|
||||
{ messageId, ingestionSourceId: source.id },
|
||||
'Skipping duplicate email'
|
||||
);
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
const emlBuffer = email.eml ?? Buffer.from(email.body, 'utf-8');
|
||||
@@ -393,23 +399,14 @@ export class IngestionService {
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
}
|
||||
// adding to indexing queue
|
||||
//Instead: index by email (raw email object, ingestion id)
|
||||
logger.info({ emailId: archivedEmail.id }, 'Indexing email');
|
||||
// await indexingQueue.add('index-email', {
|
||||
// emailId: archivedEmail.id,
|
||||
// });
|
||||
const searchService = new SearchService();
|
||||
const storageService = new StorageService();
|
||||
const databaseService = new DatabaseService();
|
||||
const indexingService = new IndexingService(
|
||||
databaseService,
|
||||
searchService,
|
||||
storageService
|
||||
);
|
||||
//assign userEmail
|
||||
|
||||
email.userEmail = userEmail;
|
||||
await indexingService.indexByEmail(email, source.id, archivedEmail.id);
|
||||
|
||||
return {
|
||||
email,
|
||||
sourceId: source.id,
|
||||
archivedId: archivedEmail.id,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error({
|
||||
message: `Failed to process email ${email.id} for source ${source.id}`,
|
||||
@@ -417,6 +414,7 @@ export class IngestionService {
|
||||
emailId: email.id,
|
||||
ingestionSourceId: source.id,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
271
packages/backend/src/services/OcrService.ts
Normal file
271
packages/backend/src/services/OcrService.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
import crypto from 'crypto';
|
||||
import { logger } from '../config/logger';
|
||||
|
||||
// Simple LRU cache for Tika results with statistics
|
||||
class TikaCache {
|
||||
private cache = new Map<string, string>();
|
||||
private maxSize = 50;
|
||||
private hits = 0;
|
||||
private misses = 0;
|
||||
|
||||
get(key: string): string | undefined {
|
||||
const value = this.cache.get(key);
|
||||
if (value !== undefined) {
|
||||
this.hits++;
|
||||
// LRU: Move element to the end
|
||||
this.cache.delete(key);
|
||||
this.cache.set(key, value);
|
||||
} else {
|
||||
this.misses++;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
set(key: string, value: string): void {
|
||||
// If already exists, delete first
|
||||
if (this.cache.has(key)) {
|
||||
this.cache.delete(key);
|
||||
}
|
||||
// If cache is full, remove oldest element
|
||||
else if (this.cache.size >= this.maxSize) {
|
||||
const firstKey = this.cache.keys().next().value;
|
||||
if (firstKey !== undefined) {
|
||||
this.cache.delete(firstKey);
|
||||
}
|
||||
}
|
||||
|
||||
this.cache.set(key, value);
|
||||
}
|
||||
|
||||
getStats(): { size: number; maxSize: number; hits: number; misses: number; hitRate: number } {
|
||||
const total = this.hits + this.misses;
|
||||
const hitRate = total > 0 ? (this.hits / total) * 100 : 0;
|
||||
return {
|
||||
size: this.cache.size,
|
||||
maxSize: this.maxSize,
|
||||
hits: this.hits,
|
||||
misses: this.misses,
|
||||
hitRate: Math.round(hitRate * 100) / 100 // 2 decimal places
|
||||
};
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.cache.clear();
|
||||
this.hits = 0;
|
||||
this.misses = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Semaphore for running Tika requests
|
||||
class TikaSemaphore {
|
||||
private inProgress = new Map<string, Promise<string>>();
|
||||
private waitCount = 0;
|
||||
|
||||
async acquire(key: string, operation: () => Promise<string>): Promise<string> {
|
||||
// Check if a request for this key is already running
|
||||
const existingPromise = this.inProgress.get(key);
|
||||
if (existingPromise) {
|
||||
this.waitCount++;
|
||||
logger.debug(`Waiting for in-progress Tika request (${key.slice(0, 8)}...)`);
|
||||
try {
|
||||
return await existingPromise;
|
||||
} finally {
|
||||
this.waitCount--;
|
||||
}
|
||||
}
|
||||
|
||||
// Start new request
|
||||
const promise = this.executeOperation(key, operation);
|
||||
this.inProgress.set(key, promise);
|
||||
|
||||
try {
|
||||
return await promise;
|
||||
} finally {
|
||||
// Remove promise from map when finished
|
||||
this.inProgress.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
private async executeOperation(key: string, operation: () => Promise<string>): Promise<string> {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
// Remove promise from map even on errors
|
||||
logger.error(`Tika operation failed for key ${key.slice(0, 8)}...`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
getStats(): { inProgress: number; waitCount: number } {
|
||||
return {
|
||||
inProgress: this.inProgress.size,
|
||||
waitCount: this.waitCount
|
||||
};
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.inProgress.clear();
|
||||
this.waitCount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
export class OcrService {
|
||||
private tikaCache = new TikaCache();
|
||||
private tikaSemaphore = new TikaSemaphore();
|
||||
|
||||
// Tika-based text extraction with cache and semaphore
|
||||
async extractTextWithTika(buffer: Buffer, mimeType: string): Promise<string> {
|
||||
const tikaUrl = process.env.TIKA_URL;
|
||||
if (!tikaUrl) {
|
||||
throw new Error('TIKA_URL environment variable not set');
|
||||
}
|
||||
|
||||
// Cache key: SHA-256 hash of the buffer
|
||||
const hash = crypto.createHash('sha256').update(buffer).digest('hex');
|
||||
|
||||
// Cache lookup (before semaphore!)
|
||||
const cachedResult = this.tikaCache.get(hash);
|
||||
if (cachedResult !== undefined) {
|
||||
logger.debug(`Tika cache hit for ${mimeType} (${buffer.length} bytes)`);
|
||||
return cachedResult;
|
||||
}
|
||||
|
||||
// Use semaphore to deduplicate parallel requests
|
||||
return await this.tikaSemaphore.acquire(hash, async () => {
|
||||
// Check cache again (might have been filled by parallel request)
|
||||
const cachedAfterWait = this.tikaCache.get(hash);
|
||||
if (cachedAfterWait !== undefined) {
|
||||
logger.debug(`Tika cache hit after wait for ${mimeType} (${buffer.length} bytes)`);
|
||||
return cachedAfterWait;
|
||||
}
|
||||
|
||||
logger.debug(`Executing Tika request for ${mimeType} (${buffer.length} bytes)`);
|
||||
|
||||
// DNS fallback: If "tika" hostname, also try localhost
|
||||
const urlsToTry = [
|
||||
`${tikaUrl}/tika`,
|
||||
// Fallback falls DNS-Problem mit "tika" hostname
|
||||
...(tikaUrl.includes('://tika:')
|
||||
? [`${tikaUrl.replace('://tika:', '://localhost:')}/tika`]
|
||||
: [])
|
||||
];
|
||||
|
||||
for (const url of urlsToTry) {
|
||||
try {
|
||||
logger.debug(`Trying Tika URL: ${url}`);
|
||||
const response = await fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': mimeType || 'application/octet-stream',
|
||||
Accept: 'text/plain',
|
||||
Connection: 'close'
|
||||
},
|
||||
body: buffer,
|
||||
signal: AbortSignal.timeout(180000)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
logger.warn(
|
||||
`Tika extraction failed at ${url}: ${response.status} ${response.statusText}`
|
||||
);
|
||||
continue; // Try next URL
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
const result = text.trim();
|
||||
|
||||
// Cache result (also empty strings to avoid repeated attempts)
|
||||
this.tikaCache.set(hash, result);
|
||||
|
||||
const cacheStats = this.tikaCache.getStats();
|
||||
const semaphoreStats = this.tikaSemaphore.getStats();
|
||||
logger.debug(
|
||||
`Tika extraction successful - Cache: ${cacheStats.hits}H/${cacheStats.misses}M (${cacheStats.hitRate}%) - Semaphore: ${semaphoreStats.inProgress} active, ${semaphoreStats.waitCount} waiting`
|
||||
);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`Tika extraction error at ${url}:`,
|
||||
error instanceof Error ? error.message : 'Unknown error'
|
||||
);
|
||||
// Continue to next URL
|
||||
}
|
||||
}
|
||||
|
||||
// All URLs failed - cache this too (as empty string)
|
||||
logger.error('All Tika URLs failed');
|
||||
this.tikaCache.set(hash, '');
|
||||
return '';
|
||||
});
|
||||
}
|
||||
|
||||
// Helper function to check Tika availability
|
||||
async checkTikaAvailability(): Promise<boolean> {
|
||||
const tikaUrl = process.env.TIKA_URL;
|
||||
if (!tikaUrl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${tikaUrl}/version`, {
|
||||
method: 'GET',
|
||||
signal: AbortSignal.timeout(5000) // 5 seconds timeout
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const version = await response.text();
|
||||
logger.info(`Tika server available, version: ${version.trim()}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
'Tika server not available:',
|
||||
error instanceof Error ? error.message : 'Unknown error'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Optional: Tika health check on startup
|
||||
async initializeTextExtractor(): Promise<void> {
|
||||
const tikaUrl = process.env.TIKA_URL;
|
||||
|
||||
if (tikaUrl) {
|
||||
const isAvailable = await this.checkTikaAvailability();
|
||||
if (!isAvailable) {
|
||||
logger.error(`Tika server configured but not available at: ${tikaUrl}`);
|
||||
logger.error('Text extraction will fall back to legacy methods or fail');
|
||||
}
|
||||
} else {
|
||||
logger.info('Using legacy text extraction methods (pdf2json, mammoth, xlsx)');
|
||||
logger.info('Set TIKA_URL environment variable to use Apache Tika for better extraction');
|
||||
}
|
||||
}
|
||||
|
||||
// Get cache statistics
|
||||
getTikaCacheStats(): {
|
||||
size: number;
|
||||
maxSize: number;
|
||||
hits: number;
|
||||
misses: number;
|
||||
hitRate: number;
|
||||
} {
|
||||
return this.tikaCache.getStats();
|
||||
}
|
||||
|
||||
// Get semaphore statistics
|
||||
getTikaSemaphoreStats(): { inProgress: number; waitCount: number } {
|
||||
return this.tikaSemaphore.getStats();
|
||||
}
|
||||
|
||||
// Clear cache (e.g. for tests or manual reset)
|
||||
clearTikaCache(): void {
|
||||
this.tikaCache.reset();
|
||||
this.tikaSemaphore.clear();
|
||||
logger.info('Tika cache and semaphore cleared');
|
||||
}
|
||||
}
|
||||
|
||||
55
packages/backend/src/services/SettingsService.ts
Normal file
55
packages/backend/src/services/SettingsService.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { db } from '../database';
|
||||
import { systemSettings } from '../database/schema/system-settings';
|
||||
import type { SystemSettings } from '@open-archiver/types';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
const DEFAULT_SETTINGS: SystemSettings = {
|
||||
language: 'en',
|
||||
theme: 'system',
|
||||
supportEmail: null,
|
||||
};
|
||||
|
||||
export class SettingsService {
|
||||
/**
|
||||
* Retrieves the current system settings.
|
||||
* If no settings exist, it initializes and returns the default settings.
|
||||
* @returns The system settings.
|
||||
*/
|
||||
public async getSystemSettings(): Promise<SystemSettings> {
|
||||
const settings = await db.select().from(systemSettings).limit(1);
|
||||
|
||||
if (settings.length === 0) {
|
||||
return this.createDefaultSystemSettings();
|
||||
}
|
||||
|
||||
return settings[0].config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the system settings by merging the new configuration with the existing one.
|
||||
* @param newConfig - A partial object of the new settings configuration.
|
||||
* @returns The updated system settings.
|
||||
*/
|
||||
public async updateSystemSettings(newConfig: Partial<SystemSettings>): Promise<SystemSettings> {
|
||||
const currentConfig = await this.getSystemSettings();
|
||||
const mergedConfig = { ...currentConfig, ...newConfig };
|
||||
|
||||
// Since getSettings ensures a record always exists, we can directly update.
|
||||
const [result] = await db.update(systemSettings).set({ config: mergedConfig }).returning();
|
||||
|
||||
return result.config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates and saves the default system settings.
|
||||
* This is called internally when no settings are found.
|
||||
* @returns The newly created default settings.
|
||||
*/
|
||||
private async createDefaultSystemSettings(): Promise<SystemSettings> {
|
||||
const [result] = await db
|
||||
.insert(systemSettings)
|
||||
.values({ config: DEFAULT_SETTINGS })
|
||||
.returning();
|
||||
return result.config;
|
||||
}
|
||||
}
|
||||
@@ -69,7 +69,7 @@ export class EMLConnector implements IEmailConnector {
|
||||
syncState?: SyncState | null
|
||||
): AsyncGenerator<EmailObject | null> {
|
||||
const fileStream = await this.storage.get(this.credentials.uploadedFilePath);
|
||||
const tempDir = await fs.mkdtemp(join('/tmp', 'eml-import-'));
|
||||
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');
|
||||
@@ -115,6 +115,14 @@ export class EMLConnector implements IEmailConnector {
|
||||
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.'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,10 @@ export class ImapConnector implements IEmailConnector {
|
||||
host: this.credentials.host,
|
||||
port: this.credentials.port,
|
||||
secure: this.credentials.secure,
|
||||
tls: {
|
||||
rejectUnauthorized: this.credentials.allowInsecureCert,
|
||||
requestCert: true,
|
||||
},
|
||||
auth: {
|
||||
user: this.credentials.username,
|
||||
pass: this.credentials.password,
|
||||
@@ -145,107 +149,112 @@ export class ImapConnector implements IEmailConnector {
|
||||
userEmail: string,
|
||||
syncState?: SyncState | null
|
||||
): AsyncGenerator<EmailObject | null> {
|
||||
// list all mailboxes first
|
||||
const mailboxes = await this.withRetry(async () => await this.client.list());
|
||||
await this.disconnect();
|
||||
try {
|
||||
// list all mailboxes first
|
||||
const mailboxes = await this.withRetry(async () => await this.client.list());
|
||||
|
||||
const processableMailboxes = mailboxes.filter((mailbox) => {
|
||||
// filter out trash and all mail emails
|
||||
if (mailbox.specialUse) {
|
||||
const specialUse = mailbox.specialUse.toLowerCase();
|
||||
if (specialUse === '\\junk' || specialUse === '\\trash' || specialUse === '\\all') {
|
||||
const processableMailboxes = mailboxes.filter((mailbox) => {
|
||||
// filter out trash and all mail emails
|
||||
if (mailbox.specialUse) {
|
||||
const specialUse = mailbox.specialUse.toLowerCase();
|
||||
if (
|
||||
specialUse === '\\junk' ||
|
||||
specialUse === '\\trash' ||
|
||||
specialUse === '\\all'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Fallback to checking flags
|
||||
if (
|
||||
mailbox.flags.has('\\Noselect') ||
|
||||
mailbox.flags.has('\\Trash') ||
|
||||
mailbox.flags.has('\\Junk') ||
|
||||
mailbox.flags.has('\\All')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Fallback to checking flags
|
||||
if (
|
||||
mailbox.flags.has('\\Noselect') ||
|
||||
mailbox.flags.has('\\Trash') ||
|
||||
mailbox.flags.has('\\Junk') ||
|
||||
mailbox.flags.has('\\All')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
return true;
|
||||
});
|
||||
|
||||
for (const mailboxInfo of processableMailboxes) {
|
||||
const mailboxPath = mailboxInfo.path;
|
||||
logger.info({ mailboxPath }, 'Processing mailbox');
|
||||
for (const mailboxInfo of processableMailboxes) {
|
||||
const mailboxPath = mailboxInfo.path;
|
||||
logger.info({ mailboxPath }, 'Processing mailbox');
|
||||
|
||||
try {
|
||||
const mailbox = await this.withRetry(
|
||||
async () => await this.client.mailboxOpen(mailboxPath)
|
||||
);
|
||||
const lastUid = syncState?.imap?.[mailboxPath]?.maxUid;
|
||||
let currentMaxUid = lastUid || 0;
|
||||
try {
|
||||
const mailbox = await this.withRetry(
|
||||
async () => await this.client.mailboxOpen(mailboxPath)
|
||||
);
|
||||
const lastUid = syncState?.imap?.[mailboxPath]?.maxUid;
|
||||
let currentMaxUid = lastUid || 0;
|
||||
|
||||
if (mailbox.exists > 0) {
|
||||
const lastMessage = await this.client.fetchOne(String(mailbox.exists), {
|
||||
uid: true,
|
||||
});
|
||||
if (lastMessage && lastMessage.uid > currentMaxUid) {
|
||||
currentMaxUid = lastMessage.uid;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize with last synced UID, not the maximum UID in mailbox
|
||||
this.newMaxUids[mailboxPath] = lastUid || 0;
|
||||
|
||||
// Only fetch if the mailbox has messages, to avoid errors on empty mailboxes with some IMAP servers.
|
||||
if (mailbox.exists > 0) {
|
||||
const BATCH_SIZE = 250; // A configurable batch size
|
||||
let startUid = (lastUid || 0) + 1;
|
||||
const maxUidToFetch = currentMaxUid;
|
||||
|
||||
while (startUid <= maxUidToFetch) {
|
||||
const endUid = Math.min(startUid + BATCH_SIZE - 1, maxUidToFetch);
|
||||
const searchCriteria = { uid: `${startUid}:${endUid}` };
|
||||
|
||||
for await (const msg of this.client.fetch(searchCriteria, {
|
||||
envelope: true,
|
||||
source: true,
|
||||
bodyStructure: true,
|
||||
if (mailbox.exists > 0) {
|
||||
const lastMessage = await this.client.fetchOne(String(mailbox.exists), {
|
||||
uid: true,
|
||||
})) {
|
||||
if (lastUid && msg.uid <= lastUid) {
|
||||
continue;
|
||||
}
|
||||
});
|
||||
if (lastMessage && lastMessage.uid > currentMaxUid) {
|
||||
currentMaxUid = lastMessage.uid;
|
||||
}
|
||||
}
|
||||
|
||||
if (msg.uid > this.newMaxUids[mailboxPath]) {
|
||||
this.newMaxUids[mailboxPath] = msg.uid;
|
||||
}
|
||||
// Initialize with last synced UID, not the maximum UID in mailbox
|
||||
this.newMaxUids[mailboxPath] = lastUid || 0;
|
||||
|
||||
logger.debug({ mailboxPath, uid: msg.uid }, 'Processing message');
|
||||
// Only fetch if the mailbox has messages, to avoid errors on empty mailboxes with some IMAP servers.
|
||||
if (mailbox.exists > 0) {
|
||||
const BATCH_SIZE = 250; // A configurable batch size
|
||||
let startUid = (lastUid || 0) + 1;
|
||||
const maxUidToFetch = currentMaxUid;
|
||||
|
||||
if (msg.envelope && msg.source) {
|
||||
try {
|
||||
yield await this.parseMessage(msg, mailboxPath);
|
||||
} catch (err: any) {
|
||||
logger.error(
|
||||
{ err, mailboxPath, uid: msg.uid },
|
||||
'Failed to parse message'
|
||||
);
|
||||
throw err;
|
||||
while (startUid <= maxUidToFetch) {
|
||||
const endUid = Math.min(startUid + BATCH_SIZE - 1, maxUidToFetch);
|
||||
const searchCriteria = { uid: `${startUid}:${endUid}` };
|
||||
|
||||
for await (const msg of this.client.fetch(searchCriteria, {
|
||||
envelope: true,
|
||||
source: true,
|
||||
bodyStructure: true,
|
||||
uid: true,
|
||||
})) {
|
||||
if (lastUid && msg.uid <= lastUid) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (msg.uid > this.newMaxUids[mailboxPath]) {
|
||||
this.newMaxUids[mailboxPath] = msg.uid;
|
||||
}
|
||||
|
||||
logger.debug({ mailboxPath, uid: msg.uid }, 'Processing message');
|
||||
|
||||
if (msg.envelope && msg.source) {
|
||||
try {
|
||||
yield await this.parseMessage(msg, mailboxPath);
|
||||
} catch (err: any) {
|
||||
logger.error(
|
||||
{ err, mailboxPath, uid: msg.uid },
|
||||
'Failed to parse message'
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Move to the next batch
|
||||
startUid = endUid + 1;
|
||||
// Move to the next batch
|
||||
startUid = endUid + 1;
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
logger.error({ err, mailboxPath }, 'Failed to process mailbox');
|
||||
// Check if the error indicates a persistent failure after retries
|
||||
if (err.message.includes('IMAP operation failed after all retries')) {
|
||||
this.statusMessage =
|
||||
'Sync paused due to reaching the mail server rate limit. The process will automatically resume later.';
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
logger.error({ err, mailboxPath }, 'Failed to process mailbox');
|
||||
// Check if the error indicates a persistent failure after retries
|
||||
if (err.message.includes('IMAP operation failed after all retries')) {
|
||||
this.statusMessage =
|
||||
'Sync paused due to reaching the mail server rate limit. The process will automatically resume later.';
|
||||
}
|
||||
} finally {
|
||||
await this.disconnect();
|
||||
}
|
||||
} finally {
|
||||
await this.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import type {
|
||||
MboxImportCredentials,
|
||||
EmailObject,
|
||||
EmailAddress,
|
||||
SyncState,
|
||||
MailboxUser,
|
||||
} from '@open-archiver/types';
|
||||
import type { IEmailConnector } from '../EmailProviderFactory';
|
||||
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 { createHash } from 'crypto';
|
||||
import { streamToBuffer } from '../../helpers/streamToBuffer';
|
||||
|
||||
export class MboxConnector implements IEmailConnector {
|
||||
private storage: StorageService;
|
||||
|
||||
constructor(private credentials: MboxImportCredentials) {
|
||||
this.storage = new StorageService();
|
||||
}
|
||||
|
||||
public async testConnection(): Promise<boolean> {
|
||||
try {
|
||||
if (!this.credentials.uploadedFilePath) {
|
||||
throw Error('Mbox file path not provided.');
|
||||
}
|
||||
if (!this.credentials.uploadedFilePath.includes('.mbox')) {
|
||||
throw Error('Provided file is not in the MBOX format.');
|
||||
}
|
||||
const fileExist = await this.storage.exists(this.credentials.uploadedFilePath);
|
||||
if (!fileExist) {
|
||||
throw Error('Mbox file upload not finished yet, please wait.');
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error({ error, credentials: this.credentials }, 'Mbox file validation failed.');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async *listAllUsers(): AsyncGenerator<MailboxUser> {
|
||||
const displayName =
|
||||
this.credentials.uploadedFileName || `mbox-import-${new Date().getTime()}`;
|
||||
logger.info(`Found potential mailbox: ${displayName}`);
|
||||
const constructedPrimaryEmail = `${displayName.replace(/ /g, '.').toLowerCase()}@mbox.local`;
|
||||
yield {
|
||||
id: constructedPrimaryEmail,
|
||||
primaryEmail: constructedPrimaryEmail,
|
||||
displayName: displayName,
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
// 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 {
|
||||
try {
|
||||
await this.storage.delete(this.credentials.uploadedFilePath);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ error, file: this.credentials.uploadedFilePath },
|
||||
'Failed to delete mbox file after processing.'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async parseMessage(emlBuffer: Buffer, path: string): Promise<EmailObject> {
|
||||
const parsedEmail: ParsedMail = await simpleParser(emlBuffer);
|
||||
|
||||
const attachments = parsedEmail.attachments.map((attachment: Attachment) => ({
|
||||
filename: attachment.filename || 'untitled',
|
||||
contentType: attachment.contentType,
|
||||
size: attachment.size,
|
||||
content: attachment.content as Buffer,
|
||||
}));
|
||||
|
||||
const mapAddresses = (
|
||||
addresses: AddressObject | AddressObject[] | undefined
|
||||
): EmailAddress[] => {
|
||||
if (!addresses) return [];
|
||||
const addressArray = Array.isArray(addresses) ? addresses : [addresses];
|
||||
return addressArray.flatMap((a) =>
|
||||
a.value.map((v) => ({
|
||||
name: v.name,
|
||||
address: v.address?.replaceAll(`'`, '') || '',
|
||||
}))
|
||||
);
|
||||
};
|
||||
|
||||
const threadId = getThreadId(parsedEmail.headers);
|
||||
let messageId = parsedEmail.messageId;
|
||||
|
||||
if (!messageId) {
|
||||
messageId = `generated-${createHash('sha256').update(emlBuffer).digest('hex')}`;
|
||||
}
|
||||
|
||||
const from = mapAddresses(parsedEmail.from);
|
||||
if (from.length === 0) {
|
||||
from.push({ name: 'No Sender', address: 'No Sender' });
|
||||
}
|
||||
|
||||
// Extract folder path from headers. Mbox files don't have a standard folder structure, so we rely on custom headers added by email clients.
|
||||
// Gmail uses 'X-Gmail-Labels', and other clients like Thunderbird may use 'X-Folder'.
|
||||
const gmailLabels = parsedEmail.headers.get('x-gmail-labels');
|
||||
const folderHeader = parsedEmail.headers.get('x-folder');
|
||||
let finalPath = '';
|
||||
|
||||
if (gmailLabels && typeof gmailLabels === 'string') {
|
||||
// We take the first label as the primary folder.
|
||||
// Gmail labels can be hierarchical, but we'll simplify to the first label.
|
||||
finalPath = gmailLabels.split(',')[0];
|
||||
} else if (folderHeader && typeof folderHeader === 'string') {
|
||||
finalPath = folderHeader;
|
||||
}
|
||||
|
||||
return {
|
||||
id: messageId,
|
||||
threadId: threadId,
|
||||
from,
|
||||
to: mapAddresses(parsedEmail.to),
|
||||
cc: mapAddresses(parsedEmail.cc),
|
||||
bcc: mapAddresses(parsedEmail.bcc),
|
||||
subject: parsedEmail.subject || '',
|
||||
body: parsedEmail.text || '',
|
||||
html: parsedEmail.html || '',
|
||||
headers: parsedEmail.headers,
|
||||
attachments,
|
||||
receivedAt: parsedEmail.date || new Date(),
|
||||
eml: emlBuffer,
|
||||
path: finalPath,
|
||||
};
|
||||
}
|
||||
|
||||
public getUpdatedSyncState(): SyncState {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -193,6 +193,14 @@ export class PSTConnector implements IEmailConnector {
|
||||
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.'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,8 +281,8 @@ export class PSTConnector implements IEmailConnector {
|
||||
emlBuffer ?? Buffer.from(parsedEmail.text || parsedEmail.html || '', 'utf-8')
|
||||
)
|
||||
.digest('hex')}-${createHash('sha256')
|
||||
.update(emlBuffer ?? Buffer.from(msg.subject || '', 'utf-8'))
|
||||
.digest('hex')}-${msg.clientSubmitTime?.getTime()}`;
|
||||
.update(emlBuffer ?? Buffer.from(msg.subject || '', 'utf-8'))
|
||||
.digest('hex')}-${msg.clientSubmitTime?.getTime()}`;
|
||||
}
|
||||
return {
|
||||
id: messageId,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Worker } from 'bullmq';
|
||||
import { connection } from '../config/redis';
|
||||
import indexEmailProcessor from '../jobs/processors/index-email.processor';
|
||||
import indexEmailBatchProcessor from '../jobs/processors/index-email-batch.processor';
|
||||
|
||||
const processor = async (job: any) => {
|
||||
switch (job.name) {
|
||||
case 'index-email':
|
||||
return indexEmailProcessor(job);
|
||||
case 'index-email-batch':
|
||||
return indexEmailBatchProcessor(job);
|
||||
default:
|
||||
throw new Error(`Unknown job name: ${job.name}`);
|
||||
}
|
||||
|
||||
@@ -15,14 +15,17 @@
|
||||
"dependencies": {
|
||||
"@iconify/svelte": "^5.0.1",
|
||||
"@open-archiver/types": "workspace:*",
|
||||
"@sveltejs/kit": "^2.16.0",
|
||||
"@sveltejs/kit": "^2.38.1",
|
||||
"bits-ui": "^2.8.10",
|
||||
"clsx": "^2.1.1",
|
||||
"d3-shape": "^3.2.0",
|
||||
"html-entities": "^2.6.0",
|
||||
"jose": "^6.0.1",
|
||||
"lucide-svelte": "^0.525.0",
|
||||
"postal-mime": "^2.4.4",
|
||||
"semver": "^7.7.2",
|
||||
"svelte-persisted-store": "^0.12.0",
|
||||
"sveltekit-i18n": "^2.4.2",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwind-variants": "^1.0.0"
|
||||
},
|
||||
@@ -34,6 +37,7 @@
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@types/d3-shape": "^3.1.7",
|
||||
"@types/semver": "^7.7.1",
|
||||
"dotenv": "^17.2.0",
|
||||
"layerchart": "2.0.0-next.27",
|
||||
"mode-watcher": "^1.1.0",
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script lang="ts">
|
||||
import PostalMime, { type Email } from 'postal-mime';
|
||||
import type { Buffer } from 'buffer';
|
||||
import { t } from '$lib/translations';
|
||||
import { encode } from 'html-entities';
|
||||
|
||||
let {
|
||||
raw,
|
||||
@@ -17,7 +19,9 @@
|
||||
if (parsedEmail && parsedEmail.html) {
|
||||
return `<base target="_blank" />${parsedEmail.html}`;
|
||||
} else if (parsedEmail && parsedEmail.text) {
|
||||
return `<base target="_blank" />${parsedEmail.text}`;
|
||||
// display raw text email body in html
|
||||
const safeHtmlContent: string = encode(parsedEmail.text);
|
||||
return `<base target="_blank" /><div>${safeHtmlContent.replaceAll('\n', '<br>')}</div>`;
|
||||
} else if (rawHtml) {
|
||||
return `<base target="_blank" />${rawHtml}`;
|
||||
}
|
||||
@@ -51,13 +55,16 @@
|
||||
|
||||
<div class="mt-2 rounded-md border bg-white p-4">
|
||||
{#if isLoading}
|
||||
<p>Loading email preview...</p>
|
||||
{:else if emailHtml}
|
||||
<iframe title="Email Preview" srcdoc={emailHtml()} class="h-[600px] w-full border-none"
|
||||
<p>{$t('app.components.email_preview.loading')}</p>
|
||||
{:else if emailHtml()}
|
||||
<iframe
|
||||
title={$t('app.archive.email_preview')}
|
||||
srcdoc={emailHtml()}
|
||||
class="h-[600px] w-full border-none"
|
||||
></iframe>
|
||||
{:else if raw}
|
||||
<p>Could not render email preview.</p>
|
||||
<p>{$t('app.components.email_preview.render_error')}</p>
|
||||
{:else}
|
||||
<p class="text-gray-500">Raw .eml file not available for this email.</p>
|
||||
<p class="text-gray-500">{$t('app.components.email_preview.not_available')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import type { ArchivedEmail } from '@open-archiver/types';
|
||||
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
|
||||
import { t } from '$lib/translations';
|
||||
|
||||
let {
|
||||
thread,
|
||||
@@ -47,16 +48,16 @@
|
||||
goto(`/dashboard/archived-emails/${item.id}`, {
|
||||
invalidateAll: true,
|
||||
});
|
||||
}}>{item.subject || 'No Subject'}</a
|
||||
}}>{item.subject || $t('app.archive.no_subject')}</a
|
||||
>
|
||||
{:else}
|
||||
{item.subject || 'No Subject'}
|
||||
{item.subject || $t('app.archive.no_subject')}
|
||||
{/if}
|
||||
</h4>
|
||||
<div
|
||||
class="flex flex-col space-y-2 text-sm font-normal leading-none text-gray-400"
|
||||
>
|
||||
<span>From: {item.senderEmail}</span>
|
||||
<span>{$t('app.archive.from')}: {item.senderEmail}</span>
|
||||
<time class="">{new Date(item.sentAt).toLocaleString()}</time>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,38 @@
|
||||
<footer class=" bg-muted py-6 md:py-0">
|
||||
<div
|
||||
class="container mx-auto flex flex-col items-center justify-center gap-4 md:h-24 md:flex-row"
|
||||
>
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/translations';
|
||||
import * as Alert from '$lib/components/ui/alert';
|
||||
import { Info } from 'lucide-svelte';
|
||||
|
||||
export let currentVersion: string;
|
||||
export let newVersionInfo: { version: string; description: string; url: string } | null = null;
|
||||
</script>
|
||||
|
||||
<footer class="bg-muted py-6 md:py-0">
|
||||
<div class="container mx-auto flex flex-col items-center justify-center gap-4 py-8 md:flex-row">
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<p class=" text-balance text-center text-xs font-medium leading-loose">
|
||||
{#if newVersionInfo}
|
||||
<Alert.Root>
|
||||
<Alert.Title class="flex items-center gap-2">
|
||||
<Info class="h-4 w-4" />
|
||||
{$t('app.components.footer.new_version_available')}
|
||||
<a
|
||||
href={newVersionInfo.url}
|
||||
target="_blank"
|
||||
class=" text-muted-foreground underline"
|
||||
>
|
||||
{newVersionInfo.description}
|
||||
</a>
|
||||
</Alert.Title>
|
||||
</Alert.Root>
|
||||
{/if}
|
||||
<p class="text-balance text-center text-xs font-medium leading-loose">
|
||||
© {new Date().getFullYear()}
|
||||
<a href="https://openarchiver.com/" target="_blank">Open Archiver</a>. All rights
|
||||
reserved.
|
||||
<a href="https://openarchiver.com/" target="_blank">Open Archiver</a>. {$t(
|
||||
'app.components.footer.all_rights_reserved'
|
||||
)}
|
||||
</p>
|
||||
<p class="text-balance text-center text-xs font-medium leading-loose">
|
||||
Version: {currentVersion}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import { setAlert } from '$lib/components/custom/alert/alert-state.svelte';
|
||||
import { api } from '$lib/api.client';
|
||||
import { Loader2 } from 'lucide-svelte';
|
||||
import { t } from '$lib/translations';
|
||||
let {
|
||||
source = null,
|
||||
onSubmit,
|
||||
@@ -20,11 +21,30 @@
|
||||
} = $props();
|
||||
|
||||
const providerOptions = [
|
||||
{ value: 'generic_imap', label: 'Generic IMAP' },
|
||||
{ value: 'google_workspace', label: 'Google Workspace' },
|
||||
{ value: 'microsoft_365', label: 'Microsoft 365' },
|
||||
{ value: 'pst_import', label: 'PST Import' },
|
||||
{ value: 'eml_import', label: 'EML Import' },
|
||||
{
|
||||
value: 'generic_imap',
|
||||
label: $t('app.components.ingestion_source_form.provider_generic_imap'),
|
||||
},
|
||||
{
|
||||
value: 'google_workspace',
|
||||
label: $t('app.components.ingestion_source_form.provider_google_workspace'),
|
||||
},
|
||||
{
|
||||
value: 'microsoft_365',
|
||||
label: $t('app.components.ingestion_source_form.provider_microsoft_365'),
|
||||
},
|
||||
{
|
||||
value: 'pst_import',
|
||||
label: $t('app.components.ingestion_source_form.provider_pst_import'),
|
||||
},
|
||||
{
|
||||
value: 'eml_import',
|
||||
label: $t('app.components.ingestion_source_form.provider_eml_import'),
|
||||
},
|
||||
{
|
||||
value: 'mbox_import',
|
||||
label: $t('app.components.ingestion_source_form.provider_mbox_import'),
|
||||
},
|
||||
];
|
||||
|
||||
let formData: CreateIngestionSourceDto = $state({
|
||||
@@ -33,16 +53,17 @@
|
||||
providerConfig: source?.credentials ?? {
|
||||
type: source?.provider ?? 'generic_imap',
|
||||
secure: true,
|
||||
allowInsecureCert: false,
|
||||
},
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
formData.providerConfig.type = formData.provider;
|
||||
console.log(formData);
|
||||
});
|
||||
|
||||
const triggerContent = $derived(
|
||||
providerOptions.find((p) => p.value === formData.provider)?.label ?? 'Select a provider'
|
||||
providerOptions.find((p) => p.value === formData.provider)?.label ??
|
||||
$t('app.components.ingestion_source_form.select_provider')
|
||||
);
|
||||
|
||||
let isSubmitting = $state(false);
|
||||
@@ -83,13 +104,12 @@
|
||||
|
||||
formData.providerConfig.uploadedFilePath = result.filePath;
|
||||
formData.providerConfig.uploadedFileName = file.name;
|
||||
|
||||
fileUploading = false;
|
||||
} catch (error) {
|
||||
fileUploading = false;
|
||||
setAlert({
|
||||
type: 'error',
|
||||
title: 'Upload Failed, please try again',
|
||||
title: $t('app.components.ingestion_source_form.upload_failed'),
|
||||
message: JSON.stringify(error),
|
||||
duration: 5000,
|
||||
show: true,
|
||||
@@ -100,11 +120,11 @@
|
||||
|
||||
<form onsubmit={handleSubmit} class="grid gap-4 py-4">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="name" class="text-left">Name</Label>
|
||||
<Label for="name" class="text-left">{$t('app.ingestions.name')}</Label>
|
||||
<Input id="name" bind:value={formData.name} class="col-span-3" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="provider" class="text-left">Provider</Label>
|
||||
<Label for="provider" class="text-left">{$t('app.ingestions.provider')}</Label>
|
||||
<Select.Root name="provider" bind:value={formData.provider} type="single">
|
||||
<Select.Trigger class="col-span-3">
|
||||
{triggerContent}
|
||||
@@ -119,16 +139,22 @@
|
||||
|
||||
{#if formData.provider === 'google_workspace'}
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="serviceAccountKeyJson" class="text-left">Service Account Key (JSON)</Label>
|
||||
<Label for="serviceAccountKeyJson" class="text-left"
|
||||
>{$t('app.components.ingestion_source_form.service_account_key')}</Label
|
||||
>
|
||||
<Textarea
|
||||
placeholder="Paste your service account key JSON content"
|
||||
placeholder={$t(
|
||||
'app.components.ingestion_source_form.service_account_key_placeholder'
|
||||
)}
|
||||
id="serviceAccountKeyJson"
|
||||
bind:value={formData.providerConfig.serviceAccountKeyJson}
|
||||
class="col-span-3 max-h-32"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="impersonatedAdminEmail" class="text-left">Impersonated Admin Email</Label>
|
||||
<Label for="impersonatedAdminEmail" class="text-left"
|
||||
>{$t('app.components.ingestion_source_form.impersonated_admin_email')}</Label
|
||||
>
|
||||
<Input
|
||||
id="impersonatedAdminEmail"
|
||||
bind:value={formData.providerConfig.impersonatedAdminEmail}
|
||||
@@ -137,30 +163,40 @@
|
||||
</div>
|
||||
{:else if formData.provider === 'microsoft_365'}
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="clientId" class="text-left">Application (Client) ID</Label>
|
||||
<Label for="clientId" class="text-left"
|
||||
>{$t('app.components.ingestion_source_form.client_id')}</Label
|
||||
>
|
||||
<Input id="clientId" bind:value={formData.providerConfig.clientId} class="col-span-3" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="clientSecret" class="text-left">Client Secret Value</Label>
|
||||
<Label for="clientSecret" class="text-left"
|
||||
>{$t('app.components.ingestion_source_form.client_secret')}</Label
|
||||
>
|
||||
<Input
|
||||
id="clientSecret"
|
||||
type="password"
|
||||
placeholder="Enter the secret Value, not the Secret ID"
|
||||
placeholder={$t('app.components.ingestion_source_form.client_secret_placeholder')}
|
||||
bind:value={formData.providerConfig.clientSecret}
|
||||
class="col-span-3"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="tenantId" class="text-left">Directory (Tenant) ID</Label>
|
||||
<Label for="tenantId" class="text-left"
|
||||
>{$t('app.components.ingestion_source_form.tenant_id')}</Label
|
||||
>
|
||||
<Input id="tenantId" bind:value={formData.providerConfig.tenantId} class="col-span-3" />
|
||||
</div>
|
||||
{:else if formData.provider === 'generic_imap'}
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="host" class="text-left">Host</Label>
|
||||
<Label for="host" class="text-left"
|
||||
>{$t('app.components.ingestion_source_form.host')}</Label
|
||||
>
|
||||
<Input id="host" bind:value={formData.providerConfig.host} class="col-span-3" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="port" class="text-left">Port</Label>
|
||||
<Label for="port" class="text-left"
|
||||
>{$t('app.components.ingestion_source_form.port')}</Label
|
||||
>
|
||||
<Input
|
||||
id="port"
|
||||
type="number"
|
||||
@@ -169,11 +205,13 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="username" class="text-left">Username</Label>
|
||||
<Label for="username" class="text-left"
|
||||
>{$t('app.components.ingestion_source_form.username')}</Label
|
||||
>
|
||||
<Input id="username" bind:value={formData.providerConfig.username} class="col-span-3" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="password" class="text-left">Password</Label>
|
||||
<Label for="password" class="text-left">{$t('app.auth.password')}</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
@@ -182,12 +220,25 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="secure" class="text-left">Use TLS</Label>
|
||||
<Label for="secure" class="text-left"
|
||||
>{$t('app.components.ingestion_source_form.use_tls')}</Label
|
||||
>
|
||||
<Checkbox id="secure" bind:checked={formData.providerConfig.secure} />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="allowInsecureCert" class="text-left"
|
||||
>{$t('app.components.ingestion_source_form.allow_insecure_cert')}</Label
|
||||
>
|
||||
<Checkbox
|
||||
id="allowInsecureCert"
|
||||
bind:checked={formData.providerConfig.allowInsecureCert}
|
||||
/>
|
||||
</div>
|
||||
{:else if formData.provider === 'pst_import'}
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="pst-file" class="text-left">PST File</Label>
|
||||
<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"
|
||||
@@ -203,7 +254,9 @@
|
||||
</div>
|
||||
{:else if formData.provider === 'eml_import'}
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="eml-file" class="text-left">EML File</Label>
|
||||
<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"
|
||||
@@ -217,15 +270,31 @@
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{: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>
|
||||
{/if}
|
||||
{#if formData.provider === 'google_workspace' || formData.provider === 'microsoft_365'}
|
||||
<Alert.Root>
|
||||
<Alert.Title>Heads up!</Alert.Title>
|
||||
<Alert.Title>{$t('app.components.ingestion_source_form.heads_up')}</Alert.Title>
|
||||
<Alert.Description>
|
||||
<div class="my-1">
|
||||
Please note that this is an organization-wide operation. This kind of ingestions
|
||||
will import and index <b>all</b> email inboxes in your organization. If you want
|
||||
to import only specific email inboxes, use the IMAP connector.
|
||||
{@html $t('app.components.ingestion_source_form.org_wide_warning')}
|
||||
</div>
|
||||
</Alert.Description>
|
||||
</Alert.Root>
|
||||
@@ -233,9 +302,9 @@
|
||||
<Dialog.Footer>
|
||||
<Button type="submit" disabled={isSubmitting || fileUploading}>
|
||||
{#if isSubmitting}
|
||||
Submitting...
|
||||
{$t('app.components.common.submitting')}
|
||||
{:else}
|
||||
Submit
|
||||
{$t('app.components.common.submit')}
|
||||
{/if}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Textarea } from '$lib/components/ui/textarea';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { t } from '$lib/translations';
|
||||
|
||||
let { role, onSubmit }: { role: Role | null; onSubmit: (formData: Partial<Role>) => void } =
|
||||
$props();
|
||||
@@ -16,7 +17,7 @@
|
||||
const parsedPolicies: CaslPolicy[] = JSON.parse(policies);
|
||||
onSubmit({ name, policies: parsedPolicies });
|
||||
} catch (error) {
|
||||
alert('Invalid JSON format for policies.');
|
||||
alert($t('app.components.role_form.invalid_json'));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -29,11 +30,13 @@
|
||||
class="grid gap-4 py-4"
|
||||
>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="name" class="text-right">Name</Label>
|
||||
<Label for="name" class="text-right">{$t('app.roles.name')}</Label>
|
||||
<Input id="name" bind:value={name} class="col-span-3" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="policies" class="text-right">Policies (JSON)</Label>
|
||||
<Label for="policies" class="text-right"
|
||||
>{$t('app.components.role_form.policies_json')}</Label
|
||||
>
|
||||
<Textarea
|
||||
id="policies"
|
||||
bind:value={policies}
|
||||
@@ -42,6 +45,6 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<Button type="submit">Save</Button>
|
||||
<Button type="submit">{$t('app.components.common.save')}</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import { Sun, Moon, Laptop } from 'lucide-svelte';
|
||||
import { t } from '$lib/translations';
|
||||
</script>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
@@ -14,21 +15,21 @@
|
||||
<Moon
|
||||
class="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100"
|
||||
/>
|
||||
<span class="sr-only">Toggle theme</span>
|
||||
<span class="sr-only">{$t('app.components.theme_switcher.toggle_theme')}</span>
|
||||
</Button>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item onclick={() => ($theme = 'light')}>
|
||||
<Sun class="mr-2 h-4 w-4" />
|
||||
<span>Light</span>
|
||||
<span>{$t('app.system_settings.light')}</span>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={() => ($theme = 'dark')}>
|
||||
<Moon class="mr-2 h-4 w-4" />
|
||||
<span>Dark</span>
|
||||
<span>{$t('app.system_settings.dark')}</span>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={() => ($theme = 'system')}>
|
||||
<Laptop class="mr-2 h-4 w-4" />
|
||||
<span>System</span>
|
||||
<span>{$t('app.system_settings.system')}</span>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { t } from '$lib/translations';
|
||||
|
||||
let {
|
||||
user = null,
|
||||
@@ -25,7 +26,8 @@
|
||||
});
|
||||
|
||||
const triggerContent = $derived(
|
||||
roles.find((r) => r.id === formData.roleId)?.name ?? 'Select a role'
|
||||
roles.find((r) => r.id === formData.roleId)?.name ??
|
||||
$t('app.components.user_form.select_role')
|
||||
);
|
||||
|
||||
let isSubmitting = $state(false);
|
||||
@@ -53,20 +55,20 @@
|
||||
|
||||
<form onsubmit={handleSubmit} class="grid gap-4 py-4">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="first_name" class="text-left">First Name</Label>
|
||||
<Label for="first_name" class="text-left">{$t('app.setup.first_name')}</Label>
|
||||
<Input id="first_name" bind:value={formData.first_name} class="col-span-3" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="last_name" class="text-left">Last Name</Label>
|
||||
<Label for="last_name" class="text-left">{$t('app.setup.last_name')}</Label>
|
||||
<Input id="last_name" bind:value={formData.last_name} class="col-span-3" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="email" class="text-left">Email</Label>
|
||||
<Label for="email" class="text-left">{$t('app.users.email')}</Label>
|
||||
<Input id="email" type="email" bind:value={formData.email} class="col-span-3" />
|
||||
</div>
|
||||
{#if !user}
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="password" class="text-left">Password</Label>
|
||||
<Label for="password" class="text-left">{$t('app.auth.password')}</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
@@ -76,7 +78,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="role" class="text-left">Role</Label>
|
||||
<Label for="role" class="text-left">{$t('app.users.role')}</Label>
|
||||
<Select.Root name="role" bind:value={formData.roleId} type="single">
|
||||
<Select.Trigger class="col-span-3">
|
||||
{triggerContent}
|
||||
@@ -92,9 +94,9 @@
|
||||
<Dialog.Footer>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{#if isSubmitting}
|
||||
Submitting...
|
||||
{$t('app.components.common.submitting')}
|
||||
{:else}
|
||||
Submit
|
||||
{$t('app.components.common.submit')}
|
||||
{/if}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
import { AreaChart } from 'layerchart';
|
||||
import { curveMonotoneX } from 'd3-shape';
|
||||
import type { ChartConfig } from '$lib/components/ui/chart';
|
||||
import { t } from '$lib/translations';
|
||||
|
||||
export let data: { date: Date; count: number }[];
|
||||
|
||||
const chartConfig = {
|
||||
count: {
|
||||
label: 'Emails Ingested',
|
||||
label: $t('app.components.charts.emails_ingested'),
|
||||
color: 'var(--chart-1)',
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
@@ -4,12 +4,13 @@
|
||||
import type { IngestionSourceStats } from '@open-archiver/types';
|
||||
import type { ChartConfig } from '$lib/components/ui/chart';
|
||||
import { formatBytes } from '$lib/utils';
|
||||
import { t } from '$lib/translations';
|
||||
|
||||
export let data: IngestionSourceStats[];
|
||||
|
||||
const chartConfig = {
|
||||
storageUsed: {
|
||||
label: 'Storage Used',
|
||||
label: $t('app.components.charts.storage_used'),
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
</script>
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
import { BarChart } from 'layerchart';
|
||||
import type { TopSender } from '@open-archiver/types';
|
||||
import type { ChartConfig } from '$lib/components/ui/chart';
|
||||
import { t } from '$lib/translations';
|
||||
|
||||
export let data: TopSender[];
|
||||
|
||||
const chartConfig = {
|
||||
count: {
|
||||
label: 'Emails',
|
||||
label: $t('app.components.charts.emails'),
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
</script>
|
||||
|
||||
10
packages/frontend/src/lib/components/ui/radio-group/index.ts
Normal file
10
packages/frontend/src/lib/components/ui/radio-group/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import Root from "./radio-group.svelte";
|
||||
import Item from "./radio-group-item.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
Item,
|
||||
//
|
||||
Root as RadioGroup,
|
||||
Item as RadioGroupItem,
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import { RadioGroup as RadioGroupPrimitive } from "bits-ui";
|
||||
import CircleIcon from "@lucide/svelte/icons/circle";
|
||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<RadioGroupPrimitive.ItemProps> = $props();
|
||||
</script>
|
||||
|
||||
<RadioGroupPrimitive.Item
|
||||
bind:ref
|
||||
data-slot="radio-group-item"
|
||||
class={cn(
|
||||
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 shadow-xs aspect-square size-4 shrink-0 rounded-full border outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{#snippet children({ checked })}
|
||||
<div data-slot="radio-group-indicator" class="relative flex items-center justify-center">
|
||||
{#if checked}
|
||||
<CircleIcon
|
||||
class="fill-primary absolute left-1/2 top-1/2 size-2 -translate-x-1/2 -translate-y-1/2"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</RadioGroupPrimitive.Item>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { RadioGroup as RadioGroupPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
value = $bindable(""),
|
||||
...restProps
|
||||
}: RadioGroupPrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<RadioGroupPrimitive.Root
|
||||
bind:ref
|
||||
bind:value
|
||||
data-slot="radio-group"
|
||||
class={cn("grid gap-3", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
260
packages/frontend/src/lib/translations/de.json
Normal file
260
packages/frontend/src/lib/translations/de.json
Normal file
@@ -0,0 +1,260 @@
|
||||
{
|
||||
"app": {
|
||||
"auth": {
|
||||
"login": "Anmelden",
|
||||
"login_tip": "Geben Sie unten Ihre E-Mail-Adresse ein, um sich bei Ihrem Konto anzumelden.",
|
||||
"email": "Email",
|
||||
"password": "Passwort"
|
||||
},
|
||||
"common": {
|
||||
"working": "Arbeiten"
|
||||
},
|
||||
"archive": {
|
||||
"title": "Archiv",
|
||||
"no_subject": "Kein Betreff",
|
||||
"from": "Von",
|
||||
"sent": "Gesendet",
|
||||
"recipients": "Empfänger",
|
||||
"to": "An",
|
||||
"meta_data": "Metadaten",
|
||||
"folder": "Ordner",
|
||||
"tags": "Tags",
|
||||
"size": "Größe",
|
||||
"email_preview": "E-Mail-Vorschau",
|
||||
"attachments": "Anhänge",
|
||||
"download": "Herunterladen",
|
||||
"actions": "Aktionen",
|
||||
"download_eml": "E-Mail herunterladen (.eml)",
|
||||
"delete_email": "E-Mail löschen",
|
||||
"email_thread": "E-Mail-Thread",
|
||||
"delete_confirmation_title": "Möchten Sie diese E-Mail wirklich löschen?",
|
||||
"delete_confirmation_description": "Diese Aktion kann nicht rückgängig gemacht werden und entfernt die E-Mail und ihre Anhänge dauerhaft.",
|
||||
"deleting": "Löschen",
|
||||
"confirm": "Bestätigen",
|
||||
"cancel": "Abbrechen",
|
||||
"not_found": "E-Mail nicht gefunden."
|
||||
},
|
||||
"ingestions": {
|
||||
"title": "Erfassungsquellen",
|
||||
"ingestion_sources": "Erfassungsquellen",
|
||||
"bulk_actions": "Massenaktionen",
|
||||
"force_sync": "Synchronisierung erzwingen",
|
||||
"delete": "Löschen",
|
||||
"create_new": "Neu erstellen",
|
||||
"name": "Name",
|
||||
"provider": "Anbieter",
|
||||
"status": "Status",
|
||||
"active": "Aktiv",
|
||||
"created_at": "Erstellt am",
|
||||
"actions": "Aktionen",
|
||||
"last_sync_message": "Letzte Synchronisierungsnachricht",
|
||||
"empty": "Leer",
|
||||
"open_menu": "Menü öffnen",
|
||||
"edit": "Bearbeiten",
|
||||
"create": "Erstellen",
|
||||
"ingestion_source": "Erfassungsquelle",
|
||||
"edit_description": "Nehmen Sie hier Änderungen an Ihrer Erfassungsquelle vor.",
|
||||
"create_description": "Fügen Sie eine neue Erfassungsquelle hinzu, um mit der Archivierung von E-Mails zu beginnen.",
|
||||
"read": "Lesen",
|
||||
"docs_here": "Dokumente hier",
|
||||
"delete_confirmation_title": "Möchten Sie diese Erfassung wirklich löschen?",
|
||||
"delete_confirmation_description": "Dadurch werden alle archivierten E-Mails, Anhänge, Indizierungen und Dateien, die mit dieser Erfassung verknüpft sind, gelöscht. Wenn Sie nur die Synchronisierung neuer E-Mails beenden möchten, können Sie stattdessen die Erfassung anhalten.",
|
||||
"deleting": "Löschen",
|
||||
"confirm": "Bestätigen",
|
||||
"cancel": "Abbrechen",
|
||||
"bulk_delete_confirmation_title": "Möchten Sie wirklich {{count}} ausgewählte Erfassungen löschen?",
|
||||
"bulk_delete_confirmation_description": "Dadurch werden alle archivierten E-Mails, Anhänge, Indizierungen und Dateien, die mit diesen Erfassungen verknüpft sind, gelöscht. Wenn Sie nur die Synchronisierung neuer E-Mails beenden möchten, können Sie stattdessen die Erfassungen anhalten."
|
||||
},
|
||||
"search": {
|
||||
"title": "Suche",
|
||||
"description": "Suchen Sie nach archivierten E-Mails.",
|
||||
"email_search": "E-Mail-Suche",
|
||||
"placeholder": "Suche nach Stichwort, Absender, Empfänger...",
|
||||
"search_button": "Suche",
|
||||
"search_options": "Suchoptionen",
|
||||
"strategy_fuzzy": "Fuzzy",
|
||||
"strategy_verbatim": "Wörtlich",
|
||||
"strategy_frequency": "Frequenz",
|
||||
"select_strategy": "Wählen Sie eine Strategie",
|
||||
"error": "Fehler",
|
||||
"found_results_in": "{{total}} Ergebnisse in {{seconds}}s gefunden",
|
||||
"found_results": "{{total}} Ergebnisse gefunden",
|
||||
"from": "Von",
|
||||
"to": "An",
|
||||
"in_email_body": "Im E-Mail-Text",
|
||||
"in_attachment": "Im Anhang: {{filename}}",
|
||||
"prev": "Zurück",
|
||||
"next": "Weiter"
|
||||
},
|
||||
"roles": {
|
||||
"title": "Rollenverwaltung",
|
||||
"role_management": "Rollenverwaltung",
|
||||
"create_new": "Neu erstellen",
|
||||
"name": "Name",
|
||||
"created_at": "Erstellt am",
|
||||
"actions": "Aktionen",
|
||||
"open_menu": "Menü öffnen",
|
||||
"view_policy": "Richtlinie anzeigen",
|
||||
"edit": "Bearbeiten",
|
||||
"delete": "Löschen",
|
||||
"no_roles_found": "Keine Rollen gefunden.",
|
||||
"role_policy": "Rollenrichtlinie",
|
||||
"viewing_policy_for_role": "Richtlinie für Rolle anzeigen: {{name}}",
|
||||
"create": "Erstellen",
|
||||
"role": "Rolle",
|
||||
"edit_description": "Nehmen Sie hier Änderungen an der Rolle vor.",
|
||||
"create_description": "Fügen Sie dem System eine neue Rolle hinzu.",
|
||||
"delete_confirmation_title": "Möchten Sie diese Rolle wirklich löschen?",
|
||||
"delete_confirmation_description": "Diese Aktion kann nicht rückgängig gemacht werden. Dadurch wird die Rolle dauerhaft gelöscht.",
|
||||
"deleting": "Löschen",
|
||||
"confirm": "Bestätigen",
|
||||
"cancel": "Abbrechen"
|
||||
},
|
||||
"system_settings": {
|
||||
"title": "Systemeinstellungen",
|
||||
"system_settings": "Systemeinstellungen",
|
||||
"description": "Globale Anwendungseinstellungen verwalten.",
|
||||
"language": "Sprache",
|
||||
"default_theme": "Standardthema",
|
||||
"light": "Hell",
|
||||
"dark": "Dunkel",
|
||||
"system": "System",
|
||||
"support_email": "Support-E-Mail",
|
||||
"saving": "Speichern",
|
||||
"save_changes": "Änderungen speichern"
|
||||
},
|
||||
"users": {
|
||||
"title": "Benutzerverwaltung",
|
||||
"user_management": "Benutzerverwaltung",
|
||||
"create_new": "Neu erstellen",
|
||||
"name": "Name",
|
||||
"email": "Email",
|
||||
"role": "Rolle",
|
||||
"created_at": "Erstellt am",
|
||||
"actions": "Aktionen",
|
||||
"open_menu": "Menü öffnen",
|
||||
"edit": "Bearbeiten",
|
||||
"delete": "Löschen",
|
||||
"no_users_found": "Keine Benutzer gefunden.",
|
||||
"create": "Erstellen",
|
||||
"user": "Benutzer",
|
||||
"edit_description": "Nehmen Sie hier Änderungen am Benutzer vor.",
|
||||
"create_description": "Fügen Sie dem System einen neuen Benutzer hinzu.",
|
||||
"delete_confirmation_title": "Möchten Sie diesen Benutzer wirklich löschen?",
|
||||
"delete_confirmation_description": "Diese Aktion kann nicht rückgängig gemacht werden. Dadurch wird der Benutzer dauerhaft gelöscht und seine Daten von unseren Servern entfernt.",
|
||||
"deleting": "Löschen",
|
||||
"confirm": "Bestätigen",
|
||||
"cancel": "Abbrechen"
|
||||
},
|
||||
"setup": {
|
||||
"title": "Einrichtung",
|
||||
"description": "Richten Sie das anfängliche Administratorkonto für Open Archiver ein.",
|
||||
"welcome": "Willkommen",
|
||||
"create_admin_account": "Erstellen Sie das erste Administratorkonto, um loszulegen.",
|
||||
"first_name": "Vorname",
|
||||
"last_name": "Nachname",
|
||||
"email": "Email",
|
||||
"password": "Passwort",
|
||||
"creating_account": "Konto wird erstellt",
|
||||
"create_account": "Konto erstellen"
|
||||
},
|
||||
"layout": {
|
||||
"dashboard": "Dashboard",
|
||||
"ingestions": "Erfassungen",
|
||||
"archived_emails": "Archivierte E-Mails",
|
||||
"search": "Suche",
|
||||
"settings": "Einstellungen",
|
||||
"system": "System",
|
||||
"users": "Benutzer",
|
||||
"roles": "Rollen",
|
||||
"logout": "Abmelden"
|
||||
},
|
||||
"components": {
|
||||
"charts": {
|
||||
"emails_ingested": "E-Mails aufgenommen",
|
||||
"storage_used": "Speicher verwendet",
|
||||
"emails": "E-Mails"
|
||||
},
|
||||
"common": {
|
||||
"submitting": "Übermittlung...",
|
||||
"submit": "Übermitteln",
|
||||
"save": "Speichern"
|
||||
},
|
||||
"email_preview": {
|
||||
"loading": "E-Mail-Vorschau wird geladen...",
|
||||
"render_error": "E-Mail-Vorschau konnte nicht gerendert werden.",
|
||||
"not_available": "Rohe .eml-Datei für diese E-Mail nicht verfügbar."
|
||||
},
|
||||
"footer": {
|
||||
"all_rights_reserved": "Alle Rechte vorbehalten."
|
||||
},
|
||||
"ingestion_source_form": {
|
||||
"provider_generic_imap": "Generisches IMAP",
|
||||
"provider_google_workspace": "Google Workspace",
|
||||
"provider_microsoft_365": "Microsoft 365",
|
||||
"provider_pst_import": "PST-Import",
|
||||
"provider_eml_import": "EML-Import",
|
||||
"select_provider": "Wählen Sie einen Anbieter",
|
||||
"service_account_key": "Dienstkontoschlüssel (JSON)",
|
||||
"service_account_key_placeholder": "Fügen Sie den JSON-Inhalt Ihres Dienstkontoschlüssels ein",
|
||||
"impersonated_admin_email": "Impersonierte Admin-E-Mail",
|
||||
"client_id": "Anwendungs-(Client-)ID",
|
||||
"client_secret": "Client-Geheimniswert",
|
||||
"client_secret_placeholder": "Geben Sie den Geheimniswert ein, nicht die Geheimnis-ID",
|
||||
"tenant_id": "Verzeichnis-(Mandanten-)ID",
|
||||
"host": "Host",
|
||||
"port": "Port",
|
||||
"username": "Benutzername",
|
||||
"use_tls": "TLS verwenden",
|
||||
"pst_file": "PST-Datei",
|
||||
"eml_file": "EML-Datei",
|
||||
"heads_up": "Achtung!",
|
||||
"org_wide_warning": "Bitte beachten Sie, dass dies ein organisationsweiter Vorgang ist. Diese Art von Erfassungen importiert und indiziert <b>alle</b> E-Mail-Postfächer in Ihrer Organisation. Wenn Sie nur bestimmte E-Mail-Postfächer importieren möchten, verwenden Sie den IMAP-Connector.",
|
||||
"upload_failed": "Hochladen fehlgeschlagen, bitte versuchen Sie es erneut"
|
||||
},
|
||||
"role_form": {
|
||||
"policies_json": "Richtlinien (JSON)",
|
||||
"invalid_json": "Ungültiges JSON-Format für Richtlinien."
|
||||
},
|
||||
"theme_switcher": {
|
||||
"toggle_theme": "Thema umschalten"
|
||||
},
|
||||
"user_form": {
|
||||
"select_role": "Wählen Sie eine Rolle aus"
|
||||
}
|
||||
},
|
||||
"dashboard_page": {
|
||||
"title": "Dashboard",
|
||||
"meta_description": "Übersicht über Ihr E-Mail-Archiv.",
|
||||
"header": "Dashboard",
|
||||
"create_ingestion": "Erfassung erstellen",
|
||||
"no_ingestion_header": "Sie haben keine Erfassungsquelle eingerichtet.",
|
||||
"no_ingestion_text": "Fügen Sie eine Erfassungsquelle hinzu, um mit der Archivierung Ihrer Posteingänge zu beginnen.",
|
||||
"total_emails_archived": "Insgesamt archivierte E-Mails",
|
||||
"total_storage_used": "Insgesamt genutzter Speicherplatz",
|
||||
"failed_ingestions": "Fehlgeschlagene Erfassungen (letzte 7 Tage)",
|
||||
"ingestion_history": "Erfassungsverlauf",
|
||||
"no_ingestion_history": "Kein Erfassungsverlauf verfügbar.",
|
||||
"storage_by_source": "Speicher nach Erfassungsquelle",
|
||||
"no_ingestion_sources": "Keine Erfassungsquellen verfügbar.",
|
||||
"indexed_insights": "Indizierte Einblicke",
|
||||
"top_10_senders": "Top 10 Absender",
|
||||
"no_indexed_insights": "Keine indizierten Einblicke verfügbar."
|
||||
},
|
||||
"archived_emails_page": {
|
||||
"title": "Archivierte E-Mails",
|
||||
"header": "Archivierte E-Mails",
|
||||
"select_ingestion_source": "Wählen Sie eine Erfassungsquelle aus",
|
||||
"date": "Datum",
|
||||
"subject": "Betreff",
|
||||
"sender": "Absender",
|
||||
"inbox": "Posteingang",
|
||||
"path": "Pfad",
|
||||
"actions": "Aktionen",
|
||||
"view": "Ansehen",
|
||||
"no_emails_found": "Keine archivierten E-Mails gefunden.",
|
||||
"prev": "Zurück",
|
||||
"next": "Weiter"
|
||||
}
|
||||
}
|
||||
}
|
||||
260
packages/frontend/src/lib/translations/el.json
Normal file
260
packages/frontend/src/lib/translations/el.json
Normal file
@@ -0,0 +1,260 @@
|
||||
{
|
||||
"app": {
|
||||
"auth": {
|
||||
"login": "Σύνδεση",
|
||||
"login_tip": "Εισαγάγετε το email σας παρακάτω για να συνδεθείτε στον λογαριασμό σας.",
|
||||
"email": "Email",
|
||||
"password": "Κωδικός πρόσβασης"
|
||||
},
|
||||
"common": {
|
||||
"working": "Επεξεργασία"
|
||||
},
|
||||
"archive": {
|
||||
"title": "Αρχείο",
|
||||
"no_subject": "Χωρίς θέμα",
|
||||
"from": "Από",
|
||||
"sent": "Απεσταλμένα",
|
||||
"recipients": "Παραλήπτες",
|
||||
"to": "Προς",
|
||||
"meta_data": "Μεταδεδομένα",
|
||||
"folder": "Φάκελος",
|
||||
"tags": "Ετικέτες",
|
||||
"size": "Μέγεθος",
|
||||
"email_preview": "Προεπισκόπηση email",
|
||||
"attachments": "Συνημμένα",
|
||||
"download": "Λήψη",
|
||||
"actions": "Ενέργειες",
|
||||
"download_eml": "Λήψη email (.eml)",
|
||||
"delete_email": "Διαγραφή email",
|
||||
"email_thread": "Συνομιλία email",
|
||||
"delete_confirmation_title": "Είστε βέβαιοι ότι θέλετε να διαγράψετε αυτό το email;",
|
||||
"delete_confirmation_description": "Αυτή η ενέργεια δεν μπορεί να αναιρεθεί και θα διαγράψει οριστικά το email και τα συνημμένα του.",
|
||||
"deleting": "Διαγραφή",
|
||||
"confirm": "Επιβεβαίωση",
|
||||
"cancel": "Άκυρο",
|
||||
"not_found": "Το email δεν βρέθηκε."
|
||||
},
|
||||
"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": "Προσθέστε μια νέα πηγή εισαγωγής για να ξεκινήσετε την αρχειοθέτηση των email.",
|
||||
"read": "Διαβάστε",
|
||||
"docs_here": "την τεκμηρίωση εδώ",
|
||||
"delete_confirmation_title": "Είστε βέβαιοι ότι θέλετε να διαγράψετε αυτήν την εισαγωγή;",
|
||||
"delete_confirmation_description": "Αυτό θα διαγράψει όλα τα αρχειοθετημένα email, τα συνημμένα, την ευρετηρίαση και τα αρχεία που σχετίζονται με αυτήν την εισαγωγή. Εάν θέλετε μόνο να σταματήσετε τον συγχρονισμό νέων email, μπορείτε να θέσετε σε παύση την εισαγωγή.",
|
||||
"deleting": "Διαγραφή",
|
||||
"confirm": "Επιβεβαίωση",
|
||||
"cancel": "Άκυρο",
|
||||
"bulk_delete_confirmation_title": "Είστε βέβαιοι ότι θέλετε να διαγράψετε τις {{count}} επιλεγμένες εισαγωγές;",
|
||||
"bulk_delete_confirmation_description": "Αυτό θα διαγράψει όλα τα αρχειοθετημένα email, τα συνημμένα, την ευρετηρίαση και τα αρχεία που σχετίζονται με αυτές τις εισαγωγές. Εάν θέλετε μόνο να σταματήσετε τον συγχρονισμό νέων email, μπορείτε να θέσετε σε παύση τις εισαγωγές."
|
||||
},
|
||||
"search": {
|
||||
"title": "Αναζήτηση",
|
||||
"description": "Αναζήτηση για αρχειοθετημένα email.",
|
||||
"email_search": "Αναζήτηση email",
|
||||
"placeholder": "Αναζήτηση με λέξη-κλειδί, αποστολέα, παραλήπτη...",
|
||||
"search_button": "Αναζήτηση",
|
||||
"search_options": "Επιλογές αναζήτησης",
|
||||
"strategy_fuzzy": "Ασαφής",
|
||||
"strategy_verbatim": "Κατά λέξη",
|
||||
"strategy_frequency": "Συχνότητα",
|
||||
"select_strategy": "Επιλέξτε μια στρατηγική",
|
||||
"error": "Σφάλμα",
|
||||
"found_results_in": "Βρέθηκαν {{total}} αποτελέσματα σε {{seconds}}s",
|
||||
"found_results": "Βρέθηκαν {{total}} αποτελέσματα",
|
||||
"from": "Από",
|
||||
"to": "Προς",
|
||||
"in_email_body": "Στο σώμα του email",
|
||||
"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": "Email υποστήριξης",
|
||||
"saving": "Αποθήκευση",
|
||||
"save_changes": "Αποθήκευση αλλαγών"
|
||||
},
|
||||
"users": {
|
||||
"title": "Διαχείριση χρηστών",
|
||||
"user_management": "Διαχείριση χρηστών",
|
||||
"create_new": "Δημιουργία νέου",
|
||||
"name": "Όνομα",
|
||||
"email": "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": "Εισερχόμενα email",
|
||||
"storage_used": "Χρησιμοποιημένος χώρος αποθήκευσης",
|
||||
"emails": "Email"
|
||||
},
|
||||
"common": {
|
||||
"submitting": "Υποβολή...",
|
||||
"submit": "Υποβολή",
|
||||
"save": "Αποθήκευση"
|
||||
},
|
||||
"email_preview": {
|
||||
"loading": "Φόρτωση προεπισκόπησης email...",
|
||||
"render_error": "Δεν ήταν δυνατή η απόδοση της προεπισκόπησης email.",
|
||||
"not_available": "Το ακατέργαστο αρχείο .eml δεν είναι διαθέσιμο για αυτό το email."
|
||||
},
|
||||
"footer": {
|
||||
"all_rights_reserved": "Με επιφύλαξη παντός δικαιώματος."
|
||||
},
|
||||
"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",
|
||||
"select_provider": "Επιλέξτε έναν πάροχο",
|
||||
"service_account_key": "Κλειδί λογαριασμού υπηρεσίας (JSON)",
|
||||
"service_account_key_placeholder": "Επικολλήστε το περιεχόμενο JSON του κλειδιού του λογαριασμού υπηρεσίας σας",
|
||||
"impersonated_admin_email": "Email διαχειριστή που έχει πλαστοπροσωπηθεί",
|
||||
"client_id": "Αναγνωριστικό εφαρμογής (πελάτη)",
|
||||
"client_secret": "Τιμή μυστικού πελάτη",
|
||||
"client_secret_placeholder": "Εισαγάγετε την τιμή του μυστικού, όχι το αναγνωριστικό του μυστικού",
|
||||
"tenant_id": "Αναγνωριστικό καταλόγου (μισθωτή)",
|
||||
"host": "Κεντρικός υπολογιστής",
|
||||
"port": "Θύρα",
|
||||
"username": "Όνομα χρήστη",
|
||||
"use_tls": "Χρήση TLS",
|
||||
"pst_file": "Αρχείο PST",
|
||||
"eml_file": "Αρχείο EML",
|
||||
"heads_up": "Προσοχή!",
|
||||
"org_wide_warning": "Λάβετε υπόψη ότι αυτή είναι μια λειτουργία σε επίπεδο οργανισμού. Αυτό το είδος εισαγωγής θα εισαγάγει και θα ευρετηριάσει <b>όλα</b> τα εισερχόμενα email στον οργανισμό σας. Εάν θέλετε να εισαγάγετε μόνο συγκεκριμένα εισερχόμενα email, χρησιμοποιήστε τη σύνδεση 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": "Email",
|
||||
"password": "Κωδικός πρόσβασης",
|
||||
"creating_account": "Δημιουργία λογαριασμού",
|
||||
"create_account": "Δημιουργία λογαριασμού"
|
||||
},
|
||||
"layout": {
|
||||
"dashboard": "Πίνακας ελέγχου",
|
||||
"ingestions": "Εισαγωγές",
|
||||
"archived_emails": "Αρχειοθετημένα email",
|
||||
"search": "Αναζήτηση",
|
||||
"settings": "Ρυθμίσεις",
|
||||
"system": "Σύστημα",
|
||||
"users": "Χρήστες",
|
||||
"roles": "Ρόλοι",
|
||||
"logout": "Αποσύνδεση"
|
||||
},
|
||||
"archived_emails_page": {
|
||||
"title": "Αρχειοθετημένα email",
|
||||
"header": "Αρχειοθετημένα email",
|
||||
"select_ingestion_source": "Επιλέξτε μια πηγή εισαγωγής",
|
||||
"date": "Ημερομηνία",
|
||||
"subject": "Θέμα",
|
||||
"sender": "Αποστολέας",
|
||||
"inbox": "Εισερχόμενα",
|
||||
"path": "Διαδρομή",
|
||||
"actions": "Ενέργειες",
|
||||
"view": "Προβολή",
|
||||
"no_emails_found": "Δεν βρέθηκαν αρχειοθετημένα email.",
|
||||
"prev": "Προηγούμενο",
|
||||
"next": "Επόμενο"
|
||||
},
|
||||
"dashboard_page": {
|
||||
"title": "Πίνακας ελέγχου",
|
||||
"meta_description": "Επισκόπηση του αρχείου email σας.",
|
||||
"header": "Πίνακας ελέγχου",
|
||||
"create_ingestion": "Δημιουργία εισαγωγής",
|
||||
"no_ingestion_header": "Δεν έχετε ρυθμίσει καμία πηγή εισαγωγής.",
|
||||
"no_ingestion_text": "Προσθέστε μια πηγή εισαγωγής για να ξεκινήσετε την αρχειοθέτηση των εισερχομένων σας.",
|
||||
"total_emails_archived": "Συνολικά αρχειοθετημένα email",
|
||||
"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": "Δεν υπάρχουν διαθέσιμες ευρετηριασμένες πληροφορίες."
|
||||
}
|
||||
}
|
||||
}
|
||||
292
packages/frontend/src/lib/translations/en.json
Normal file
292
packages/frontend/src/lib/translations/en.json
Normal file
@@ -0,0 +1,292 @@
|
||||
{
|
||||
"app": {
|
||||
"auth": {
|
||||
"login": "Login",
|
||||
"login_tip": "Enter your email below to login to your account.",
|
||||
"email": "Email",
|
||||
"password": "Password"
|
||||
},
|
||||
"common": {
|
||||
"working": "Working"
|
||||
},
|
||||
"archive": {
|
||||
"title": "Archive",
|
||||
"no_subject": "No Subject",
|
||||
"from": "From",
|
||||
"sent": "Sent",
|
||||
"recipients": "Recipients",
|
||||
"to": "To",
|
||||
"meta_data": "Meta Data",
|
||||
"folder": "Folder",
|
||||
"tags": "Tags",
|
||||
"size": "Size",
|
||||
"email_preview": "Email Preview",
|
||||
"attachments": "Attachments",
|
||||
"download": "Download",
|
||||
"actions": "Actions",
|
||||
"download_eml": "Download Email (.eml)",
|
||||
"delete_email": "Delete Email",
|
||||
"email_thread": "Email Thread",
|
||||
"delete_confirmation_title": "Are you sure you want to delete this email?",
|
||||
"delete_confirmation_description": "This action cannot be undone and will permanently remove the email and its attachments.",
|
||||
"deleting": "Deleting",
|
||||
"confirm": "Confirm",
|
||||
"cancel": "Cancel",
|
||||
"not_found": "Email not found."
|
||||
},
|
||||
"ingestions": {
|
||||
"title": "Ingestion Sources",
|
||||
"ingestion_sources": "Ingestion Sources",
|
||||
"bulk_actions": "Bulk Actions",
|
||||
"force_sync": "Force Sync",
|
||||
"delete": "Delete",
|
||||
"create_new": "Create New",
|
||||
"name": "Name",
|
||||
"provider": "Provider",
|
||||
"status": "Status",
|
||||
"active": "Active",
|
||||
"created_at": "Created At",
|
||||
"actions": "Actions",
|
||||
"last_sync_message": "Last sync message",
|
||||
"empty": "Empty",
|
||||
"open_menu": "Open menu",
|
||||
"edit": "Edit",
|
||||
"create": "Create",
|
||||
"ingestion_source": "Ingestion Source",
|
||||
"edit_description": "Make changes to your ingestion source here.",
|
||||
"create_description": "Add a new ingestion source to start archiving emails.",
|
||||
"read": "Read",
|
||||
"docs_here": "docs here",
|
||||
"delete_confirmation_title": "Are you sure you want to delete this ingestion?",
|
||||
"delete_confirmation_description": "This will delete all archived emails, attachments, indexing, and files associated with this ingestion. If you only want to stop syncing new emails, you can pause the ingestion instead.",
|
||||
"deleting": "Deleting",
|
||||
"confirm": "Confirm",
|
||||
"cancel": "Cancel",
|
||||
"bulk_delete_confirmation_title": "Are you sure you want to delete {{count}} selected ingestions?",
|
||||
"bulk_delete_confirmation_description": "This will delete all archived emails, attachments, indexing, and files associated with these ingestions. If you only want to stop syncing new emails, you can pause the ingestions instead."
|
||||
},
|
||||
"search": {
|
||||
"title": "Search",
|
||||
"description": "Search for archived emails.",
|
||||
"email_search": "Email Search",
|
||||
"placeholder": "Search by keyword, sender, recipient...",
|
||||
"search_button": "Search",
|
||||
"search_options": "Search options",
|
||||
"strategy_fuzzy": "Fuzzy",
|
||||
"strategy_verbatim": "Verbatim",
|
||||
"strategy_frequency": "Frequency",
|
||||
"select_strategy": "Select a strategy",
|
||||
"error": "Error",
|
||||
"found_results_in": "Found {{total}} results in {{seconds}}s",
|
||||
"found_results": "Found {{total}} results",
|
||||
"from": "From",
|
||||
"to": "To",
|
||||
"in_email_body": "In email body",
|
||||
"in_attachment": "In attachment: {{filename}}",
|
||||
"prev": "Prev",
|
||||
"next": "Next"
|
||||
},
|
||||
"roles": {
|
||||
"title": "Role Management",
|
||||
"role_management": "Role Management",
|
||||
"create_new": "Create New",
|
||||
"name": "Name",
|
||||
"created_at": "Created At",
|
||||
"actions": "Actions",
|
||||
"open_menu": "Open menu",
|
||||
"view_policy": "View Policy",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"no_roles_found": "No roles found.",
|
||||
"role_policy": "Role Policy",
|
||||
"viewing_policy_for_role": "Viewing policy for role: {{name}}",
|
||||
"create": "Create",
|
||||
"role": "Role",
|
||||
"edit_description": "Make changes to the role here.",
|
||||
"create_description": "Add a new role to the system.",
|
||||
"delete_confirmation_title": "Are you sure you want to delete this role?",
|
||||
"delete_confirmation_description": "This action cannot be undone. This will permanently delete the role.",
|
||||
"deleting": "Deleting",
|
||||
"confirm": "Confirm",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"system_settings": {
|
||||
"title": "System Settings",
|
||||
"system_settings": "System Settings",
|
||||
"description": "Manage global application settings.",
|
||||
"language": "Language",
|
||||
"default_theme": "Default theme",
|
||||
"light": "Light",
|
||||
"dark": "Dark",
|
||||
"system": "System",
|
||||
"support_email": "Support Email",
|
||||
"saving": "Saving",
|
||||
"save_changes": "Save Changes"
|
||||
},
|
||||
"users": {
|
||||
"title": "User Management",
|
||||
"user_management": "User Management",
|
||||
"create_new": "Create New",
|
||||
"name": "Name",
|
||||
"email": "Email",
|
||||
"role": "Role",
|
||||
"created_at": "Created At",
|
||||
"actions": "Actions",
|
||||
"open_menu": "Open menu",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"no_users_found": "No users found.",
|
||||
"create": "Create",
|
||||
"user": "User",
|
||||
"edit_description": "Make changes to the user here.",
|
||||
"create_description": "Add a new user to the system.",
|
||||
"delete_confirmation_title": "Are you sure you want to delete this user?",
|
||||
"delete_confirmation_description": "This action cannot be undone. This will permanently delete the user and remove their data from our servers.",
|
||||
"deleting": "Deleting",
|
||||
"confirm": "Confirm",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"components": {
|
||||
"charts": {
|
||||
"emails_ingested": "Emails Ingested",
|
||||
"storage_used": "Storage Used",
|
||||
"emails": "Emails"
|
||||
},
|
||||
"common": {
|
||||
"submitting": "Submitting...",
|
||||
"submit": "Submit",
|
||||
"save": "Save"
|
||||
},
|
||||
"email_preview": {
|
||||
"loading": "Loading email preview...",
|
||||
"render_error": "Could not render email preview.",
|
||||
"not_available": "Raw .eml file not available for this email."
|
||||
},
|
||||
"footer": {
|
||||
"all_rights_reserved": "All rights reserved.",
|
||||
"new_version_available": "New version available"
|
||||
},
|
||||
"ingestion_source_form": {
|
||||
"provider_generic_imap": "Generic IMAP",
|
||||
"provider_google_workspace": "Google Workspace",
|
||||
"provider_microsoft_365": "Microsoft 365",
|
||||
"provider_pst_import": "PST Import",
|
||||
"provider_eml_import": "EML Import",
|
||||
"provider_mbox_import": "Mbox Import",
|
||||
"select_provider": "Select a provider",
|
||||
"service_account_key": "Service Account Key (JSON)",
|
||||
"service_account_key_placeholder": "Paste your service account key JSON content",
|
||||
"impersonated_admin_email": "Impersonated Admin Email",
|
||||
"client_id": "Application (Client) ID",
|
||||
"client_secret": "Client Secret Value",
|
||||
"client_secret_placeholder": "Enter the secret Value, not the Secret ID",
|
||||
"tenant_id": "Directory (Tenant) ID",
|
||||
"host": "Host",
|
||||
"port": "Port",
|
||||
"username": "Username",
|
||||
"use_tls": "Use TLS",
|
||||
"allow_insecure_cert": "Allow insecure cert",
|
||||
"pst_file": "PST File",
|
||||
"eml_file": "EML File",
|
||||
"mbox_file": "Mbox File",
|
||||
"heads_up": "Heads up!",
|
||||
"org_wide_warning": "Please note that this is an organization-wide operation. This kind of ingestions will import and index <b>all</b> email inboxes in your organization. If you want to import only specific email inboxes, use the IMAP connector.",
|
||||
"upload_failed": "Upload Failed, please try again"
|
||||
},
|
||||
"role_form": {
|
||||
"policies_json": "Policies (JSON)",
|
||||
"invalid_json": "Invalid JSON format for policies."
|
||||
},
|
||||
"theme_switcher": {
|
||||
"toggle_theme": "Toggle theme"
|
||||
},
|
||||
"user_form": {
|
||||
"select_role": "Select a role"
|
||||
}
|
||||
},
|
||||
"setup": {
|
||||
"title": "Setup",
|
||||
"description": "Set up the initial administrator account for Open Archiver.",
|
||||
"welcome": "Welcome",
|
||||
"create_admin_account": "Create the first administrator account to get started.",
|
||||
"first_name": "First name",
|
||||
"last_name": "Last name",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"creating_account": "Creating Account",
|
||||
"create_account": "Create Account"
|
||||
},
|
||||
"layout": {
|
||||
"dashboard": "Dashboard",
|
||||
"ingestions": "Ingestions",
|
||||
"archived_emails": "Archived emails",
|
||||
"search": "Search",
|
||||
"settings": "Settings",
|
||||
"system": "System",
|
||||
"users": "Users",
|
||||
"roles": "Roles",
|
||||
"api_keys": "API Keys",
|
||||
"logout": "Logout"
|
||||
},
|
||||
"api_keys_page": {
|
||||
"title": "API Keys",
|
||||
"header": "API Keys",
|
||||
"generate_new_key": "Generate New Key",
|
||||
"name": "Name",
|
||||
"key": "Key",
|
||||
"expires_at": "Expires At",
|
||||
"created_at": "Created At",
|
||||
"actions": "Actions",
|
||||
"delete": "Delete",
|
||||
"no_keys_found": "No API keys found.",
|
||||
"generate_modal_title": "Generate New API Key",
|
||||
"generate_modal_description": "Please provide a name and expiration for your new API key.",
|
||||
"expires_in": "Expires In",
|
||||
"select_expiration": "Select an expiration",
|
||||
"30_days": "30 Days",
|
||||
"60_days": "60 Days",
|
||||
"6_months": "6 Months",
|
||||
"12_months": "12 Months",
|
||||
"24_months": "24 Months",
|
||||
"generate": "Generate",
|
||||
"new_api_key": "New API Key",
|
||||
"failed_to_delete": "Failed to delete API key",
|
||||
"api_key_deleted": "API key deleted",
|
||||
"generated_title": "API Key Generated",
|
||||
"generated_message": "Your API key is generated, please copy and save it in a secure place. This key will only be shown once."
|
||||
},
|
||||
"archived_emails_page": {
|
||||
"title": "Archived emails",
|
||||
"header": "Archived Emails",
|
||||
"select_ingestion_source": "Select an ingestion source",
|
||||
"date": "Date",
|
||||
"subject": "Subject",
|
||||
"sender": "Sender",
|
||||
"inbox": "Inbox",
|
||||
"path": "Path",
|
||||
"actions": "Actions",
|
||||
"view": "View",
|
||||
"no_emails_found": "No archived emails found.",
|
||||
"prev": "Prev",
|
||||
"next": "Next"
|
||||
},
|
||||
"dashboard_page": {
|
||||
"title": "Dashboard",
|
||||
"meta_description": "Overview of your email archive.",
|
||||
"header": "Dashboard",
|
||||
"create_ingestion": "Create an ingestion",
|
||||
"no_ingestion_header": "You don't have any ingestion source set up.",
|
||||
"no_ingestion_text": "Add an ingestion source to start archiving your inboxes.",
|
||||
"total_emails_archived": "Total Emails Archived",
|
||||
"total_storage_used": "Total Storage Used",
|
||||
"failed_ingestions": "Failed Ingestions (Last 7 Days)",
|
||||
"ingestion_history": "Ingestion History",
|
||||
"no_ingestion_history": "No ingestion history available.",
|
||||
"storage_by_source": "Storage by Ingestion Source",
|
||||
"no_ingestion_sources": "No ingestion sources available.",
|
||||
"indexed_insights": "Indexed insights",
|
||||
"top_10_senders": "Top 10 Senders",
|
||||
"no_indexed_insights": "No indexed insights available."
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user