V0.5.1 dev (#341)

* OpenAPI root url fix

* Journaling OSS setup

* feat: add preserve-original-file mode for email ingestion for GoBD compliance

- Add `preserveOriginalFile` option to ingestion sources and connectors
- Stream original EML/MBOX/PST emails to temp files instead of holding
  full buffers in memory, reducing memory allocation during ingestion
- Skip attachment binary extraction and EML re-serialization when
  preserve mode is enabled; use raw file on disk as source of truth
- Update `EmailObject` to use `tempFilePath` instead of in-memory `eml`
  buffer across all connectors (EML, MBOX, PST)
- Add new database migration (0032) for `preserve_original_file` column
- Add frontend UI toggle with tooltip (tippy.js) for the new option
- Replace console.warn calls with structured pino logger in connectors

* add isjournaled property to archived_email

* feat(ingestion): add unmerge ingestion source functionality

Introduces the ability to detach a child ingestion source from its
merge group, making it a standalone root source. Changes include:

- Add `unmerge` controller method with auth and error handling
- Add POST `/v1/ingestion-sources/{id}/unmerge` route with OpenAPI docs
- Implement `IngestionService.unmerge` backend logic
- Add unmerge UI action and handler in the frontend ingestion view
- Fix bulk delete to also remove children of deleted root sources
- Update docs with new API operation and merging sources user guide

* code formatting

* Database migration file for enum `partially_active`

* Error handling improvement
This commit is contained in:
Wei S.
2026-03-30 22:29:03 +02:00
committed by GitHub
parent e5e119528f
commit 0c42b30c9e
76 changed files with 13130 additions and 531 deletions

View File

@@ -104,3 +104,24 @@ ENCRYPTION_KEY=
# Apache Tika Integration # Apache Tika Integration
# ONLY active if TIKA_URL is set # ONLY active if TIKA_URL is set
TIKA_URL=http://tika:9998 TIKA_URL=http://tika:9998
# Enterprise features (Skip this part if you are using the open-source version)
# Batch size for managing retention policy lifecycle. (This number of emails will be checked each time when retention policy scans the database. Adjust based on your system capability.)
RETENTION_BATCH_SIZE=1000
# --- SMTP Journaling (Enterprise only) ---
# The port the embedded SMTP journaling listener binds to inside the container.
# This is the port your MTA (Exchange, MS365, Postfix, etc.) will send journal reports to.
# The docker-compose.yml maps this same port on the host side by default.
SMTP_JOURNALING_PORT=2525
# The domain used to generate routing addresses for journaling sources.
# Each source gets a unique address like journal-<id>@<domain>.
# Set this to the domain/subdomain whose MX record points to this server.
SMTP_JOURNALING_DOMAIN=journal.yourdomain.com
# Maximum number of waiting jobs in the journal queue before the SMTP listener
# returns 4xx temporary failures (backpressure). The MTA will retry automatically.
JOURNAL_QUEUE_BACKPRESSURE_THRESHOLD=10000
#BullMQ worker concurrency for processing journaled emails. Increase on servers with more CPU cores.
JOURNAL_WORKER_CONCURRENCY=3

View File

@@ -6,7 +6,7 @@ services:
container_name: open-archiver container_name: open-archiver
restart: unless-stopped restart: unless-stopped
ports: ports:
- '3000:3000' # Frontend - '${PORT_FRONTEND:-3000}:3000' # Frontend
env_file: env_file:
- .env - .env
volumes: volumes:
@@ -42,7 +42,7 @@ services:
- open-archiver-net - open-archiver-net
meilisearch: meilisearch:
image: getmeili/meilisearch:v1.15 image: getmeili/meilisearch:v1.38
container_name: meilisearch container_name: meilisearch
restart: unless-stopped restart: unless-stopped
environment: environment:

View File

@@ -37,3 +37,7 @@ Manage ingestion sources — the configured connections to email providers (Goog
## Force Sync ## Force Sync
<OAOperation operationId="triggerForceSync" /> <OAOperation operationId="triggerForceSync" />
## Unmerge an Ingestion Source
<OAOperation operationId="unmergeIngestionSource" />

View File

@@ -15,7 +15,7 @@
}, },
"servers": [ "servers": [
{ {
"url": "http://localhost:3001", "url": "http://localhost:3000",
"description": "Local development" "description": "Local development"
} }
], ],

View File

@@ -10,3 +10,4 @@ Choose your provider from the list below to get started:
- [EML Import](./eml.md) - [EML Import](./eml.md)
- [PST Import](./pst.md) - [PST Import](./pst.md)
- [Mbox Import](./mbox.md) - [Mbox Import](./mbox.md)
- [Merging Ingestion Sources](./merging-sources.md)

View File

@@ -0,0 +1,105 @@
# Merging Ingestion Sources
Merged ingestion groups let you combine multiple ingestion sources so that their emails appear unified in browsing, search, and thread views. This is useful when you want to pair a historical archive (for example, a PST or Mbox import) with a live connection, or when migrating between providers.
## Concepts
| Term | Definition |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Root source** | An ingestion source where no merge parent is set. Shown as the primary row in the Ingestions table. All emails in the group are physically owned by the root. |
| **Child source** | An ingestion source merged into a root. Acts as a fetch assistant — it connects to the provider and retrieves emails, but all data is stored under the root source. |
| **Group** | A root source and all its children. All emails from every member are stored under and owned by the root. |
The hierarchy is **flat** — only one level of nesting is supported. If you merge a source into a child, the system automatically redirects the relationship to the root.
## Root Ownership — How Storage and Data Work
This is the key design principle of merged sources:
> **Child sources are assistants. They fetch emails from their provider but never own any stored data. Every email ingested by a child is written to the root source's storage folder and assigned the root source's ID in the database.**
In practical terms:
- The storage path for every email belongs to the root: `openarchiver/{root-name}-{root-id}/emails/...`
- Every `archived_emails` database row created by a child ingestion will have `ingestionSourceId` set to the **root's ID**, not the child's.
- Attachments are also stored under the root's folder and scoped to the root's ID.
- The root's **Preserve Original File** (GoBD compliance) setting is inherited by all children in the group. A child's own `preserveOriginalFile` setting is ignored during ingestion — only the root's setting applies.
This means browsing the root source's emails will show all emails from the entire group, including those fetched by child sources, without any extra configuration.
## When to Use Merged Sources
- **Historical + live**: Import a PST archive and merge it into an active IMAP or Google Workspace connection so historical and current emails appear in one unified mailbox.
- **Provider migration**: Add a new Microsoft 365 connector and merge it with your existing Google Workspace connector during a cutover period.
- **Backfill**: Import an Mbox export and merge it with a live connection to cover a gap in the archive.
## How to Merge a New Source Into an Existing One
Merging can only be configured **at creation time**.
1. Navigate to the **Ingestions** page.
2. Click **Create New** to open the ingestion source form.
3. Fill in the provider details as usual.
4. Expand the **Advanced Options** section at the bottom of the form. This section is only visible when at least one ingestion source already exists.
5. Check **Merge into existing ingestion** and select the target root source from the dropdown.
6. Click **Submit**.
The new source will run its initial import normally. Once complete, its emails will appear alongside those of the root source — all stored under the root.
## How Emails Appear When Merged
When you browse archived emails for a root source, you see all emails in the group because they are all physically owned by the root. There is nothing to aggregate — the data is already unified at the storage and database level.
The same applies to search: filtering by a root source ID returns all emails in the group.
Threads also span the merge group. If a reply arrived via a different source than the original message, it still appears in the correct thread.
## How Syncing Works
Each source syncs **independently**. The scheduler picks up all sources with status `active` or `error`, regardless of whether they are merged.
- File-based imports (PST, EML, Mbox) finish with status `imported` and are never re-synced automatically.
- Live sources (IMAP, Google Workspace, Microsoft 365) continue their normal sync cycle.
When you trigger **Force Sync** on a root source, the system also queues a sync for all non-file-based children that are currently `active` or `error`.
## Deduplication Across the Group
When ingesting emails, duplicate detection covers the **entire merge group**. If the same email (matched by its RFC `Message-ID` header or provider-specific ID) already exists anywhere in the group, it is skipped and not stored again.
## Preserve Original File (GoBD Compliance) and Merged Sources
The **Preserve Original File** setting on the root source governs the entire group. When this setting is enabled on the root:
- All emails ingested by child sources are also stored unmodified (raw EML, no attachment stripping).
- The child's own `preserveOriginalFile` setting has no effect — the root's setting is always used.
This ensures consistent compliance behaviour across the group. If you require GoBD or SEC 17a-4 compliance for an entire merged group, enable **Preserve Original File** on the root source before adding any children.
## Editing Sources in a Group
Each source in a group can be edited independently. Expand the group row in the Ingestions table by clicking the chevron, then use the **⋮** actions menu on the specific source (root or child) you want to edit.
## Unmerging a Child Source
To detach a child from its group and make it standalone:
1. Expand the group row by clicking the chevron next to the root source name.
2. Open the **⋮** actions menu on the child source.
3. Click **Unmerge**.
The child becomes an independent root source. No email data is moved or deleted.
> **Note:** Because all emails fetched by the child were stored under the root source's ID, unmerging the child does not transfer those emails. Historical emails ingested while the source was a child remain owned by the root. Only new emails ingested after unmerging will be stored under the (now standalone) child.
## Deleting Sources in a Group
- **Deleting a root source** also deletes all its children: their configuration, and all emails, attachments, storage files, and search index entries owned by the root are all removed. Because all group emails are stored under the root, this effectively removes the entire group's archive.
- **Deleting a child source** removes only the child's configuration and sync state. Emails already ingested by the child are stored under the root and are **not** deleted.
A warning is shown in the delete confirmation dialog when a root source has children.
## Known Limitations
- **Merging existing standalone sources is not supported.** You can only merge a source into a group at creation time. To merge two existing sources, you must delete one and recreate it with the merge target selected.
- **Historical data from a child source before unmerging remains with the root.** If you unmerge a child, emails it previously ingested stay owned by the root and are not migrated to the child.

View File

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

View File

@@ -31,7 +31,7 @@ const options = {
}, },
servers: [ servers: [
{ {
url: 'http://localhost:3001', url: 'http://localhost:3000',
description: 'Local development', description: 'Local development',
}, },
], ],

View File

@@ -177,6 +177,31 @@ export class IngestionController {
} }
}; };
public unmerge = async (req: Request, res: Response): Promise<Response> => {
try {
const { id } = req.params;
const userId = req.user?.sub;
if (!userId) {
return res.status(401).json({ message: req.t('errors.unauthorized') });
}
const actor = await this.userService.findById(userId);
if (!actor) {
return res.status(401).json({ message: req.t('errors.unauthorized') });
}
const updatedSource = await IngestionService.unmerge(id, actor, req.ip || 'unknown');
const safeSource = this.toSafeIngestionSource(updatedSource);
return res.status(200).json(safeSource);
} catch (error) {
logger.error({ err: error }, `Unmerge ingestion source ${req.params.id} error`);
if (error instanceof Error && error.message === 'Ingestion source not found') {
return res.status(404).json({ message: req.t('ingestion.notFound') });
} else if (error instanceof Error) {
return res.status(400).json({ message: error.message });
}
return res.status(500).json({ message: req.t('errors.internalServerError') });
}
};
public triggerForceSync = async (req: Request, res: Response): Promise<Response> => { public triggerForceSync = async (req: Request, res: Response): Promise<Response> => {
try { try {
const { id } = req.params; const { id } = req.params;

View File

@@ -291,5 +291,43 @@ export const createIngestionRouter = (
ingestionController.triggerForceSync ingestionController.triggerForceSync
); );
/**
* @openapi
* /v1/ingestion-sources/{id}/unmerge:
* post:
* summary: Unmerge a child ingestion source
* description: Detaches a child source from its merge group, making it a standalone root source. Requires `update:ingestion` permission.
* operationId: unmergeIngestionSource
* tags:
* - Ingestion
* security:
* - bearerAuth: []
* - apiKeyAuth: []
* parameters:
* - name: id
* in: path
* required: true
* schema:
* type: string
* responses:
* '200':
* description: Source unmerged. Returns the updated source.
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/SafeIngestionSource'
* '400':
* description: Source is not merged into another source.
* '401':
* $ref: '#/components/responses/Unauthorized'
* '404':
* $ref: '#/components/responses/NotFound'
*/
router.post(
'/:id/unmerge',
requirePermission('update', 'ingestion'),
ingestionController.unmerge
);
return router; return router;
}; };

View File

@@ -0,0 +1,20 @@
CREATE TYPE "public"."journaling_source_status" AS ENUM('active', 'paused');--> statement-breakpoint
ALTER TYPE "public"."ingestion_provider" ADD VALUE 'smtp_journaling';--> statement-breakpoint
ALTER TYPE "public"."audit_log_target_type" ADD VALUE 'JournalingSource' BEFORE 'RetentionPolicy';--> statement-breakpoint
CREATE TABLE "journaling_sources" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"allowed_ips" jsonb NOT NULL,
"require_tls" boolean DEFAULT true NOT NULL,
"smtp_username" text,
"smtp_password_hash" text,
"status" "journaling_source_status" DEFAULT 'active' NOT NULL,
"ingestion_source_id" uuid NOT NULL,
"routing_address" text NOT NULL,
"total_received" integer DEFAULT 0 NOT NULL,
"last_received_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "journaling_sources" ADD CONSTRAINT "journaling_sources_ingestion_source_id_ingestion_sources_id_fk" FOREIGN KEY ("ingestion_source_id") REFERENCES "public"."ingestion_sources"("id") ON DELETE cascade ON UPDATE no action;

View File

@@ -0,0 +1 @@
ALTER TABLE "ingestion_sources" ADD COLUMN "preserve_original_file" boolean DEFAULT false NOT NULL;

View File

@@ -0,0 +1,3 @@
ALTER TABLE "archived_emails" ADD COLUMN "is_journaled" boolean DEFAULT false;--> statement-breakpoint
ALTER TABLE "ingestion_sources" ADD COLUMN "merged_into_id" uuid;--> statement-breakpoint
CREATE INDEX "idx_merged_into" ON "ingestion_sources" USING btree ("merged_into_id");

View File

@@ -0,0 +1 @@
ALTER TABLE "ingestion_sources" ADD CONSTRAINT "ingestion_sources_merged_into_id_ingestion_sources_id_fk" FOREIGN KEY ("merged_into_id") REFERENCES "public"."ingestion_sources"("id") ON DELETE set null ON UPDATE no action;

View File

@@ -0,0 +1 @@
ALTER TYPE "public"."ingestion_status" ADD VALUE 'partially_active';

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -211,6 +211,41 @@
"when": 1773927678269, "when": 1773927678269,
"tag": "0029_lethal_brood", "tag": "0029_lethal_brood",
"breakpoints": true "breakpoints": true
},
{
"idx": 30,
"version": "7",
"when": 1774440788278,
"tag": "0030_strong_ultron",
"breakpoints": true
},
{
"idx": 31,
"version": "7",
"when": 1774623960683,
"tag": "0031_bouncy_boomerang",
"breakpoints": true
},
{
"idx": 32,
"version": "7",
"when": 1774709286830,
"tag": "0032_exotic_the_twelve",
"breakpoints": true
},
{
"idx": 33,
"version": "7",
"when": 1774719684064,
"tag": "0033_adorable_lockheed",
"breakpoints": true
},
{
"idx": 34,
"version": "7",
"when": 1774900882674,
"tag": "0034_stiff_toad",
"breakpoints": true
} }
] ]
} }

View File

@@ -10,3 +10,4 @@ export * from './schema/api-keys';
export * from './schema/audit-logs'; export * from './schema/audit-logs';
export * from './schema/enums'; export * from './schema/enums';
export * from './schema/sync-sessions'; export * from './schema/sync-sessions';
export * from './schema/journaling-sources';

View File

@@ -26,6 +26,7 @@ export const archivedEmails = pgTable(
isIndexed: boolean('is_indexed').notNull().default(false), isIndexed: boolean('is_indexed').notNull().default(false),
hasAttachments: boolean('has_attachments').notNull().default(false), hasAttachments: boolean('has_attachments').notNull().default(false),
isOnLegalHold: boolean('is_on_legal_hold').notNull().default(false), isOnLegalHold: boolean('is_on_legal_hold').notNull().default(false),
isJournaled: boolean('is_journaled').default(false),
archivedAt: timestamp('archived_at', { withTimezone: true }).notNull().defaultNow(), archivedAt: timestamp('archived_at', { withTimezone: true }).notNull().defaultNow(),
path: text('path'), path: text('path'),
tags: jsonb('tags'), tags: jsonb('tags'),

View File

@@ -1,4 +1,14 @@
import { jsonb, pgEnum, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core'; import {
boolean,
index,
jsonb,
pgEnum,
pgTable,
text,
timestamp,
uuid,
type AnyPgColumn,
} from 'drizzle-orm/pg-core';
import { users } from './users'; import { users } from './users';
import { relations } from 'drizzle-orm'; import { relations } from 'drizzle-orm';
@@ -9,6 +19,7 @@ export const ingestionProviderEnum = pgEnum('ingestion_provider', [
'pst_import', 'pst_import',
'eml_import', 'eml_import',
'mbox_import', 'mbox_import',
'smtp_journaling',
]); ]);
export const ingestionStatusEnum = pgEnum('ingestion_status', [ export const ingestionStatusEnum = pgEnum('ingestion_status', [
@@ -20,26 +31,47 @@ export const ingestionStatusEnum = pgEnum('ingestion_status', [
'importing', 'importing',
'auth_success', 'auth_success',
'imported', 'imported',
'partially_active',
]); ]);
export const ingestionSources = pgTable('ingestion_sources', { export const ingestionSources = pgTable(
id: uuid('id').primaryKey().defaultRandom(), 'ingestion_sources',
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }), {
name: text('name').notNull(), id: uuid('id').primaryKey().defaultRandom(),
provider: ingestionProviderEnum('provider').notNull(), userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }),
credentials: text('credentials'), name: text('name').notNull(),
status: ingestionStatusEnum('status').notNull().default('pending_auth'), provider: ingestionProviderEnum('provider').notNull(),
lastSyncStartedAt: timestamp('last_sync_started_at', { withTimezone: true }), credentials: text('credentials'),
lastSyncFinishedAt: timestamp('last_sync_finished_at', { withTimezone: true }), status: ingestionStatusEnum('status').notNull().default('pending_auth'),
lastSyncStatusMessage: text('last_sync_status_message'), lastSyncStartedAt: timestamp('last_sync_started_at', { withTimezone: true }),
syncState: jsonb('sync_state'), lastSyncFinishedAt: timestamp('last_sync_finished_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), lastSyncStatusMessage: text('last_sync_status_message'),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), syncState: jsonb('sync_state'),
}); preserveOriginalFile: boolean('preserve_original_file').notNull().default(false),
/** Self-referencing FK for merge groups. When set, this source is a child
* whose emails are logically grouped with the root source. Flat hierarchy only. */
mergedIntoId: uuid('merged_into_id').references((): AnyPgColumn => ingestionSources.id, {
onDelete: 'set null',
}),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [index('idx_merged_into').on(table.mergedIntoId)]
);
export const ingestionSourcesRelations = relations(ingestionSources, ({ one }) => ({ export const ingestionSourcesRelations = relations(ingestionSources, ({ one, many }) => ({
user: one(users, { user: one(users, {
fields: [ingestionSources.userId], fields: [ingestionSources.userId],
references: [users.id], references: [users.id],
}), }),
/** The root source this child is merged into (null if this is a root). */
mergedInto: one(ingestionSources, {
fields: [ingestionSources.mergedIntoId],
references: [ingestionSources.id],
relationName: 'mergedChildren',
}),
/** Child sources that are merged into this root. */
children: many(ingestionSources, {
relationName: 'mergedChildren',
}),
})); }));

View File

@@ -0,0 +1,47 @@
import {
boolean,
integer,
jsonb,
pgEnum,
pgTable,
text,
timestamp,
uuid,
} from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
import { ingestionSources } from './ingestion-sources';
export const journalingSourceStatusEnum = pgEnum('journaling_source_status', ['active', 'paused']);
export const journalingSources = pgTable('journaling_sources', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull(),
/** CIDR blocks or IP addresses allowed to send journal reports */
allowedIps: jsonb('allowed_ips').notNull().$type<string[]>(),
/** Whether to reject non-TLS connections (GDPR compliance) */
requireTls: boolean('require_tls').notNull().default(true),
/** Optional SMTP AUTH username */
smtpUsername: text('smtp_username'),
/** Bcrypt-hashed SMTP AUTH password */
smtpPasswordHash: text('smtp_password_hash'),
status: journalingSourceStatusEnum('status').notNull().default('active'),
/** The backing ingestion source that owns all archived emails */
ingestionSourceId: uuid('ingestion_source_id')
.notNull()
.references(() => ingestionSources.id, { onDelete: 'cascade' }),
/** Persisted SMTP routing address generated at creation time (immutable unless regenerated) */
routingAddress: text('routing_address').notNull(),
/** Running count of emails received via this journaling endpoint */
totalReceived: integer('total_received').notNull().default(0),
/** Timestamp of the last email received */
lastReceivedAt: timestamp('last_received_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
});
export const journalingSourcesRelations = relations(journalingSources, ({ one }) => ({
ingestionSource: one(ingestionSources, {
fields: [journalingSources.ingestionSourceId],
references: [ingestionSources.id],
}),
}));

View File

@@ -57,7 +57,7 @@ export default async (job: Job<ISyncCycleFinishedJob>) => {
// syncState was already merged incrementally by each process-mailbox job via // syncState was already merged incrementally by each process-mailbox job via
// SyncSessionService.recordMailboxResult() — no deepmerge needed here. // SyncSessionService.recordMailboxResult() — no deepmerge needed here.
await IngestionService.update(ingestionSourceId, { await IngestionService.update(ingestionSourceId, {
status, status: source.status === 'paused' ? 'paused' : status, // Don't override paused status
lastSyncFinishedAt: new Date(), lastSyncFinishedAt: new Date(),
lastSyncStatusMessage: message, lastSyncStatusMessage: message,
}); });

View File

@@ -14,7 +14,8 @@
"demoMode": "Тази операция не е разрешена в демо режим", "demoMode": "Тази операция не е разрешена в демо режим",
"unauthorized": "Неоторизирано", "unauthorized": "Неоторизирано",
"unknown": "Възникна неизвестна грешка", "unknown": "Възникна неизвестна грешка",
"noPermissionToAction": "Нямате разрешение да извършите текущото действие." "noPermissionToAction": "Нямате разрешение да извършите текущото действие.",
"deletion_disabled": "Изтриването е деактивирано за тази инстанция."
}, },
"user": { "user": {
"notFound": "Потребителят не е открит", "notFound": "Потребителят не е открит",
@@ -65,5 +66,12 @@
}, },
"api": { "api": {
"requestBodyInvalid": "Невалидно съдържание на заявката." "requestBodyInvalid": "Невалидно съдържание на заявката."
},
"upload": {
"invalid_request": "Заявката за качване е невалидна или неправилно формирана.",
"stream_error": "При получаването на файла възникна грешка. Моля, опитайте отново.",
"parse_error": "Данните на качения файл не можаха да бъдат обработени.",
"storage_error": "Каченият файл не можа да бъде запазен. Моля, опитайте отново.",
"connection_error": "Връзката беше прекъсната по време на качването."
} }
} }

View File

@@ -59,5 +59,19 @@
"invalidFilePath": "Ungültiger Dateipfad", "invalidFilePath": "Ungültiger Dateipfad",
"fileNotFound": "Datei nicht gefunden", "fileNotFound": "Datei nicht gefunden",
"downloadError": "Fehler beim Herunterladen der Datei" "downloadError": "Fehler beim Herunterladen der Datei"
},
"apiKeys": {
"generateSuccess": "API-Schlüssel erfolgreich generiert.",
"deleteSuccess": "API-Schlüssel erfolgreich gelöscht."
},
"api": {
"requestBodyInvalid": "Ungültiger Anfrage-Body."
},
"upload": {
"invalid_request": "Die Upload-Anfrage ist ungültig oder fehlerhaft.",
"stream_error": "Beim Empfangen der Datei ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.",
"parse_error": "Die hochgeladenen Dateidaten konnten nicht verarbeitet werden.",
"storage_error": "Die hochgeladene Datei konnte nicht gespeichert werden. Bitte versuchen Sie es erneut.",
"connection_error": "Die Verbindung wurde während des Uploads unterbrochen."
} }
} }

View File

@@ -14,7 +14,8 @@
"demoMode": "Αυτή η λειτουργία δεν επιτρέπεται σε λειτουργία επίδειξης.", "demoMode": "Αυτή η λειτουργία δεν επιτρέπεται σε λειτουργία επίδειξης.",
"unauthorized": "Μη εξουσιοδοτημένο", "unauthorized": "Μη εξουσιοδοτημένο",
"unknown": "Παρουσιάστηκε ένα άγνωστο σφάλμα", "unknown": "Παρουσιάστηκε ένα άγνωστο σφάλμα",
"noPermissionToAction": "Δεν έχετε την άδεια να εκτελέσετε την τρέχουσα ενέργεια." "noPermissionToAction": "Δεν έχετε την άδεια να εκτελέσετε την τρέχουσα ενέργεια.",
"deletion_disabled": "Η διαγραφή είναι απενεργοποιημένη για αυτήν την εγκατάσταση."
}, },
"user": { "user": {
"notFound": "Ο χρήστης δεν βρέθηκε", "notFound": "Ο χρήστης δεν βρέθηκε",
@@ -58,5 +59,19 @@
"invalidFilePath": "Μη έγκυρη διαδρομή αρχείου", "invalidFilePath": "Μη έγκυρη διαδρομή αρχείου",
"fileNotFound": "Το αρχείο δεν βρέθηκε", "fileNotFound": "Το αρχείο δεν βρέθηκε",
"downloadError": "Σφάλμα κατά τη λήψη του αρχείου" "downloadError": "Σφάλμα κατά τη λήψη του αρχείου"
},
"apiKeys": {
"generateSuccess": "Το κλειδί API δημιουργήθηκε με επιτυχία.",
"deleteSuccess": "Το κλειδί API διαγράφηκε με επιτυχία."
},
"api": {
"requestBodyInvalid": "Μη έγκυρο σώμα αιτήματος."
},
"upload": {
"invalid_request": "Το αίτημα αποστολής είναι μη έγκυρο ή κακοσχηματισμένο.",
"stream_error": "Παρουσιάστηκε σφάλμα κατά τη λήψη του αρχείου. Παρακαλώ δοκιμάστε ξανά.",
"parse_error": "Αποτυχία επεξεργασίας των δεδομένων του αναρτημένου αρχείου.",
"storage_error": "Αποτυχία αποθήκευσης του αναρτημένου αρχείου. Παρακαλώ δοκιμάστε ξανά.",
"connection_error": "Η σύνδεση χάθηκε κατά τη διάρκεια της αποστολής."
} }
} }

View File

@@ -14,7 +14,8 @@
"demoMode": "Esta operación no está permitida en modo de demostración.", "demoMode": "Esta operación no está permitida en modo de demostración.",
"unauthorized": "No autorizado", "unauthorized": "No autorizado",
"unknown": "Ocurrió un error desconocido", "unknown": "Ocurrió un error desconocido",
"noPermissionToAction": "No tienes permiso para realizar la acción actual." "noPermissionToAction": "No tienes permiso para realizar la acción actual.",
"deletion_disabled": "La eliminación está deshabilitada para esta instancia."
}, },
"user": { "user": {
"notFound": "Usuario no encontrado", "notFound": "Usuario no encontrado",
@@ -58,5 +59,19 @@
"invalidFilePath": "Ruta de archivo no válida", "invalidFilePath": "Ruta de archivo no válida",
"fileNotFound": "Archivo no encontrado", "fileNotFound": "Archivo no encontrado",
"downloadError": "Error al descargar el archivo" "downloadError": "Error al descargar el archivo"
},
"apiKeys": {
"generateSuccess": "Clave de API generada correctamente.",
"deleteSuccess": "Clave de API eliminada correctamente."
},
"api": {
"requestBodyInvalid": "El cuerpo de la solicitud no es válido."
},
"upload": {
"invalid_request": "La solicitud de carga no es válida o está malformada.",
"stream_error": "Se produjo un error al recibir el archivo. Por favor, inténtalo de nuevo.",
"parse_error": "No se pudieron procesar los datos del archivo cargado.",
"storage_error": "No se pudo guardar el archivo cargado. Por favor, inténtalo de nuevo.",
"connection_error": "La conexión se perdió durante la carga."
} }
} }

View File

@@ -14,7 +14,8 @@
"demoMode": "See toiming pole demorežiimis lubatud.", "demoMode": "See toiming pole demorežiimis lubatud.",
"unauthorized": "Volitamata", "unauthorized": "Volitamata",
"unknown": "Ilmnes tundmatu viga", "unknown": "Ilmnes tundmatu viga",
"noPermissionToAction": "Teil pole praeguse toimingu tegemiseks luba." "noPermissionToAction": "Teil pole praeguse toimingu tegemiseks luba.",
"deletion_disabled": "Kustutamine on selle eksemplari jaoks keelatud."
}, },
"user": { "user": {
"notFound": "Kasutajat ei leitud", "notFound": "Kasutajat ei leitud",
@@ -58,5 +59,19 @@
"invalidFilePath": "Kehtetu faili tee", "invalidFilePath": "Kehtetu faili tee",
"fileNotFound": "Faili ei leitud", "fileNotFound": "Faili ei leitud",
"downloadError": "Faili allalaadimisel ilmnes viga" "downloadError": "Faili allalaadimisel ilmnes viga"
},
"apiKeys": {
"generateSuccess": "API-võti genereeriti edukalt.",
"deleteSuccess": "API-võti kustutati edukalt."
},
"api": {
"requestBodyInvalid": "Vigane päringu sisu."
},
"upload": {
"invalid_request": "Üleslaadimispäring on vigane või valesti vormindatud.",
"stream_error": "Faili vastuvõtmisel ilmnes viga. Palun proovige uuesti.",
"parse_error": "Üleslaaditud faili andmeid ei õnnestunud töödelda.",
"storage_error": "Üleslaaditud faili salvestamine ebaõnnestus. Palun proovige uuesti.",
"connection_error": "Ühendus katkes üleslaadimise ajal."
} }
} }

View File

@@ -14,7 +14,8 @@
"demoMode": "Cette opération n'est pas autorisée en mode démo.", "demoMode": "Cette opération n'est pas autorisée en mode démo.",
"unauthorized": "Non autorisé", "unauthorized": "Non autorisé",
"unknown": "Une erreur inconnue s'est produite", "unknown": "Une erreur inconnue s'est produite",
"noPermissionToAction": "Vous n'avez pas la permission d'effectuer l'action en cours." "noPermissionToAction": "Vous n'avez pas la permission d'effectuer l'action en cours.",
"deletion_disabled": "La suppression est désactivée pour cette instance."
}, },
"user": { "user": {
"notFound": "Utilisateur non trouvé", "notFound": "Utilisateur non trouvé",
@@ -58,5 +59,19 @@
"invalidFilePath": "Chemin de fichier invalide", "invalidFilePath": "Chemin de fichier invalide",
"fileNotFound": "Fichier non trouvé", "fileNotFound": "Fichier non trouvé",
"downloadError": "Erreur lors du téléchargement du fichier" "downloadError": "Erreur lors du téléchargement du fichier"
},
"apiKeys": {
"generateSuccess": "Clé API générée avec succès.",
"deleteSuccess": "Clé API supprimée avec succès."
},
"api": {
"requestBodyInvalid": "Corps de la requête invalide."
},
"upload": {
"invalid_request": "La demande d'envoi est invalide ou malformée.",
"stream_error": "Une erreur s'est produite lors de la réception du fichier. Veuillez réessayer.",
"parse_error": "Impossible de traiter les données du fichier envoyé.",
"storage_error": "Impossible d'enregistrer le fichier envoyé. Veuillez réessayer.",
"connection_error": "La connexion a été perdue pendant l'envoi."
} }
} }

View File

@@ -5,7 +5,7 @@
"alreadyCompleted": "La configurazione è già stata completata." "alreadyCompleted": "La configurazione è già stata completata."
}, },
"login": { "login": {
"emailAndPasswordRequired": "Email and password are required", "emailAndPasswordRequired": "Email e password sono obbligatorie",
"invalidCredentials": "Credenziali non valide" "invalidCredentials": "Credenziali non valide"
} }
}, },
@@ -14,7 +14,8 @@
"demoMode": "Questa operazione non è consentita in modalità demo.", "demoMode": "Questa operazione non è consentita in modalità demo.",
"unauthorized": "Non autorizzato", "unauthorized": "Non autorizzato",
"unknown": "Si è verificato un errore sconosciuto", "unknown": "Si è verificato un errore sconosciuto",
"noPermissionToAction": "Non hai il permesso di eseguire l'azione corrente." "noPermissionToAction": "Non hai il permesso di eseguire l'azione corrente.",
"deletion_disabled": "L'eliminazione è disabilitata per questa istanza."
}, },
"user": { "user": {
"notFound": "Utente non trovato", "notFound": "Utente non trovato",
@@ -58,5 +59,19 @@
"invalidFilePath": "Percorso del file non valido", "invalidFilePath": "Percorso del file non valido",
"fileNotFound": "File non trovato", "fileNotFound": "File non trovato",
"downloadError": "Errore durante il download del file" "downloadError": "Errore durante il download del file"
},
"apiKeys": {
"generateSuccess": "Chiave API generata con successo.",
"deleteSuccess": "Chiave API eliminata con successo."
},
"api": {
"requestBodyInvalid": "Corpo della richiesta non valido."
},
"upload": {
"invalid_request": "La richiesta di caricamento non è valida o è malformata.",
"stream_error": "Si è verificato un errore durante la ricezione del file. Si prega di riprovare.",
"parse_error": "Impossibile elaborare i dati del file caricato.",
"storage_error": "Impossibile salvare il file caricato. Si prega di riprovare.",
"connection_error": "La connessione è stata persa durante il caricamento."
} }
} }

View File

@@ -14,7 +14,8 @@
"demoMode": "この操作はデモモードでは許可されていません。", "demoMode": "この操作はデモモードでは許可されていません。",
"unauthorized": "不正なアクセス", "unauthorized": "不正なアクセス",
"unknown": "不明なエラーが発生しました", "unknown": "不明なエラーが発生しました",
"noPermissionToAction": "現在の操作を実行する権限がありません。" "noPermissionToAction": "現在の操作を実行する権限がありません。",
"deletion_disabled": "このインスタンスでは削除が無効になっています。"
}, },
"user": { "user": {
"notFound": "ユーザーが見つかりません", "notFound": "ユーザーが見つかりません",
@@ -58,5 +59,19 @@
"invalidFilePath": "無効なファイルパス", "invalidFilePath": "無効なファイルパス",
"fileNotFound": "ファイルが見つかりません", "fileNotFound": "ファイルが見つかりません",
"downloadError": "ファイルのダウンロード中にエラーが発生しました" "downloadError": "ファイルのダウンロード中にエラーが発生しました"
},
"apiKeys": {
"generateSuccess": "APIキーが正常に生成されました。",
"deleteSuccess": "APIキーが正常に削除されました。"
},
"api": {
"requestBodyInvalid": "リクエストボディが無効です。"
},
"upload": {
"invalid_request": "アップロードリクエストが無効または不正な形式です。",
"stream_error": "ファイルの受信中にエラーが発生しました。もう一度お試しください。",
"parse_error": "アップロードされたファイルのデータを処理できませんでした。",
"storage_error": "アップロードされたファイルを保存できませんでした。もう一度お試しください。",
"connection_error": "アップロード中に接続が切断されました。"
} }
} }

View File

@@ -14,7 +14,8 @@
"demoMode": "Deze bewerking is niet toegestaan in de demomodus.", "demoMode": "Deze bewerking is niet toegestaan in de demomodus.",
"unauthorized": "Ongeautoriseerd", "unauthorized": "Ongeautoriseerd",
"unknown": "Er is een onbekende fout opgetreden", "unknown": "Er is een onbekende fout opgetreden",
"noPermissionToAction": "U heeft geen toestemming om de huidige actie uit te voeren." "noPermissionToAction": "U heeft geen toestemming om de huidige actie uit te voeren.",
"deletion_disabled": "Verwijderen is uitgeschakeld voor deze instantie."
}, },
"user": { "user": {
"notFound": "Gebruiker niet gevonden", "notFound": "Gebruiker niet gevonden",
@@ -58,5 +59,19 @@
"invalidFilePath": "Ongeldig bestandspad", "invalidFilePath": "Ongeldig bestandspad",
"fileNotFound": "Bestand niet gevonden", "fileNotFound": "Bestand niet gevonden",
"downloadError": "Fout bij het downloaden van het bestand" "downloadError": "Fout bij het downloaden van het bestand"
},
"apiKeys": {
"generateSuccess": "API-sleutel succesvol gegenereerd.",
"deleteSuccess": "API-sleutel succesvol verwijderd."
},
"api": {
"requestBodyInvalid": "Ongeldige inhoud van het verzoek."
},
"upload": {
"invalid_request": "Het uploadverzoek is ongeldig of onjuist geformatteerd.",
"stream_error": "Er is een fout opgetreden bij het ontvangen van het bestand. Probeer het opnieuw.",
"parse_error": "De gegevens van het geüploade bestand konden niet worden verwerkt.",
"storage_error": "Het geüploade bestand kon niet worden opgeslagen. Probeer het opnieuw.",
"connection_error": "De verbinding is verbroken tijdens het uploaden."
} }
} }

View File

@@ -14,7 +14,8 @@
"demoMode": "Esta operação não é permitida no modo de demonstração.", "demoMode": "Esta operação não é permitida no modo de demonstração.",
"unauthorized": "Não autorizado", "unauthorized": "Não autorizado",
"unknown": "Ocorreu um erro desconhecido", "unknown": "Ocorreu um erro desconhecido",
"noPermissionToAction": "Você não tem permissão para executar a ação atual." "noPermissionToAction": "Você não tem permissão para executar a ação atual.",
"deletion_disabled": "A exclusão está desabilitada para esta instância."
}, },
"user": { "user": {
"notFound": "Usuário não encontrado", "notFound": "Usuário não encontrado",
@@ -58,5 +59,19 @@
"invalidFilePath": "Caminho de arquivo inválido", "invalidFilePath": "Caminho de arquivo inválido",
"fileNotFound": "Arquivo não encontrado", "fileNotFound": "Arquivo não encontrado",
"downloadError": "Erro ao baixar o arquivo" "downloadError": "Erro ao baixar o arquivo"
},
"apiKeys": {
"generateSuccess": "Chave de API gerada com sucesso.",
"deleteSuccess": "Chave de API excluída com sucesso."
},
"api": {
"requestBodyInvalid": "Corpo da requisição inválido."
},
"upload": {
"invalid_request": "A requisição de envio é inválida ou malformada.",
"stream_error": "Ocorreu um erro ao receber o arquivo. Por favor, tente novamente.",
"parse_error": "Não foi possível processar os dados do arquivo enviado.",
"storage_error": "Não foi possível salvar o arquivo enviado. Por favor, tente novamente.",
"connection_error": "A conexão foi perdida durante o envio."
} }
} }

View File

@@ -1,4 +1,4 @@
import { count, desc, eq, asc, and } from 'drizzle-orm'; import { count, desc, eq, asc, and, inArray } from 'drizzle-orm';
import { db } from '../database'; import { db } from '../database';
import { import {
archivedEmails, archivedEmails,
@@ -16,11 +16,13 @@ import type {
} from '@open-archiver/types'; } from '@open-archiver/types';
import { StorageService } from './StorageService'; import { StorageService } from './StorageService';
import { SearchService } from './SearchService'; import { SearchService } from './SearchService';
import { IngestionService } from './IngestionService';
import type { Readable } from 'stream'; import type { Readable } from 'stream';
import { AuditService } from './AuditService'; import { AuditService } from './AuditService';
import { User } from '@open-archiver/types'; import { User } from '@open-archiver/types';
import { checkDeletionEnabled } from '../helpers/deletionGuard'; import { checkDeletionEnabled } from '../helpers/deletionGuard';
import { RetentionHook } from '../hooks/RetentionHook'; import { RetentionHook } from '../hooks/RetentionHook';
import { logger } from '../config/logger';
interface DbRecipients { interface DbRecipients {
to: { name: string; address: string }[]; to: { name: string; address: string }[];
@@ -58,7 +60,14 @@ export class ArchivedEmailService {
): Promise<PaginatedArchivedEmails> { ): Promise<PaginatedArchivedEmails> {
const offset = (page - 1) * limit; const offset = (page - 1) * limit;
const { drizzleFilter } = await FilterBuilder.create(userId, 'archive', 'read'); const { drizzleFilter } = await FilterBuilder.create(userId, 'archive', 'read');
const where = and(eq(archivedEmails.ingestionSourceId, ingestionSourceId), drizzleFilter);
// Expand to the full merge group so emails from children appear when browsing a root source
const groupIds = await IngestionService.findGroupSourceIds(ingestionSourceId);
const sourceFilter =
groupIds.length === 1
? eq(archivedEmails.ingestionSourceId, groupIds[0])
: inArray(archivedEmails.ingestionSourceId, groupIds);
const where = and(sourceFilter, drizzleFilter);
const countQuery = db const countQuery = db
.select({ .select({
@@ -136,12 +145,15 @@ export class ArchivedEmailService {
let threadEmails: ThreadEmail[] = []; let threadEmails: ThreadEmail[] = [];
// Expand thread query to the full merge group so threads can span across merged sources
if (email.threadId) { if (email.threadId) {
const groupIds = await IngestionService.findGroupSourceIds(email.ingestionSourceId);
const sourceFilter =
groupIds.length === 1
? eq(archivedEmails.ingestionSourceId, groupIds[0])
: inArray(archivedEmails.ingestionSourceId, groupIds);
threadEmails = await db.query.archivedEmails.findMany({ threadEmails = await db.query.archivedEmails.findMany({
where: and( where: and(eq(archivedEmails.threadId, email.threadId), sourceFilter),
eq(archivedEmails.threadId, email.threadId),
eq(archivedEmails.ingestionSourceId, email.ingestionSourceId)
),
orderBy: [asc(archivedEmails.sentAt)], orderBy: [asc(archivedEmails.sentAt)],
columns: { columns: {
id: true, id: true,
@@ -263,7 +275,13 @@ export class ArchivedEmailService {
} }
} }
} catch (error) { } catch (error) {
console.error('Failed to delete email attachments', error); logger.error(
{
emailId,
error: error instanceof Error ? error.message : String(error),
},
'Failed to delete email attachments'
);
throw new Error('Failed to delete email attachments'); throw new Error('Failed to delete email attachments');
} }
} }

View File

@@ -17,6 +17,18 @@ import { PSTConnector } from './ingestion-connectors/PSTConnector';
import { EMLConnector } from './ingestion-connectors/EMLConnector'; import { EMLConnector } from './ingestion-connectors/EMLConnector';
import { MboxConnector } from './ingestion-connectors/MboxConnector'; import { MboxConnector } from './ingestion-connectors/MboxConnector';
/**
* Options passed to connectors to control ingestion behaviour.
* Currently used to skip extracting full attachment binary content
* in preserve-original-file (GoBD) mode, where attachments are never
* stored separately and the raw EML is kept as-is.
*/
export interface ConnectorOptions {
/** When true, connectors omit attachment binary content from the
* yielded EmailObject to avoid unnecessary memory allocation. */
preserveOriginalFile: boolean;
}
// Define a common interface for all connectors // Define a common interface for all connectors
export interface IEmailConnector { export interface IEmailConnector {
testConnection(): Promise<boolean>; testConnection(): Promise<boolean>;
@@ -34,20 +46,26 @@ export class EmailProviderFactory {
static createConnector(source: IngestionSource): IEmailConnector { static createConnector(source: IngestionSource): IEmailConnector {
// Credentials are now decrypted by the IngestionService before being passed around // Credentials are now decrypted by the IngestionService before being passed around
const credentials = source.credentials; const credentials = source.credentials;
const options: ConnectorOptions = {
preserveOriginalFile: source.preserveOriginalFile ?? false,
};
switch (source.provider) { switch (source.provider) {
case 'google_workspace': case 'google_workspace':
return new GoogleWorkspaceConnector(credentials as GoogleWorkspaceCredentials); return new GoogleWorkspaceConnector(
credentials as GoogleWorkspaceCredentials,
options
);
case 'microsoft_365': case 'microsoft_365':
return new MicrosoftConnector(credentials as Microsoft365Credentials); return new MicrosoftConnector(credentials as Microsoft365Credentials, options);
case 'generic_imap': case 'generic_imap':
return new ImapConnector(credentials as GenericImapCredentials); return new ImapConnector(credentials as GenericImapCredentials, options);
case 'pst_import': case 'pst_import':
return new PSTConnector(credentials as PSTImportCredentials); return new PSTConnector(credentials as PSTImportCredentials, options);
case 'eml_import': case 'eml_import':
return new EMLConnector(credentials as EMLImportCredentials); return new EMLConnector(credentials as EMLImportCredentials, options);
case 'mbox_import': case 'mbox_import':
return new MboxConnector(credentials as MboxImportCredentials); return new MboxConnector(credentials as MboxImportCredentials, options);
default: default:
throw new Error(`Unsupported provider: ${source.provider}`); throw new Error(`Unsupported provider: ${source.provider}`);
} }

View File

@@ -12,7 +12,7 @@ import { DatabaseService } from './DatabaseService';
import { archivedEmails, attachments, emailAttachments } from '../database/schema'; import { archivedEmails, attachments, emailAttachments } from '../database/schema';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { streamToBuffer } from '../helpers/streamToBuffer'; import { streamToBuffer } from '../helpers/streamToBuffer';
import { simpleParser } from 'mailparser'; import { simpleParser, type Attachment as ParsedAttachment } from 'mailparser';
import { logger } from '../config/logger'; import { logger } from '../config/logger';
interface DbRecipients { interface DbRecipients {
@@ -351,9 +351,13 @@ export class IndexingService {
content: textContent, content: textContent,
}); });
} catch (error) { } catch (error) {
console.error( logger.error(
`Failed to extract text from attachment: ${attachment.filename}`, {
error filename: attachment.filename,
mimeType: attachment.mimeType,
error: error instanceof Error ? error.message : String(error),
},
'Failed to extract text from attachment'
); );
} }
} }
@@ -378,8 +382,6 @@ export class IndexingService {
attachments: Attachment[], attachments: Attachment[],
userEmail: string //the owner of the email inbox userEmail: string //the owner of the email inbox
): Promise<EmailDocument> { ): Promise<EmailDocument> {
const attachmentContents = await this.extractAttachmentContents(attachments);
const emailBodyStream = await this.storageService.get(email.storagePath); const emailBodyStream = await this.storageService.get(email.storagePath);
const emailBodyBuffer = await streamToBuffer(emailBodyStream); const emailBodyBuffer = await streamToBuffer(emailBodyStream);
const parsedEmail = await simpleParser(emailBodyBuffer); const parsedEmail = await simpleParser(emailBodyBuffer);
@@ -389,6 +391,20 @@ export class IndexingService {
(await extractText(emailBodyBuffer, 'text/plain')) || (await extractText(emailBodyBuffer, 'text/plain')) ||
''; '';
// If there are linked attachment records, extract text from storage (default mode).
// Otherwise, if the email has attachments but no records (preserve original file mode),
// extract attachment text directly from the parsed EML body.
let attachmentContents: { filename: string; content: string }[];
if (attachments.length > 0) {
attachmentContents = await this.extractAttachmentContents(attachments);
} else if (email.hasAttachments && parsedEmail.attachments.length > 0) {
attachmentContents = await this.extractInlineAttachmentContents(
parsedEmail.attachments
);
} else {
attachmentContents = [];
}
const recipients = email.recipients as DbRecipients; const recipients = email.recipients as DbRecipients;
// console.log('email.userEmail', email.userEmail); // console.log('email.userEmail', email.userEmail);
return { return {
@@ -406,6 +422,40 @@ export class IndexingService {
}; };
} }
/**
* Extracts text content from attachments embedded in the parsed EML.
* Used in preserve-original-file (GoBD) mode where no separate attachment
* records exist — the full MIME body is stored unmodified, so we parse
* attachments directly from the in-memory parsed email.
*/
private async extractInlineAttachmentContents(
parsedAttachments: ParsedAttachment[]
): Promise<{ filename: string; content: string }[]> {
const extracted: { filename: string; content: string }[] = [];
for (const attachment of parsedAttachments) {
try {
const textContent = await extractText(
attachment.content,
attachment.contentType || ''
);
extracted.push({
filename: attachment.filename || 'untitled',
content: textContent,
});
} catch (error) {
logger.warn(
{
filename: attachment.filename,
mimeType: attachment.contentType,
error: error instanceof Error ? error.message : String(error),
},
'Failed to extract text from inline attachment in preserve-original mode'
);
}
}
return extracted;
}
private async extractAttachmentContents( private async extractAttachmentContents(
attachments: Attachment[] attachments: Attachment[]
): Promise<{ filename: string; content: string }[]> { ): Promise<{ filename: string; content: string }[]> {

View File

@@ -8,7 +8,7 @@ import type {
IngestionProvider, IngestionProvider,
PendingEmail, PendingEmail,
} from '@open-archiver/types'; } from '@open-archiver/types';
import { and, desc, eq, or } from 'drizzle-orm'; import { and, desc, eq, inArray, or } from 'drizzle-orm';
import { CryptoService } from './CryptoService'; import { CryptoService } from './CryptoService';
import { EmailProviderFactory } from './EmailProviderFactory'; import { EmailProviderFactory } from './EmailProviderFactory';
import { ingestionQueue } from '../jobs/queues'; import { ingestionQueue } from '../jobs/queues';
@@ -22,6 +22,7 @@ import {
emailAttachments, emailAttachments,
} from '../database/schema'; } from '../database/schema';
import { createHash, randomUUID } from 'crypto'; import { createHash, randomUUID } from 'crypto';
import { readFile, unlink } from 'fs/promises';
import { logger } from '../config/logger'; import { logger } from '../config/logger';
import { SearchService } from './SearchService'; import { SearchService } from './SearchService';
import { config } from '../config/index'; import { config } from '../config/index';
@@ -60,14 +61,22 @@ export class IngestionService {
actor: User, actor: User,
actorIp: string actorIp: string
): Promise<IngestionSource> { ): Promise<IngestionSource> {
const { providerConfig, ...rest } = dto; const { providerConfig, mergedIntoId, ...rest } = dto;
const encryptedCredentials = CryptoService.encryptObject(providerConfig); const encryptedCredentials = CryptoService.encryptObject(providerConfig);
// Resolve merge target: if mergedIntoId points to a child, follow to the root.
let resolvedMergedIntoId: string | undefined;
if (mergedIntoId) {
const target = await this.findById(mergedIntoId);
resolvedMergedIntoId = target.mergedIntoId ?? target.id;
}
const valuesToInsert = { const valuesToInsert = {
userId, userId,
...rest, ...rest,
status: 'pending_auth' as const, status: 'pending_auth' as const,
credentials: encryptedCredentials, credentials: encryptedCredentials,
mergedIntoId: resolvedMergedIntoId ?? null,
}; };
const [newSource] = await db.insert(ingestionSources).values(valuesToInsert).returning(); const [newSource] = await db.insert(ingestionSources).values(valuesToInsert).returning();
@@ -206,6 +215,60 @@ export class IngestionService {
return decryptedSource; return decryptedSource;
} }
/**
* Returns all ingestionSourceId values in a merge group given any member's ID.
* If the source is standalone (no parent, no children), returns just its own ID.
*/
public static async findGroupSourceIds(sourceId: string): Promise<string[]> {
const source = await this.findById(sourceId);
const rootId = source.mergedIntoId ?? source.id;
const children = await db
.select({ id: ingestionSources.id })
.from(ingestionSources)
.where(eq(ingestionSources.mergedIntoId, rootId));
return [rootId, ...children.map((c) => c.id)];
}
/**
* Detaches a child source from its merge group, making it standalone.
*/
public static async unmerge(
id: string,
actor: User,
actorIp: string
): Promise<IngestionSource> {
const source = await this.findById(id);
if (!source.mergedIntoId) {
throw new Error('Source is not merged into another source.');
}
const [updated] = await db
.update(ingestionSources)
.set({ mergedIntoId: null })
.where(eq(ingestionSources.id, id))
.returning();
await this.auditService.createAuditLog({
actorIdentifier: actor.id,
actionType: 'UPDATE',
targetType: 'IngestionSource',
targetId: id,
actorIp,
details: {
action: 'unmerge',
previousParentId: source.mergedIntoId,
},
});
const decrypted = this.decryptSource(updated);
if (!decrypted) {
throw new Error('Failed to decrypt unmerged source.');
}
return decrypted;
}
public static async delete( public static async delete(
id: string, id: string,
actor: User, actor: User,
@@ -220,6 +283,18 @@ export class IngestionService {
throw new Error('Ingestion source not found'); throw new Error('Ingestion source not found');
} }
// If this is a root source with children, delete all children first
if (!source.mergedIntoId) {
const children = await db
.select({ id: ingestionSources.id })
.from(ingestionSources)
.where(eq(ingestionSources.mergedIntoId, id));
for (const child of children) {
await this.delete(child.id, actor, actorIp, force);
}
}
// Delete all emails and attachments from storage // Delete all emails and attachments from storage
const storage = new StorageService(); const storage = new StorageService();
const emailPath = `${config.storage.openArchiverFolderName}/${source.name.replaceAll(' ', '-')}-${source.id}/`; const emailPath = `${config.storage.openArchiverFolderName}/${source.name.replaceAll(' ', '-')}-${source.id}/`;
@@ -326,6 +401,32 @@ export class IngestionService {
}); });
await ingestionQueue.add('continuous-sync', { ingestionSourceId: source.id }); await ingestionQueue.add('continuous-sync', { ingestionSourceId: source.id });
// If this is a root source, also trigger sync for all non-file-based active/error children
if (!source.mergedIntoId) {
const fileBasedProviders = this.returnFileBasedIngestions();
const children = await db
.select({
id: ingestionSources.id,
provider: ingestionSources.provider,
status: ingestionSources.status,
})
.from(ingestionSources)
.where(eq(ingestionSources.mergedIntoId, id));
for (const child of children) {
if (
!fileBasedProviders.includes(child.provider) &&
(child.status === 'active' || child.status === 'error')
) {
logger.info(
{ childId: child.id, parentId: id },
'Cascading force sync to child source.'
);
await ingestionQueue.add('continuous-sync', { ingestionSourceId: child.id });
}
}
}
} }
public static async performBulkImport( public static async performBulkImport(
@@ -395,14 +496,23 @@ export class IngestionService {
* Pre-fetch duplicate check to avoid unnecessary API calls during ingestion. * Pre-fetch duplicate check to avoid unnecessary API calls during ingestion.
* Checks both providerMessageId (for Google/Microsoft API IDs) and * Checks both providerMessageId (for Google/Microsoft API IDs) and
* messageIdHeader (for IMAP/PST/EML/Mbox RFC Message-IDs and pre-migration rows). * messageIdHeader (for IMAP/PST/EML/Mbox RFC Message-IDs and pre-migration rows).
*
* The check is scoped to the full merge group so that emails already archived
* by a sibling source are not re-downloaded and stored again.
*/ */
public static async doesEmailExist( public static async doesEmailExist(
messageId: string, messageId: string,
ingestionSourceId: string ingestionSourceId: string
): Promise<boolean> { ): Promise<boolean> {
const groupIds = await this.findGroupSourceIds(ingestionSourceId);
const sourceFilter =
groupIds.length === 1
? eq(archivedEmails.ingestionSourceId, groupIds[0])
: inArray(archivedEmails.ingestionSourceId, groupIds);
const existingEmail = await db.query.archivedEmails.findFirst({ const existingEmail = await db.query.archivedEmails.findFirst({
where: and( where: and(
eq(archivedEmails.ingestionSourceId, ingestionSourceId), sourceFilter,
or( or(
eq(archivedEmails.providerMessageId, messageId), eq(archivedEmails.providerMessageId, messageId),
eq(archivedEmails.messageIdHeader, messageId) eq(archivedEmails.messageIdHeader, messageId)
@@ -420,6 +530,16 @@ export class IngestionService {
userEmail: string userEmail: string
): Promise<PendingEmail | null> { ): Promise<PendingEmail | null> {
try { try {
// Read the raw bytes from the temp file written by the connector
const rawEmlBuffer = await readFile(email.tempFilePath);
// If this source is a child in a merge group, redirect all storage and DB
// ownership to the root source. Child sources are "assistants" — they fetch
// emails on behalf of the root but never own any stored content.
const effectiveSource = source.mergedIntoId
? await IngestionService.findById(source.mergedIntoId)
: source;
// Generate a unique message ID for the email. If the email already has a message-id header, use that. // 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. // Otherwise, generate a new one based on the email's hash, source ID, and email ID.
const messageIdHeader = email.headers.get('message-id'); const messageIdHeader = email.headers.get('message-id');
@@ -431,15 +551,20 @@ export class IngestionService {
} }
if (!messageId) { if (!messageId) {
messageId = `generated-${createHash('sha256') messageId = `generated-${createHash('sha256')
.update(email.eml ?? Buffer.from(email.body, 'utf-8')) .update(rawEmlBuffer)
.digest('hex')}-${source.id}-${email.id}`; .digest('hex')}-${source.id}-${email.id}`;
} }
// Check if an email with the same message ID has already been imported for the current ingestion source. This is to prevent duplicate imports when an email is present in multiple mailboxes (e.g., "Inbox" and "All Mail"). // Check if an email with the same message ID has already been imported
// within the merge group. This prevents duplicate imports when the same
// email exists in multiple mailboxes or across merged ingestion sources.
const groupIds = await IngestionService.findGroupSourceIds(source.id);
const groupSourceFilter =
groupIds.length === 1
? eq(archivedEmails.ingestionSourceId, groupIds[0])
: inArray(archivedEmails.ingestionSourceId, groupIds);
const existingEmail = await db.query.archivedEmails.findFirst({ const existingEmail = await db.query.archivedEmails.findFirst({
where: and( where: and(eq(archivedEmails.messageIdHeader, messageId), groupSourceFilter),
eq(archivedEmails.messageIdHeader, messageId),
eq(archivedEmails.ingestionSourceId, source.id)
),
}); });
if (existingEmail) { if (existingEmail) {
@@ -450,19 +575,81 @@ export class IngestionService {
return null; return null;
} }
const rawEmlBuffer = email.eml ?? Buffer.from(email.body, 'utf-8'); const sanitizedPath = email.path ? email.path : '';
// Strip non-inline attachments from the .eml to avoid double-storing // Use effectiveSource (root) for storage path and DB ownership.
// Child sources are assistants; all content physically belongs to the root.
const emailPath = `${config.storage.openArchiverFolderName}/${effectiveSource.name.replaceAll(' ', '-')}-${effectiveSource.id}/emails/${sanitizedPath}${email.id}.eml`;
// GoBD / Preserve Original File mode: store the unmodified raw EML as-is.
// No attachment stripping, no attachment table records — the full MIME body
// including attachments is preserved in the single .eml file.
// Use the root (effectiveSource) compliance mode as authoritative.
if (effectiveSource.preserveOriginalFile) {
const emailHash = createHash('sha256').update(rawEmlBuffer).digest('hex');
// Message-level deduplication by file hash, scoped to the effective (root) source
const hashDuplicate = await db.query.archivedEmails.findFirst({
where: and(
eq(archivedEmails.storageHashSha256, emailHash),
eq(archivedEmails.ingestionSourceId, effectiveSource.id)
),
columns: { id: true },
});
if (hashDuplicate) {
logger.info(
{ emailHash, ingestionSourceId: effectiveSource.id },
'Skipping duplicate email (hash-level dedup, preserve original mode)'
);
return null;
}
// Store the unmodified raw buffer — no modifications
await storage.put(emailPath, rawEmlBuffer);
const [archivedEmail] = await db
.insert(archivedEmails)
.values({
// Always assign to root (effectiveSource)
ingestionSourceId: effectiveSource.id,
userEmail,
threadId: email.threadId,
messageIdHeader: messageId,
providerMessageId: email.id,
sentAt: email.receivedAt,
subject: email.subject,
senderName: email.from[0]?.name,
senderEmail: email.from[0]?.address,
recipients: {
to: email.to,
cc: email.cc,
bcc: email.bcc,
},
storagePath: emailPath,
storageHashSha256: emailHash,
sizeBytes: rawEmlBuffer.length,
hasAttachments: email.attachments.length > 0,
path: email.path,
tags: email.tags,
})
.returning();
return {
archivedEmailId: archivedEmail.id,
};
}
// Default mode: strip non-inline attachments from the .eml to avoid double-storing
// attachment data (attachments are stored separately). // attachment data (attachments are stored separately).
const emlBuffer = await stripAttachmentsFromEml(rawEmlBuffer); const emlBuffer = await stripAttachmentsFromEml(rawEmlBuffer);
const emailHash = createHash('sha256').update(emlBuffer).digest('hex'); const emailHash = createHash('sha256').update(emlBuffer).digest('hex');
const sanitizedPath = email.path ? email.path : '';
const emailPath = `${config.storage.openArchiverFolderName}/${source.name.replaceAll(' ', '-')}-${source.id}/emails/${sanitizedPath}${email.id}.eml`;
await storage.put(emailPath, emlBuffer); await storage.put(emailPath, emlBuffer);
const [archivedEmail] = await db const [archivedEmail] = await db
.insert(archivedEmails) .insert(archivedEmails)
.values({ .values({
ingestionSourceId: source.id, // Always assign to root (effectiveSource)
ingestionSourceId: effectiveSource.id,
userEmail, userEmail,
threadId: email.threadId, threadId: email.threadId,
messageIdHeader: messageId, messageIdHeader: messageId,
@@ -492,54 +679,45 @@ export class IngestionService {
.update(attachmentBuffer) .update(attachmentBuffer)
.digest('hex'); .digest('hex');
// Check if an attachment with the same hash already exists for this source // Check if an attachment with the same hash already exists for the root source
const existingAttachment = await db.query.attachments.findFirst({ const existingAttachment = await db.query.attachments.findFirst({
where: and( where: and(
eq(attachmentsSchema.contentHashSha256, attachmentHash), eq(attachmentsSchema.contentHashSha256, attachmentHash),
eq(attachmentsSchema.ingestionSourceId, source.id) eq(attachmentsSchema.ingestionSourceId, effectiveSource.id)
), ),
}); });
let storagePath: string; let attachmentId: string;
if (existingAttachment) { if (existingAttachment) {
// If it exists, reuse the storage path and don't save the file again attachmentId = existingAttachment.id;
storagePath = existingAttachment.storagePath;
logger.info( logger.info(
{ {
attachmentHash, attachmentHash,
ingestionSourceId: source.id, ingestionSourceId: effectiveSource.id,
reusedPath: storagePath, reusedPath: existingAttachment.storagePath,
}, },
'Reusing existing attachment file for deduplication.' 'Reusing existing attachment file for deduplication.'
); );
} else { } else {
// If it's a new attachment, create a unique path and save it // New attachment: store under the root source's folder
const uniqueId = randomUUID().slice(0, 7); const uniqueId = randomUUID().slice(0, 7);
storagePath = `${config.storage.openArchiverFolderName}/${source.name.replaceAll(' ', '-')}-${source.id}/attachments/${uniqueId}-${attachment.filename}`; const storagePath = `${config.storage.openArchiverFolderName}/${effectiveSource.name.replaceAll(' ', '-')}-${effectiveSource.id}/attachments/${uniqueId}-${attachment.filename}`;
await storage.put(storagePath, attachmentBuffer);
}
let attachmentRecord = existingAttachment;
if (!attachmentRecord) {
// If it's a new attachment, create a unique path and save it
const uniqueId = randomUUID().slice(0, 5);
const storagePath = `${config.storage.openArchiverFolderName}/${source.name.replaceAll(' ', '-')}-${source.id}/attachments/${uniqueId}-${attachment.filename}`;
await storage.put(storagePath, attachmentBuffer); await storage.put(storagePath, attachmentBuffer);
// Insert a new attachment record const [newRecord] = await db
[attachmentRecord] = await db
.insert(attachmentsSchema) .insert(attachmentsSchema)
.values({ .values({
filename: attachment.filename, filename: attachment.filename,
mimeType: attachment.contentType, mimeType: attachment.contentType,
sizeBytes: attachment.size, sizeBytes: attachment.size,
contentHashSha256: attachmentHash, contentHashSha256: attachmentHash,
storagePath: storagePath, storagePath,
ingestionSourceId: source.id, // Always assign attachment ownership to root (effectiveSource)
ingestionSourceId: effectiveSource.id,
}) })
.returning(); .returning();
attachmentId = newRecord.id;
} }
// Link the attachment record (either new or existing) to the email // Link the attachment record (either new or existing) to the email
@@ -547,7 +725,7 @@ export class IngestionService {
.insert(emailAttachments) .insert(emailAttachments)
.values({ .values({
emailId: archivedEmail.id, emailId: archivedEmail.id,
attachmentId: attachmentRecord.id, attachmentId,
}) })
.onConflictDoNothing(); .onConflictDoNothing();
} }
@@ -564,6 +742,14 @@ export class IngestionService {
ingestionSourceId: source.id, ingestionSourceId: source.id,
}); });
return null; return null;
} finally {
// Always clean up the temp file, regardless of success or failure
await unlink(email.tempFilePath).catch((err) =>
logger.warn(
{ err, tempFilePath: email.tempFilePath },
'Failed to delete temp email file'
)
);
} }
} }
} }

View File

@@ -9,6 +9,7 @@ import type {
} from '@open-archiver/types'; } from '@open-archiver/types';
import { FilterBuilder } from './FilterBuilder'; import { FilterBuilder } from './FilterBuilder';
import { AuditService } from './AuditService'; import { AuditService } from './AuditService';
import { IngestionService } from './IngestionService';
export class SearchService { export class SearchService {
private client: MeiliSearch; private client: MeiliSearch;
@@ -75,13 +76,24 @@ export class SearchService {
}; };
if (filters) { if (filters) {
const filterStrings = Object.entries(filters).map(([key, value]) => { const filterParts: string[] = [];
if (typeof value === 'string') { for (const [key, value] of Object.entries(filters)) {
return `${key} = '${value}'`; // Expand ingestionSourceId to the full merge group
if (key === 'ingestionSourceId' && typeof value === 'string') {
const groupIds = await IngestionService.findGroupSourceIds(value);
if (groupIds.length === 1) {
filterParts.push(`ingestionSourceId = '${groupIds[0]}'`);
} else {
const inList = groupIds.map((id) => `'${id}'`).join(', ');
filterParts.push(`ingestionSourceId IN [${inList}]`);
}
} else if (typeof value === 'string') {
filterParts.push(`${key} = '${value}'`);
} else {
filterParts.push(`${key} = ${value}`);
} }
return `${key} = ${value}`; }
}); searchParams.filter = filterParts.join(' AND ');
searchParams.filter = filterStrings.join(' AND ');
} }
// Create a filter based on the user's permissions. // Create a filter based on the user's permissions.

View File

@@ -5,10 +5,11 @@ import type {
SyncState, SyncState,
MailboxUser, MailboxUser,
} from '@open-archiver/types'; } from '@open-archiver/types';
import type { IEmailConnector } from '../EmailProviderFactory'; import type { IEmailConnector, ConnectorOptions } from '../EmailProviderFactory';
import { simpleParser, ParsedMail, Attachment, AddressObject } from 'mailparser'; import { simpleParser, ParsedMail, Attachment, AddressObject } from 'mailparser';
import { logger } from '../../config/logger'; import { logger } from '../../config/logger';
import { getThreadId } from './helpers/utils'; import { getThreadId } from './helpers/utils';
import { writeEmailToTempFile } from './helpers/tempFile';
import { StorageService } from '../StorageService'; import { StorageService } from '../StorageService';
import { Readable } from 'stream'; import { Readable } from 'stream';
import { createHash } from 'crypto'; import { createHash } from 'crypto';
@@ -27,8 +28,13 @@ const streamToBuffer = (stream: Readable): Promise<Buffer> => {
export class EMLConnector implements IEmailConnector { export class EMLConnector implements IEmailConnector {
private storage: StorageService; private storage: StorageService;
private options: ConnectorOptions;
constructor(private credentials: EMLImportCredentials) { constructor(
private credentials: EMLImportCredentials,
options?: ConnectorOptions
) {
this.options = options ?? { preserveOriginalFile: false };
this.storage = new StorageService(); this.storage = new StorageService();
} }
@@ -266,13 +272,18 @@ export class EMLConnector implements IEmailConnector {
emlBuffer = await streamToBuffer(input); emlBuffer = await streamToBuffer(input);
} }
const tempFilePath = await writeEmailToTempFile(emlBuffer);
const parsedEmail: ParsedMail = await simpleParser(emlBuffer); const parsedEmail: ParsedMail = await simpleParser(emlBuffer);
// In preserve-original mode, skip extracting full attachment binary content
// to avoid unnecessary memory allocation — the raw EML on disk is the source of truth.
const attachments = parsedEmail.attachments.map((attachment: Attachment) => ({ const attachments = parsedEmail.attachments.map((attachment: Attachment) => ({
filename: attachment.filename || 'untitled', filename: attachment.filename || 'untitled',
contentType: attachment.contentType, contentType: attachment.contentType,
size: attachment.size, size: attachment.size,
content: attachment.content as Buffer, content: this.options.preserveOriginalFile
? Buffer.alloc(0)
: (attachment.content as Buffer),
})); }));
const mapAddresses = ( const mapAddresses = (
@@ -313,7 +324,7 @@ export class EMLConnector implements IEmailConnector {
headers: parsedEmail.headers, headers: parsedEmail.headers,
attachments, attachments,
receivedAt: parsedEmail.date || new Date(), receivedAt: parsedEmail.date || new Date(),
eml: emlBuffer, tempFilePath,
path, path,
}; };
} }

View File

@@ -7,10 +7,11 @@ import type {
SyncState, SyncState,
MailboxUser, MailboxUser,
} from '@open-archiver/types'; } from '@open-archiver/types';
import type { IEmailConnector } from '../EmailProviderFactory'; import type { IEmailConnector, ConnectorOptions } from '../EmailProviderFactory';
import { logger } from '../../config/logger'; import { logger } from '../../config/logger';
import { simpleParser, ParsedMail, Attachment, AddressObject, Headers } from 'mailparser'; import { simpleParser, ParsedMail, Attachment, AddressObject, Headers } from 'mailparser';
import { getThreadId } from './helpers/utils'; import { getThreadId } from './helpers/utils';
import { writeEmailToTempFile } from './helpers/tempFile';
/** /**
* A connector for Google Workspace that uses a service account with domain-wide delegation * A connector for Google Workspace that uses a service account with domain-wide delegation
@@ -20,9 +21,11 @@ export class GoogleWorkspaceConnector implements IEmailConnector {
private credentials: GoogleWorkspaceCredentials; private credentials: GoogleWorkspaceCredentials;
private serviceAccountCreds: { client_email: string; private_key: string }; private serviceAccountCreds: { client_email: string; private_key: string };
private newHistoryId: string | undefined; private newHistoryId: string | undefined;
private options: ConnectorOptions;
constructor(credentials: GoogleWorkspaceCredentials) { constructor(credentials: GoogleWorkspaceCredentials, options?: ConnectorOptions) {
this.credentials = credentials; this.credentials = credentials;
this.options = options ?? { preserveOriginalFile: false };
try { try {
// Pre-parse the JSON key to catch errors early. // Pre-parse the JSON key to catch errors early.
const parsedKey = JSON.parse(this.credentials.serviceAccountKeyJson); const parsedKey = JSON.parse(this.credentials.serviceAccountKeyJson);
@@ -201,48 +204,13 @@ export class GoogleWorkspaceConnector implements IEmailConnector {
if (msgResponse.data.raw) { if (msgResponse.data.raw) {
const rawEmail = Buffer.from(msgResponse.data.raw, 'base64url'); const rawEmail = Buffer.from(msgResponse.data.raw, 'base64url');
const parsedEmail: ParsedMail = await simpleParser(rawEmail); yield this.parseRawEmail(
const attachments = parsedEmail.attachments.map( rawEmail,
(attachment: Attachment) => ({ msgResponse.data.id!,
filename: attachment.filename || 'untitled', userEmail,
contentType: attachment.contentType, labels.path,
size: attachment.size, labels.tags
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 || '',
}))
);
};
const threadId = getThreadId(parsedEmail.headers);
yield {
id: msgResponse.data.id!,
threadId,
userEmail: userEmail,
eml: rawEmail,
from: mapAddresses(parsedEmail.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(),
path: labels.path,
tags: labels.tags,
};
} }
} catch (error: any) { } catch (error: any) {
if (error.code === 404) { if (error.code === 404) {
@@ -326,45 +294,13 @@ export class GoogleWorkspaceConnector implements IEmailConnector {
if (msgResponse.data.raw) { if (msgResponse.data.raw) {
const rawEmail = Buffer.from(msgResponse.data.raw, 'base64url'); const rawEmail = Buffer.from(msgResponse.data.raw, 'base64url');
const parsedEmail: ParsedMail = await simpleParser(rawEmail); yield this.parseRawEmail(
const attachments = parsedEmail.attachments.map( rawEmail,
(attachment: Attachment) => ({ msgResponse.data.id!,
filename: attachment.filename || 'untitled', userEmail,
contentType: attachment.contentType, labels.path,
size: attachment.size, labels.tags
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 || '' }))
);
};
const threadId = getThreadId(parsedEmail.headers);
yield {
id: msgResponse.data.id!,
threadId,
userEmail: userEmail,
eml: rawEmail,
from: mapAddresses(parsedEmail.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(),
path: labels.path,
tags: labels.tags,
};
} }
} catch (error: any) { } catch (error: any) {
if (error.code === 404) { if (error.code === 404) {
@@ -382,6 +318,63 @@ export class GoogleWorkspaceConnector implements IEmailConnector {
} while (pageToken); } while (pageToken);
} }
/**
* Parses a raw email buffer into an EmailObject, extracting metadata via simpleParser.
* In preserve-original mode, attachment binary content is omitted to save memory.
*/
private async parseRawEmail(
rawEmail: Buffer,
messageId: string,
userEmail: string,
path: string,
tags: string[]
): Promise<EmailObject> {
const tempFilePath = await writeEmailToTempFile(rawEmail);
const parsedEmail: ParsedMail = await simpleParser(rawEmail);
// In preserve-original mode, skip extracting full attachment binary content
// to avoid unnecessary memory allocation — the raw EML on disk is the source of truth.
const attachments = parsedEmail.attachments.map((attachment: Attachment) => ({
filename: attachment.filename || 'untitled',
contentType: attachment.contentType,
size: attachment.size,
content: this.options.preserveOriginalFile
? Buffer.alloc(0)
: (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 || '' }))
);
};
const threadId = getThreadId(parsedEmail.headers);
return {
id: messageId,
threadId,
userEmail,
tempFilePath,
from: mapAddresses(parsedEmail.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(),
path,
tags,
};
}
public getUpdatedSyncState(userEmail: string): SyncState { public getUpdatedSyncState(userEmail: string): SyncState {
if (!this.newHistoryId) { if (!this.newHistoryId) {
return {}; return {};

View File

@@ -5,19 +5,25 @@ import type {
SyncState, SyncState,
MailboxUser, MailboxUser,
} from '@open-archiver/types'; } from '@open-archiver/types';
import type { IEmailConnector } from '../EmailProviderFactory'; import type { IEmailConnector, ConnectorOptions } from '../EmailProviderFactory';
import { ImapFlow } from 'imapflow'; import { ImapFlow } from 'imapflow';
import { simpleParser, ParsedMail, Attachment, AddressObject, Headers } from 'mailparser'; import { simpleParser, ParsedMail, Attachment, AddressObject, Headers } from 'mailparser';
import { config } from '../../config'; import { config } from '../../config';
import { logger } from '../../config/logger'; import { logger } from '../../config/logger';
import { getThreadId } from './helpers/utils'; import { getThreadId } from './helpers/utils';
import { writeEmailToTempFile } from './helpers/tempFile';
export class ImapConnector implements IEmailConnector { export class ImapConnector implements IEmailConnector {
private client: ImapFlow; private client: ImapFlow;
private newMaxUids: { [mailboxPath: string]: number } = {}; private newMaxUids: { [mailboxPath: string]: number } = {};
private statusMessage: string | undefined; private statusMessage: string | undefined;
private options: ConnectorOptions;
constructor(private credentials: GenericImapCredentials) { constructor(
private credentials: GenericImapCredentials,
options?: ConnectorOptions
) {
this.options = options ?? { preserveOriginalFile: false };
this.client = this.createClient(); this.client = this.createClient();
} }
@@ -298,12 +304,21 @@ export class ImapConnector implements IEmailConnector {
} }
private async parseMessage(msg: any, mailboxPath: string): Promise<EmailObject> { private async parseMessage(msg: any, mailboxPath: string): Promise<EmailObject> {
// Write raw bytes to temp file to keep large buffers off the JS heap
const tempFilePath = await writeEmailToTempFile(msg.source);
// Parse only for metadata extraction (read-only)
const parsedEmail: ParsedMail = await simpleParser(msg.source); const parsedEmail: ParsedMail = await simpleParser(msg.source);
// In preserve-original mode, skip extracting full attachment binary content
// to avoid unnecessary memory allocation — the raw EML on disk is the source of truth.
const attachments = parsedEmail.attachments.map((attachment: Attachment) => ({ const attachments = parsedEmail.attachments.map((attachment: Attachment) => ({
filename: attachment.filename || 'untitled', filename: attachment.filename || 'untitled',
contentType: attachment.contentType, contentType: attachment.contentType,
size: attachment.size, size: attachment.size,
content: attachment.content as Buffer, content: this.options.preserveOriginalFile
? Buffer.alloc(0)
: (attachment.content as Buffer),
})); }));
const mapAddresses = ( const mapAddresses = (
@@ -331,7 +346,7 @@ export class ImapConnector implements IEmailConnector {
headers: parsedEmail.headers, headers: parsedEmail.headers,
attachments, attachments,
receivedAt: parsedEmail.date || new Date(), receivedAt: parsedEmail.date || new Date(),
eml: msg.source, tempFilePath,
path: mailboxPath, path: mailboxPath,
}; };
} }

View File

@@ -5,10 +5,11 @@ import type {
SyncState, SyncState,
MailboxUser, MailboxUser,
} from '@open-archiver/types'; } from '@open-archiver/types';
import type { IEmailConnector } from '../EmailProviderFactory'; import type { IEmailConnector, ConnectorOptions } from '../EmailProviderFactory';
import { simpleParser, ParsedMail, Attachment, AddressObject } from 'mailparser'; import { simpleParser, ParsedMail, Attachment, AddressObject } from 'mailparser';
import { logger } from '../../config/logger'; import { logger } from '../../config/logger';
import { getThreadId } from './helpers/utils'; import { getThreadId } from './helpers/utils';
import { writeEmailToTempFile } from './helpers/tempFile';
import { StorageService } from '../StorageService'; import { StorageService } from '../StorageService';
import { Readable, Transform } from 'stream'; import { Readable, Transform } from 'stream';
import { createHash } from 'crypto'; import { createHash } from 'crypto';
@@ -54,8 +55,13 @@ class MboxSplitter extends Transform {
export class MboxConnector implements IEmailConnector { export class MboxConnector implements IEmailConnector {
private storage: StorageService; private storage: StorageService;
private options: ConnectorOptions;
constructor(private credentials: MboxImportCredentials) { constructor(
private credentials: MboxImportCredentials,
options?: ConnectorOptions
) {
this.options = options ?? { preserveOriginalFile: false };
this.storage = new StorageService(); this.storage = new StorageService();
} }
@@ -164,14 +170,42 @@ export class MboxConnector implements IEmailConnector {
} }
} }
private async parseMessage(emlBuffer: Buffer, path: string): Promise<EmailObject> { /**
* Strips the mbox "From " envelope line from the raw buffer.
* The mbox format prepends each message with a "From sender@... timestamp\n"
* line that is NOT part of the RFC 5322 message. Storing this line in the
* .eml would produce an invalid file and corrupt the SHA-256 hash for GoBD
* compliance purposes.
*/
private stripMboxEnvelope(buffer: Buffer): Buffer {
// The "From " line ends at the first \n — everything after is the real RFC 5322 message.
const fromPrefix = Buffer.from('From ');
if (buffer.subarray(0, fromPrefix.length).equals(fromPrefix)) {
const newlineIndex = buffer.indexOf(0x0a); // \n
if (newlineIndex !== -1) {
return buffer.subarray(newlineIndex + 1);
}
}
return buffer;
}
private async parseMessage(rawMboxBuffer: Buffer, path: string): Promise<EmailObject> {
// Strip the mbox "From " envelope line before writing to temp file.
// This line is an mbox transport artifact, not part of the RFC 5322 message.
const emlBuffer = this.stripMboxEnvelope(rawMboxBuffer);
const tempFilePath = await writeEmailToTempFile(emlBuffer);
const parsedEmail: ParsedMail = await simpleParser(emlBuffer); const parsedEmail: ParsedMail = await simpleParser(emlBuffer);
// In preserve-original mode, skip extracting full attachment binary content
// to avoid unnecessary memory allocation — the raw EML on disk is the source of truth.
const attachments = parsedEmail.attachments.map((attachment: Attachment) => ({ const attachments = parsedEmail.attachments.map((attachment: Attachment) => ({
filename: attachment.filename || 'untitled', filename: attachment.filename || 'untitled',
contentType: attachment.contentType, contentType: attachment.contentType,
size: attachment.size, size: attachment.size,
content: attachment.content as Buffer, content: this.options.preserveOriginalFile
? Buffer.alloc(0)
: (attachment.content as Buffer),
})); }));
const mapAddresses = ( const mapAddresses = (
@@ -226,7 +260,7 @@ export class MboxConnector implements IEmailConnector {
headers: parsedEmail.headers, headers: parsedEmail.headers,
attachments, attachments,
receivedAt: parsedEmail.date || new Date(), receivedAt: parsedEmail.date || new Date(),
eml: emlBuffer, tempFilePath,
path: finalPath, path: finalPath,
}; };
} }

View File

@@ -6,9 +6,10 @@ import type {
SyncState, SyncState,
MailboxUser, MailboxUser,
} from '@open-archiver/types'; } from '@open-archiver/types';
import type { IEmailConnector } from '../EmailProviderFactory'; import type { IEmailConnector, ConnectorOptions } from '../EmailProviderFactory';
import { logger } from '../../config/logger'; import { logger } from '../../config/logger';
import { simpleParser, ParsedMail, Attachment, AddressObject } from 'mailparser'; import { simpleParser, ParsedMail, Attachment, AddressObject } from 'mailparser';
import { writeEmailToTempFile } from './helpers/tempFile';
import { ConfidentialClientApplication, Configuration, LogLevel } from '@azure/msal-node'; import { ConfidentialClientApplication, Configuration, LogLevel } from '@azure/msal-node';
import { Client } from '@microsoft/microsoft-graph-client'; import { Client } from '@microsoft/microsoft-graph-client';
import type { User, MailFolder } from 'microsoft-graph'; import type { User, MailFolder } from 'microsoft-graph';
@@ -23,9 +24,11 @@ export class MicrosoftConnector implements IEmailConnector {
private graphClient: Client; private graphClient: Client;
// Store delta tokens for each folder during a sync operation. // Store delta tokens for each folder during a sync operation.
private newDeltaTokens: { [folderId: string]: string }; private newDeltaTokens: { [folderId: string]: string };
private options: ConnectorOptions;
constructor(credentials: Microsoft365Credentials) { constructor(credentials: Microsoft365Credentials, options?: ConnectorOptions) {
this.credentials = credentials; this.credentials = credentials;
this.options = options ?? { preserveOriginalFile: false };
this.newDeltaTokens = {}; // Initialize as an empty object this.newDeltaTokens = {}; // Initialize as an empty object
const msalConfig: Configuration = { const msalConfig: Configuration = {
@@ -299,12 +302,18 @@ export class MicrosoftConnector implements IEmailConnector {
userEmail: string, userEmail: string,
path: string path: string
): Promise<EmailObject> { ): Promise<EmailObject> {
const tempFilePath = await writeEmailToTempFile(rawEmail);
const parsedEmail: ParsedMail = await simpleParser(rawEmail); const parsedEmail: ParsedMail = await simpleParser(rawEmail);
// In preserve-original mode, skip extracting full attachment binary content
// to avoid unnecessary memory allocation — the raw EML on disk is the source of truth.
const attachments = parsedEmail.attachments.map((attachment: Attachment) => ({ const attachments = parsedEmail.attachments.map((attachment: Attachment) => ({
filename: attachment.filename || 'untitled', filename: attachment.filename || 'untitled',
contentType: attachment.contentType, contentType: attachment.contentType,
size: attachment.size, size: attachment.size,
content: attachment.content as Buffer, content: this.options.preserveOriginalFile
? Buffer.alloc(0)
: (attachment.content as Buffer),
})); }));
const mapAddresses = ( const mapAddresses = (
addresses: AddressObject | AddressObject[] | undefined addresses: AddressObject | AddressObject[] | undefined
@@ -319,7 +328,7 @@ export class MicrosoftConnector implements IEmailConnector {
return { return {
id: messageId, id: messageId,
userEmail: userEmail, userEmail: userEmail,
eml: rawEmail, tempFilePath,
from: mapAddresses(parsedEmail.from), from: mapAddresses(parsedEmail.from),
to: mapAddresses(parsedEmail.to), to: mapAddresses(parsedEmail.to),
cc: mapAddresses(parsedEmail.cc), cc: mapAddresses(parsedEmail.cc),

View File

@@ -5,13 +5,13 @@ import type {
SyncState, SyncState,
MailboxUser, MailboxUser,
} from '@open-archiver/types'; } from '@open-archiver/types';
import type { IEmailConnector } from '../EmailProviderFactory'; import type { IEmailConnector, ConnectorOptions } from '../EmailProviderFactory';
import { PSTFile, PSTFolder, PSTMessage } from 'pst-extractor'; import { PSTFile, PSTFolder, PSTMessage } from 'pst-extractor';
import { simpleParser, ParsedMail, Attachment, AddressObject } from 'mailparser'; import { simpleParser, ParsedMail, Attachment, AddressObject } from 'mailparser';
import { logger } from '../../config/logger'; import { logger } from '../../config/logger';
import { getThreadId } from './helpers/utils'; import { getThreadId } from './helpers/utils';
import { writeEmailToTempFile } from './helpers/tempFile';
import { StorageService } from '../StorageService'; import { StorageService } from '../StorageService';
import { Readable } from 'stream';
import { createHash } from 'crypto'; import { createHash } from 'crypto';
import { join } from 'path'; import { join } from 'path';
import { createWriteStream, createReadStream, promises as fs } from 'fs'; import { createWriteStream, createReadStream, promises as fs } from 'fs';
@@ -106,8 +106,13 @@ const JUNK_FOLDERS = new Set([
export class PSTConnector implements IEmailConnector { export class PSTConnector implements IEmailConnector {
private storage: StorageService; private storage: StorageService;
private options: ConnectorOptions;
constructor(private credentials: PSTImportCredentials) { constructor(
private credentials: PSTImportCredentials,
options?: ConnectorOptions
) {
this.options = options ?? { preserveOriginalFile: false };
this.storage = new StorageService(); this.storage = new StorageService();
} }
@@ -263,7 +268,10 @@ export class PSTConnector implements IEmailConnector {
try { try {
email = folder.getNextChild(); email = folder.getNextChild();
} catch (error) { } catch (error) {
console.warn("Folder doesn't have child"); logger.warn(
{ folder: folder.displayName, error },
"Folder doesn't have child or failed to read next child."
);
email = null; email = null;
} }
} }
@@ -283,13 +291,18 @@ export class PSTConnector implements IEmailConnector {
): Promise<EmailObject> { ): Promise<EmailObject> {
const emlContent = await this.constructEml(msg); const emlContent = await this.constructEml(msg);
const emlBuffer = Buffer.from(emlContent, 'utf-8'); const emlBuffer = Buffer.from(emlContent, 'utf-8');
const tempFilePath = await writeEmailToTempFile(emlBuffer);
const parsedEmail: ParsedMail = await simpleParser(emlBuffer); const parsedEmail: ParsedMail = await simpleParser(emlBuffer);
// In preserve-original mode, skip extracting full attachment binary content
// to avoid unnecessary memory allocation — the raw EML on disk is the source of truth.
const attachments = parsedEmail.attachments.map((attachment: Attachment) => ({ const attachments = parsedEmail.attachments.map((attachment: Attachment) => ({
filename: attachment.filename || 'untitled', filename: attachment.filename || 'untitled',
contentType: attachment.contentType, contentType: attachment.contentType,
size: attachment.size, size: attachment.size,
content: attachment.content as Buffer, content: this.options.preserveOriginalFile
? Buffer.alloc(0)
: (attachment.content as Buffer),
})); }));
const mapAddresses = ( const mapAddresses = (
@@ -336,7 +349,7 @@ export class PSTConnector implements IEmailConnector {
headers: parsedEmail.headers, headers: parsedEmail.headers,
attachments, attachments,
receivedAt: parsedEmail.date || new Date(), receivedAt: parsedEmail.date || new Date(),
eml: emlBuffer, tempFilePath,
path, path,
}; };
} }

View File

@@ -0,0 +1,15 @@
import { tmpdir } from 'os';
import { join } from 'path';
import { writeFile } from 'fs/promises';
import { randomUUID } from 'crypto';
/**
* Writes a raw email buffer to a temporary file on disk and returns the path.
* This keeps large buffers off the JS heap between connector yield and processEmail().
* The caller (IngestionService.processEmail) is responsible for deleting the file.
*/
export async function writeEmailToTempFile(buffer: Buffer): Promise<string> {
const tempFilePath = join(tmpdir(), `oa-email-${randomUUID()}.eml`);
await writeFile(tempFilePath, buffer);
return tempFilePath;
}

View File

@@ -28,7 +28,8 @@
"svelte-persisted-store": "^0.12.0", "svelte-persisted-store": "^0.12.0",
"sveltekit-i18n": "^2.4.2", "sveltekit-i18n": "^2.4.2",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"tailwind-variants": "^1.0.0" "tailwind-variants": "^1.0.0",
"tippy.js": "^6.3.7"
}, },
"devDependencies": { "devDependencies": {
"@internationalized/date": "^3.8.2", "@internationalized/date": "^3.8.2",

View File

@@ -27,9 +27,7 @@
{/if} {/if}
<p class="text-balance text-center text-xs font-medium leading-loose"> <p class="text-balance text-center text-xs font-medium leading-loose">
© {new Date().getFullYear()} © {new Date().getFullYear()}
<a href="https://openarchiver.com/" target="_blank">Open Archiver</a>. {$t( <a href="https://openarchiver.com/" target="_blank">Open Archiver</a>
'app.components.footer.all_rights_reserved'
)}
</p> </p>
<p class="text-balance text-center text-xs font-medium leading-loose"> <p class="text-balance text-center text-xs font-medium leading-loose">
Version: {currentVersion} Version: {currentVersion}

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import type { IngestionSource, CreateIngestionSourceDto } from '@open-archiver/types'; import type { SafeIngestionSource, CreateIngestionSourceDto } from '@open-archiver/types';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog'; import * as Dialog from '$lib/components/ui/dialog';
import { Checkbox } from '$lib/components/ui/checkbox'; import { Checkbox } from '$lib/components/ui/checkbox';
@@ -11,13 +11,18 @@
import { Textarea } from '$lib/components/ui/textarea/index.js'; import { Textarea } from '$lib/components/ui/textarea/index.js';
import { setAlert } from '$lib/components/custom/alert/alert-state.svelte'; import { setAlert } from '$lib/components/custom/alert/alert-state.svelte';
import { api } from '$lib/api.client'; import { api } from '$lib/api.client';
import { Loader2 } from 'lucide-svelte'; import { Loader2, Info, ChevronDown } from 'lucide-svelte';
import tippy from 'tippy.js';
import 'tippy.js/dist/tippy.css';
import { t } from '$lib/translations'; import { t } from '$lib/translations';
let { let {
source = null, source = null,
existingSources = [],
onSubmit, onSubmit,
}: { }: {
source?: IngestionSource | null; source?: SafeIngestionSource | null;
/** Existing root ingestion sources for the merge dropdown (create mode only) */
existingSources?: SafeIngestionSource[];
onSubmit: (data: CreateIngestionSourceDto) => Promise<void>; onSubmit: (data: CreateIngestionSourceDto) => Promise<void>;
} = $props(); } = $props();
@@ -48,14 +53,18 @@
}, },
]; ];
/** Only show root sources (not children) in the merge dropdown */
const mergeableRootSources = $derived(existingSources.filter((s) => !s.mergedIntoId));
let formData: CreateIngestionSourceDto = $state({ let formData: CreateIngestionSourceDto = $state({
name: source?.name ?? '', name: source?.name ?? '',
provider: source?.provider ?? 'generic_imap', provider: source?.provider ?? 'generic_imap',
providerConfig: source?.credentials ?? { providerConfig: {
type: source?.provider ?? 'generic_imap', type: source?.provider ?? 'generic_imap',
secure: true, secure: true,
allowInsecureCert: false, allowInsecureCert: false,
}, },
preserveOriginalFile: source?.preserveOriginalFile ?? false,
}); });
$effect(() => { $effect(() => {
@@ -68,16 +77,18 @@
); );
let isSubmitting = $state(false); let isSubmitting = $state(false);
let fileUploading = $state(false); let fileUploading = $state(false);
let showAdvanced = $state(false);
let mergeEnabled = $state(false);
let importMethod = $state<'upload' | 'local'>( /** When merge is toggled off, clear the mergedIntoId */
source?.credentials && $effect(() => {
'localFilePath' in source.credentials && if (!mergeEnabled) {
source.credentials.localFilePath delete formData.mergedIntoId;
? 'local' }
: 'upload' });
);
let importMethod = $state<'upload' | 'local'>('upload');
$effect(() => { $effect(() => {
if (importMethod === 'upload') { if (importMethod === 'upload') {
@@ -154,6 +165,13 @@
fileUploading = false; fileUploading = false;
} }
}; };
const mergeTriggerContent = $derived(
formData.mergedIntoId
? (mergeableRootSources.find((s) => s.id === formData.mergedIntoId)?.name ??
$t('app.components.ingestion_source_form.merge_into_select'))
: $t('app.components.ingestion_source_form.merge_into_select')
);
</script> </script>
<form onsubmit={handleSubmit} class="grid gap-4 py-4"> <form onsubmit={handleSubmit} class="grid gap-4 py-4">
@@ -439,6 +457,101 @@
</Alert.Description> </Alert.Description>
</Alert.Root> </Alert.Root>
{/if} {/if}
<!-- Advanced Options (collapsible) -->
<div class="border-t pt-2">
<button
type="button"
class="text-muted-foreground flex w-full cursor-pointer items-center gap-1 text-sm font-medium"
onclick={() => (showAdvanced = !showAdvanced)}
>
<ChevronDown class="h-4 w-4 transition-transform {showAdvanced ? 'rotate-180' : ''}" />
{$t('app.components.ingestion_source_form.advanced_options')}
</button>
{#if showAdvanced}
<div class="mt-3 grid gap-4">
<div class="grid grid-cols-4 items-center gap-4">
<div class="flex items-center gap-1 text-left">
<Label for="preserveOriginalFile"
>{$t(
'app.components.ingestion_source_form.preserve_original_file'
)}</Label
>
<span
use:tippy={{
allowHTML: true,
content: $t(
'app.components.ingestion_source_form.preserve_original_file_tooltip'
),
interactive: true,
delay: 500,
}}
class="text-muted-foreground cursor-help"
>
<Info class="h-4 w-4" />
</span>
</div>
<Checkbox
id="preserveOriginalFile"
bind:checked={formData.preserveOriginalFile}
/>
</div>
<!-- Merge into existing ingestion (create mode only, when existing sources exist) -->
{#if !source && mergeableRootSources.length > 0}
<div class="grid grid-cols-4 items-center gap-4">
<div class="flex items-center gap-1 text-left">
<Label for="mergeEnabled"
>{$t('app.components.ingestion_source_form.merge_into')}</Label
>
<span
use:tippy={{
allowHTML: true,
content: $t(
'app.components.ingestion_source_form.merge_into_tooltip'
),
interactive: true,
delay: 500,
}}
class="text-muted-foreground cursor-help"
>
<Info class="h-4 w-4" />
</span>
</div>
<Checkbox id="mergeEnabled" bind:checked={mergeEnabled} />
</div>
{#if mergeEnabled}
<div class="grid grid-cols-4 items-center gap-4">
<div class="col-span-1"></div>
<div class="col-span-3">
<Select.Root
name="mergedIntoId"
bind:value={formData.mergedIntoId}
type="single"
>
<Select.Trigger class="w-full">
{mergeTriggerContent}
</Select.Trigger>
<Select.Content>
{#each mergeableRootSources as rootSource}
<Select.Item value={rootSource.id}>
{rootSource.name} ({rootSource.provider
.split('_')
.join(' ')})
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
</div>
{/if}
{/if}
</div>
{/if}
</div>
<Dialog.Footer> <Dialog.Footer>
<Button type="submit" disabled={isSubmitting || fileUploading}> <Button type="submit" disabled={isSubmitting || fileUploading}>
{#if isSubmitting} {#if isSubmitting}

View File

@@ -35,12 +35,28 @@
"cancel": "Abbrechen", "cancel": "Abbrechen",
"not_found": "E-Mail nicht gefunden.", "not_found": "E-Mail nicht gefunden.",
"integrity_report": "Integritätsbericht", "integrity_report": "Integritätsbericht",
"download_integrity_report_pdf": "Integritätsbericht herunterladen (PDF)",
"downloading_integrity_report": "Wird erstellt...",
"integrity_report_download_error": "Der Integritätsbericht konnte nicht erstellt werden.",
"email_eml": "E-Mail (.eml)", "email_eml": "E-Mail (.eml)",
"valid": "Gültig", "valid": "Gültig",
"invalid": "Ungültig", "invalid": "Ungültig",
"integrity_check_failed_title": "Integritätsprüfung fehlgeschlagen", "integrity_check_failed_title": "Integritätsprüfung fehlgeschlagen",
"integrity_check_failed_message": "Die Integrität der E-Mail und ihrer Anhänge konnte nicht überprüft werden.", "integrity_check_failed_message": "Die Integrität der E-Mail und ihrer Anhänge konnte nicht überprüft werden.",
"integrity_report_description": "Dieser Bericht überprüft, ob der Inhalt Ihrer archivierten E-Mails nicht verändert wurde." "integrity_report_description": "Dieser Bericht überprüft, ob der Inhalt Ihrer archivierten E-Mails nicht verändert wurde.",
"retention_policy": "Aufbewahrungsrichtlinie",
"retention_policy_description": "Zeigt, welche Aufbewahrungsrichtlinie für diese E-Mail gilt und wann die Löschung geplant ist.",
"retention_no_policy": "Keine Richtlinie anwendbar diese E-Mail wird nicht automatisch gelöscht.",
"retention_period": "Aufbewahrungsfrist",
"retention_action": "Aktion bei Ablauf",
"retention_matching_policies": "Zutreffende Richtlinien",
"retention_delete_permanently": "Endgültige Löschung",
"retention_scheduled_deletion": "Geplante Löschung",
"retention_policy_overridden_by_label": "Diese Richtlinie wird durch das Aufbewahrungslabel überschrieben: ",
"embedded_attachments": "Eingebettete Anhänge",
"embedded": "Eingebettet",
"embedded_attachment_title": "Eingebetteter Anhang",
"embedded_attachment_description": "Dieser Anhang ist in die ursprüngliche E-Mail-Datei eingebettet und kann nicht separat heruntergeladen werden. Um diesen Anhang zu erhalten, laden Sie die vollständige E-Mail-Datei (.eml) herunter."
}, },
"ingestions": { "ingestions": {
"title": "Erfassungsquellen", "title": "Erfassungsquellen",
@@ -71,7 +87,19 @@
"confirm": "Bestätigen", "confirm": "Bestätigen",
"cancel": "Abbrechen", "cancel": "Abbrechen",
"bulk_delete_confirmation_title": "Möchten Sie wirklich {{count}} ausgewählte Erfassungen löschen?", "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." "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.",
"merged_sources": "zusammengeführte Quellen",
"unmerge": "Trennen",
"unmerge_success": "Die Quelle wurde von ihrer Gruppe getrennt.",
"unmerge_confirmation_title": "Diese Quelle trennen?",
"unmerge_confirmation_description": "Dadurch wird die untergeordnete Quelle von der Gruppe getrennt und als eigenständige Erfassung eingerichtet. Bitte beachten Sie Folgendes:",
"unmerge_warning_emails": "Bereits von dieser Quelle erfasste E-Mails sind unter der Hauptquelle gespeichert. Sie verbleiben dort und werden nicht verschoben.",
"unmerge_warning_future": "Nur neue E-Mails, die nach der Trennung erfasst werden, werden unter dieser Quelle gespeichert.",
"unmerge_confirm": "Trennen",
"unmerging": "Wird getrennt",
"delete_root_warning": "Diese Erfassung hat {{count}} zusammengeführte Quelle(n). Beim Löschen werden auch alle zusammengeführten Quellen und deren Daten entfernt.",
"expand": "Aufklappen",
"collapse": "Zuklappen"
}, },
"search": { "search": {
"title": "Suche", "title": "Suche",
@@ -118,6 +146,23 @@
"confirm": "Bestätigen", "confirm": "Bestätigen",
"cancel": "Abbrechen" "cancel": "Abbrechen"
}, },
"account": {
"title": "Kontoeinstellungen",
"description": "Verwalten Sie Ihr Profil und Ihre Sicherheitseinstellungen.",
"personal_info": "Persönliche Informationen",
"personal_info_desc": "Aktualisieren Sie Ihre persönlichen Daten.",
"security": "Sicherheit",
"security_desc": "Verwalten Sie Ihr Passwort und Ihre Sicherheitseinstellungen.",
"edit_profile": "Profil bearbeiten",
"change_password": "Passwort ändern",
"edit_profile_desc": "Nehmen Sie hier Änderungen an Ihrem Profil vor.",
"change_password_desc": "Ändern Sie Ihr Passwort. Sie müssen Ihr aktuelles Passwort eingeben.",
"current_password": "Aktuelles Passwort",
"new_password": "Neues Passwort",
"confirm_new_password": "Neues Passwort bestätigen",
"operation_successful": "Vorgang erfolgreich",
"passwords_do_not_match": "Passwörter stimmen nicht überein"
},
"system_settings": { "system_settings": {
"title": "Systemeinstellungen", "title": "Systemeinstellungen",
"system_settings": "Systemeinstellungen", "system_settings": "Systemeinstellungen",
@@ -154,6 +199,76 @@
"confirm": "Bestätigen", "confirm": "Bestätigen",
"cancel": "Abbrechen" "cancel": "Abbrechen"
}, },
"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.",
"new_version_available": "Neue Version verfügbar"
},
"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",
"provider_mbox_import": "Mbox-Import",
"select_provider": "Wählen Sie einen Anbieter",
"import_method": "Importmethode",
"upload_file": "Datei hochladen",
"local_path": "Lokaler Pfad (empfohlen für große Dateien)",
"local_file_path": "Lokaler Dateipfad",
"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",
"allow_insecure_cert": "Unsicheres Zertifikat zulassen",
"pst_file": "PST-Datei",
"eml_file": "EML-Datei",
"mbox_file": "Mbox-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",
"upload_network_error": "Der Server konnte den Upload nicht verarbeiten. Die Datei überschreitet möglicherweise das konfigurierte Upload-Größenlimit (BODY_SIZE_LIMIT). Verwenden Sie für sehr große Dateien stattdessen die Option \"Lokaler Pfad\".",
"merge_into": "In bestehende Erfassung zusammenführen",
"merge_into_description": "E-Mails aus dieser Quelle werden mit der ausgewählten Erfassung gruppiert. Beide Quellen synchronisieren unabhängig, aber E-Mails werden gemeinsam angezeigt.",
"merge_into_tooltip": "Beim Zusammenführen wird diese neue Quelle zu einer untergeordneten Quelle der ausgewählten Haupterfassung. Alle von dieser Quelle abgerufenen E-Mails werden physisch unter der Haupterfassung gespeichert nicht unter dieser.<br><br>Die Einstellung <b>Originaldatei beibehalten</b> (GoBD-Konformität) der Haupterfassung gilt für die gesamte Gruppe. Die Einstellung in diesem Formular wird ignoriert, wenn die Zusammenführung aktiviert ist.<br><br>Beide Quellen synchronisieren unabhängig nach ihrem eigenen Zeitplan.",
"merge_into_select": "Erfassung zum Zusammenführen auswählen",
"advanced_options": "Erweiterte Optionen",
"preserve_original_file": "Originaldatei beibehalten",
"preserve_original_file_tooltip": "Wenn aktiviert: Speichert die exakte, unveränderte E-Mail-Datei wie vom Server empfangen. Keine Anhänge werden entfernt. Erforderlich für GoBD (Deutschland) und SEC 17a-4 Konformität.<br><br>Wenn deaktiviert: Entfernt Nicht-Inline-Anhänge und speichert sie separat mit Deduplizierung, um Speicherplatz zu sparen."
},
"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"
}
},
"setup": { "setup": {
"title": "Einrichtung", "title": "Einrichtung",
"description": "Richten Sie das anfängliche Administratorkonto für Open Archiver ein.", "description": "Richten Sie das anfängliche Administratorkonto für Open Archiver ein.",
@@ -175,61 +290,52 @@
"system": "System", "system": "System",
"users": "Benutzer", "users": "Benutzer",
"roles": "Rollen", "roles": "Rollen",
"logout": "Abmelden" "api_keys": "API-Schlüssel",
"account": "Konto",
"logout": "Abmelden",
"admin": "Admin"
}, },
"components": { "api_keys_page": {
"charts": { "title": "API-Schlüssel",
"emails_ingested": "E-Mails aufgenommen", "header": "API-Schlüssel",
"storage_used": "Speicher verwendet", "generate_new_key": "Neuen Schlüssel erstellen",
"emails": "E-Mails" "name": "Name",
}, "key": "Schlüssel",
"common": { "expires_at": "Läuft ab am",
"submitting": "Übermittlung...", "created_at": "Erstellt am",
"submit": "Übermitteln", "actions": "Aktionen",
"save": "Speichern" "delete": "Löschen",
}, "no_keys_found": "Keine API-Schlüssel gefunden.",
"email_preview": { "generate_modal_title": "Neuen API-Schlüssel erstellen",
"loading": "E-Mail-Vorschau wird geladen...", "generate_modal_description": "Bitte geben Sie einen Namen und eine Gültigkeitsdauer für Ihren neuen API-Schlüssel an.",
"render_error": "E-Mail-Vorschau konnte nicht gerendert werden.", "expires_in": "Gültig für",
"not_available": "Rohe .eml-Datei für diese E-Mail nicht verfügbar." "select_expiration": "Gültigkeitsdauer auswählen",
}, "30_days": "30 Tage",
"footer": { "60_days": "60 Tage",
"all_rights_reserved": "Alle Rechte vorbehalten." "6_months": "6 Monate",
}, "12_months": "12 Monate",
"ingestion_source_form": { "24_months": "24 Monate",
"provider_generic_imap": "Generisches IMAP", "generate": "Erstellen",
"provider_google_workspace": "Google Workspace", "new_api_key": "Neuer API-Schlüssel",
"provider_microsoft_365": "Microsoft 365", "failed_to_delete": "API-Schlüssel konnte nicht gelöscht werden",
"provider_pst_import": "PST-Import", "api_key_deleted": "API-Schlüssel gelöscht",
"provider_eml_import": "EML-Import", "generated_title": "API-Schlüssel erstellt",
"select_provider": "Wählen Sie einen Anbieter", "generated_message": "Ihr API-Schlüssel wurde erstellt. Bitte kopieren und speichern Sie ihn an einem sicheren Ort. Dieser Schlüssel wird nur einmal angezeigt."
"service_account_key": "Dienstkontoschlüssel (JSON)", },
"service_account_key_placeholder": "Fügen Sie den JSON-Inhalt Ihres Dienstkontoschlüssels ein", "archived_emails_page": {
"impersonated_admin_email": "Impersonierte Admin-E-Mail", "title": "Archivierte E-Mails",
"client_id": "Anwendungs-(Client-)ID", "header": "Archivierte E-Mails",
"client_secret": "Client-Geheimniswert", "select_ingestion_source": "Wählen Sie eine Erfassungsquelle aus",
"client_secret_placeholder": "Geben Sie den Geheimniswert ein, nicht die Geheimnis-ID", "date": "Datum",
"tenant_id": "Verzeichnis-(Mandanten-)ID", "subject": "Betreff",
"host": "Host", "sender": "Absender",
"port": "Port", "inbox": "Posteingang",
"username": "Benutzername", "path": "Pfad",
"use_tls": "TLS verwenden", "actions": "Aktionen",
"pst_file": "PST-Datei", "view": "Ansehen",
"eml_file": "EML-Datei", "no_emails_found": "Keine archivierten E-Mails gefunden.",
"heads_up": "Achtung!", "prev": "Zurück",
"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.", "next": "Weiter"
"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": { "dashboard_page": {
"title": "Dashboard", "title": "Dashboard",
@@ -249,20 +355,229 @@
"top_10_senders": "Top 10 Absender", "top_10_senders": "Top 10 Absender",
"no_indexed_insights": "Keine indizierten Einblicke verfügbar." "no_indexed_insights": "Keine indizierten Einblicke verfügbar."
}, },
"archived_emails_page": { "retention_policies": {
"title": "Archivierte E-Mails", "title": "Aufbewahrungsrichtlinien",
"header": "Archivierte E-Mails", "header": "Aufbewahrungsrichtlinien",
"select_ingestion_source": "Wählen Sie eine Erfassungsquelle aus", "meta_description": "Verwalten Sie Aufbewahrungsrichtlinien zur Automatisierung des E-Mail-Lebenszyklus und der Compliance.",
"date": "Datum", "create_new": "Neue Richtlinie erstellen",
"subject": "Betreff", "no_policies_found": "Keine Aufbewahrungsrichtlinien gefunden.",
"sender": "Absender", "name": "Name",
"inbox": "Posteingang", "description": "Beschreibung",
"path": "Pfad", "priority": "Priorität",
"retention_period": "Aufbewahrungsfrist",
"retention_period_days": "Aufbewahrungsfrist (Tage)",
"action_on_expiry": "Aktion bei Ablauf",
"delete_permanently": "Endgültig löschen",
"status": "Status",
"active": "Aktiv",
"inactive": "Inaktiv",
"conditions": "Bedingungen",
"conditions_description": "Definieren Sie Regeln zum Abgleich von E-Mails. Wenn keine Bedingungen festgelegt sind, gilt die Richtlinie für alle E-Mails.",
"logical_operator": "Logischer Operator",
"and": "UND",
"or": "ODER",
"add_rule": "Regel hinzufügen",
"remove_rule": "Regel entfernen",
"field": "Feld",
"field_sender": "Absender",
"field_recipient": "Empfänger",
"field_subject": "Betreff",
"field_attachment_type": "Anhangstyp",
"operator": "Operator",
"operator_equals": "Gleich",
"operator_not_equals": "Ungleich",
"operator_contains": "Enthält",
"operator_not_contains": "Enthält nicht",
"operator_starts_with": "Beginnt mit",
"operator_ends_with": "Endet mit",
"operator_domain_match": "Domain-Abgleich",
"operator_regex_match": "Regex-Abgleich",
"value": "Wert",
"value_placeholder": "z. B. benutzer@beispiel.com",
"edit": "Bearbeiten",
"delete": "Löschen",
"create": "Erstellen",
"save": "Änderungen speichern",
"cancel": "Abbrechen",
"create_description": "Erstellen Sie eine neue Aufbewahrungsrichtlinie zur Verwaltung des Lebenszyklus archivierter E-Mails.",
"edit_description": "Aktualisieren Sie die Einstellungen dieser Aufbewahrungsrichtlinie.",
"delete_confirmation_title": "Diese Aufbewahrungsrichtlinie löschen?",
"delete_confirmation_description": "Diese Aktion kann nicht rückgängig gemacht werden. E-Mails, die von dieser Richtlinie betroffen sind, unterliegen nicht mehr der automatischen Löschung.",
"deleting": "Löschen",
"confirm": "Bestätigen",
"days": "Tage",
"no_conditions": "Alle E-Mails (kein Filter)",
"rules": "Regeln",
"simulator_title": "Richtlinien-Simulator",
"simulator_description": "Testen Sie die Metadaten einer E-Mail gegen alle aktiven Richtlinien, um zu sehen, welche Aufbewahrungsfrist gelten würde.",
"simulator_sender": "Absender-E-Mail",
"simulator_sender_placeholder": "z. B. max@finanzen.firma.de",
"simulator_recipients": "Empfänger",
"simulator_recipients_placeholder": "Kommagetrennt, z. B. anna@firma.de, peter@firma.de",
"simulator_subject": "Betreff",
"simulator_subject_placeholder": "z. B. Q4-Steuerbericht",
"simulator_attachment_types": "Anhangstypen",
"simulator_attachment_types_placeholder": "Kommagetrennt, z. B. .pdf, .xlsx",
"simulator_run": "Simulation starten",
"simulator_running": "Wird ausgeführt...",
"simulator_result_title": "Simulationsergebnis",
"simulator_no_match": "Keine aktive Richtlinie hat auf diese E-Mail zugetroffen. Sie unterliegt keiner automatischen Löschung.",
"simulator_matched": "Zugetroffen Aufbewahrungsfrist von {{days}} Tagen gilt.",
"simulator_matching_policies": "Zutreffende Richtlinien-IDs",
"simulator_no_result": "Starten Sie eine Simulation, um zu sehen, welche Richtlinien auf eine bestimmte E-Mail zutreffen.",
"simulator_ingestion_source": "Für Erfassungsquelle simulieren",
"simulator_ingestion_source_description": "Wählen Sie eine Erfassungsquelle aus, um eingeschränkte Richtlinien zu testen. Lassen Sie das Feld leer, um alle Richtlinien unabhängig vom Geltungsbereich zu prüfen.",
"simulator_ingestion_all": "Alle Quellen (Geltungsbereich ignorieren)",
"ingestion_scope": "Erfassungsbereich",
"ingestion_scope_description": "Beschränken Sie diese Richtlinie auf bestimmte Erfassungsquellen. Lassen Sie alle deaktiviert, um sie auf alle Quellen anzuwenden.",
"ingestion_scope_all": "Alle Erfassungsquellen",
"ingestion_scope_selected": "{{count}} Quelle(n) ausgewählt diese Richtlinie gilt nur für E-Mails aus diesen Quellen.",
"create_success": "Aufbewahrungsrichtlinie erfolgreich erstellt.",
"update_success": "Aufbewahrungsrichtlinie erfolgreich aktualisiert.",
"delete_success": "Aufbewahrungsrichtlinie erfolgreich gelöscht.",
"delete_error": "Aufbewahrungsrichtlinie konnte nicht gelöscht werden."
},
"retention_labels": {
"title": "Aufbewahrungslabels",
"header": "Aufbewahrungslabels",
"meta_description": "Verwalten Sie Aufbewahrungslabels für individuelle Compliance-Überschreibungen bei einzelnen archivierten E-Mails.",
"create_new": "Label erstellen",
"no_labels_found": "Keine Aufbewahrungslabels gefunden.",
"name": "Name",
"description": "Beschreibung",
"retention_period": "Aufbewahrungsfrist",
"retention_period_days": "Aufbewahrungsfrist (Tage)",
"applied_count": "Zugewiesene E-Mails",
"status": "Status",
"enabled": "Aktiviert",
"disabled": "Deaktiviert",
"created_at": "Erstellt am",
"actions": "Aktionen", "actions": "Aktionen",
"view": "Ansehen", "create": "Erstellen",
"no_emails_found": "Keine archivierten E-Mails gefunden.", "edit": "Bearbeiten",
"prev": "Zurück", "delete": "Löschen",
"next": "Weiter" "disable": "Deaktivieren",
"save": "Änderungen speichern",
"cancel": "Abbrechen",
"days": "Tage",
"create_description": "Erstellen Sie ein neues Aufbewahrungslabel. Sobald es E-Mails zugewiesen wurde, kann die Aufbewahrungsfrist des Labels nicht mehr geändert werden.",
"edit_description": "Aktualisieren Sie die Details dieses Aufbewahrungslabels.",
"delete_confirmation_title": "Dieses Aufbewahrungslabel löschen?",
"delete_confirmation_description": "Diese Aktion entfernt das Label endgültig. Es kann nicht mehr neuen E-Mails zugewiesen werden.",
"disable_confirmation_title": "Dieses Aufbewahrungslabel deaktivieren?",
"disable_confirmation_description": "Dieses Label ist derzeit archivierten E-Mails zugewiesen und kann nicht gelöscht werden. Es wird deaktiviert, sodass es nicht mehr neuen E-Mails zugewiesen werden kann. Bestehende markierte E-Mails behalten dieses Label, obwohl es nicht mehr wirksam ist.",
"force_delete_confirmation_title": "Dieses deaktivierte Label endgültig löschen?",
"force_delete_confirmation_description": "Dieses Label ist deaktiviert, hat aber noch E-Mail-Zuordnungen. Durch das Löschen werden alle Zuordnungen entfernt und das Label endgültig gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.",
"deleting": "Wird verarbeitet",
"confirm": "Bestätigen",
"create_success": "Aufbewahrungslabel erfolgreich erstellt.",
"update_success": "Aufbewahrungslabel erfolgreich aktualisiert.",
"delete_success": "Aufbewahrungslabel erfolgreich gelöscht.",
"disable_success": "Aufbewahrungslabel erfolgreich deaktiviert.",
"delete_error": "Aufbewahrungslabel konnte nicht gelöscht werden.",
"create_error": "Aufbewahrungslabel konnte nicht erstellt werden.",
"update_error": "Aufbewahrungslabel konnte nicht aktualisiert werden.",
"retention_period_locked": "Die Aufbewahrungsfrist kann nicht geändert werden, solange das Label E-Mails zugewiesen ist.",
"name_placeholder": "z. B. Steuerunterlagen 10 Jahre",
"description_placeholder": "z. B. Wird steuerrelevanten Dokumenten zugewiesen, die eine verlängerte Aufbewahrung erfordern."
},
"archive_labels": {
"section_title": "Aufbewahrungslabel",
"section_description": "Überschreiben Sie den Aufbewahrungszeitplan dieser E-Mail mit einem bestimmten Label.",
"current_label": "Aktuelles Label",
"no_label": "Kein Label zugewiesen",
"select_label": "Label auswählen",
"select_label_placeholder": "Aufbewahrungslabel auswählen...",
"apply": "Label zuweisen",
"applying": "Wird zugewiesen...",
"remove": "Label entfernen",
"removing": "Wird entfernt...",
"apply_success": "Aufbewahrungslabel erfolgreich zugewiesen.",
"remove_success": "Aufbewahrungslabel erfolgreich entfernt.",
"apply_error": "Aufbewahrungslabel konnte nicht zugewiesen werden.",
"remove_error": "Aufbewahrungslabel konnte nicht entfernt werden.",
"label_overrides_policy": "Dieses Label überschreibt die allgemeinen Aufbewahrungsrichtlinien für diese E-Mail.",
"no_labels_available": "Keine Aufbewahrungslabels verfügbar. Erstellen Sie Labels in den Compliance-Einstellungen.",
"label_inactive": "Inaktiv",
"label_inactive_note": "Dieses Label wurde deaktiviert. Es bietet keine Aufbewahrungsüberschreibung oder ein geplantes Löschdatum mehr für diese E-Mail. Sie können es entfernen und bei Bedarf ein aktives Label zuweisen."
},
"legal_holds": {
"title": "Aufbewahrungspflichten",
"header": "Aufbewahrungspflichten",
"meta_description": "Verwalten Sie Aufbewahrungspflichten, um E-Mails vor automatischer Löschung während Rechtsstreitigkeiten oder behördlicher Untersuchungen zu schützen.",
"header_description": "Aufbewahrungspflichten setzen die automatische Löschung bestimmter Datensätze aus, die für Rechtsstreitigkeiten oder behördliche Untersuchungen relevant sind.",
"create_new": "Aufbewahrungspflicht erstellen",
"no_holds_found": "Keine Aufbewahrungspflichten gefunden.",
"name": "Name",
"reason": "Begründung / Beschreibung",
"no_reason": "Keine Begründung angegeben",
"email_count": "Geschützte E-Mails",
"status": "Status",
"active": "Aktiv",
"inactive": "Inaktiv",
"created_at": "Erstellt am",
"actions": "Aktionen",
"create": "Erstellen",
"edit": "Bearbeiten",
"delete": "Löschen",
"activate": "Aktivieren",
"deactivate": "Deaktivieren",
"bulk_apply": "Massenanwendung über Suche",
"release_all": "Alle E-Mails freigeben",
"save": "Änderungen speichern",
"cancel": "Abbrechen",
"confirm": "Löschen bestätigen",
"name_placeholder": "z. B. Rechtsstreit Projekt Titan 2026",
"reason_placeholder": "z. B. Anhängiger Rechtsstreit bezüglich Projekt Titan. Alle Kommunikationen müssen aufbewahrt werden.",
"create_description": "Erstellen Sie eine neue Aufbewahrungspflicht, um die automatische Löschung relevanter E-Mails zu verhindern.",
"edit_description": "Aktualisieren Sie den Namen oder die Beschreibung dieser Aufbewahrungspflicht.",
"delete_confirmation_title": "Diese Aufbewahrungspflicht endgültig löschen?",
"delete_confirmation_description": "Dadurch wird die Aufbewahrungspflicht endgültig gelöscht und alle E-Mail-Zuordnungen entfernt. Zuvor geschützte E-Mails unterliegen bei der nächsten Ausführung des Lebenszyklus-Workers den normalen Aufbewahrungsregeln.",
"bulk_apply_title": "Aufbewahrungspflicht über Suche massenanwenden",
"bulk_apply_description": "Suchen Sie nach E-Mails mithilfe von Volltext- und Metadatenfiltern. Alle übereinstimmenden E-Mails werden unter diese Aufbewahrungspflicht gestellt. Die exakte Suchanfrage wird als Nachweis des Umfangs im Audit-Protokoll gespeichert.",
"bulk_query": "Suchbegriffe",
"bulk_query_placeholder": "z. B. Projekt Titan vertraulich",
"bulk_query_hint": "Durchsucht E-Mail-Text, Betreff und Anhangsinhalte über den Volltextindex.",
"bulk_from": "Von (Absender-E-Mail)",
"bulk_date_start": "Datum von",
"bulk_date_end": "Datum bis",
"bulk_apply_warning": "Diese Aktion wird auf ALLE E-Mails angewendet, die Ihrer Suche im gesamten Archiv entsprechen. Die Suchanfrage wird dauerhaft im Audit-Protokoll gespeichert.",
"bulk_apply_confirm": "Aufbewahrungspflicht auf übereinstimmende E-Mails anwenden",
"release_all_title": "Alle E-Mails aus dieser Aufbewahrungspflicht freigeben?",
"release_all_description": "Alle E-Mails verlieren ihren Schutz durch die Aufbewahrungspflicht. Sie werden bei der nächsten Ausführung des Lebenszyklus-Workers gegen die standardmäßigen Aufbewahrungsrichtlinien geprüft und können endgültig gelöscht werden.",
"release_all_confirm": "Alle E-Mails freigeben",
"create_success": "Aufbewahrungspflicht erfolgreich erstellt.",
"update_success": "Aufbewahrungspflicht erfolgreich aktualisiert.",
"delete_success": "Aufbewahrungspflicht erfolgreich gelöscht.",
"activated_success": "Aufbewahrungspflicht aktiviert. Geschützte E-Mails sind nun vor Löschung geschützt.",
"deactivated_success": "Aufbewahrungspflicht deaktiviert. E-Mails sind nicht mehr durch diese Aufbewahrungspflicht geschützt.",
"bulk_apply_success": "Aufbewahrungspflicht erfolgreich angewendet.",
"release_all_success": "Alle E-Mails aus der Aufbewahrungspflicht freigegeben.",
"create_error": "Aufbewahrungspflicht konnte nicht erstellt werden.",
"update_error": "Aufbewahrungspflicht konnte nicht aktualisiert werden.",
"delete_error": "Aufbewahrungspflicht konnte nicht gelöscht werden.",
"bulk_apply_error": "Massenanwendung fehlgeschlagen.",
"release_all_error": "E-Mails konnten nicht aus der Aufbewahrungspflicht freigegeben werden."
},
"archive_legal_holds": {
"section_title": "Aufbewahrungspflichten",
"section_description": "Setzen Sie die automatische Löschung dieser E-Mail aus, indem Sie sie unter eine Aufbewahrungspflicht stellen.",
"no_holds": "Keine Aufbewahrungspflichten für diese E-Mail angewendet.",
"hold_name": "Name der Aufbewahrungspflicht",
"hold_status": "Status",
"applied_at": "Angewendet am",
"apply_hold": "Aufbewahrungspflicht anwenden",
"apply_hold_placeholder": "Aufbewahrungspflicht auswählen...",
"apply": "Aufbewahrungspflicht anwenden",
"applying": "Wird angewendet...",
"remove": "Entfernen",
"removing": "Wird entfernt...",
"apply_success": "Aufbewahrungspflicht auf diese E-Mail angewendet.",
"remove_success": "Aufbewahrungspflicht von dieser E-Mail entfernt.",
"apply_error": "Aufbewahrungspflicht konnte nicht angewendet werden.",
"remove_error": "Aufbewahrungspflicht konnte nicht entfernt werden.",
"immune_notice": "Diese E-Mail ist durch eine aktive Aufbewahrungspflicht geschützt und kann nicht gelöscht werden.",
"no_active_holds": "Keine aktiven Aufbewahrungspflichten verfügbar. Erstellen Sie Aufbewahrungspflichten in den Compliance-Einstellungen."
}, },
"audit_log": { "audit_log": {
"title": "Audit-Protokoll", "title": "Audit-Protokoll",
@@ -280,6 +595,12 @@
"no_logs_found": "Keine Audit-Protokolle gefunden.", "no_logs_found": "Keine Audit-Protokolle gefunden.",
"prev": "Zurück", "prev": "Zurück",
"next": "Weiter", "next": "Weiter",
"log_entry_details": "Protokolleintrag-Details",
"viewing_details_for": "Vollständige Details für Protokolleintrag #",
"actor_id": "Akteur-ID",
"previous_hash": "Vorheriger Hash",
"current_hash": "Aktueller Hash",
"close": "Schließen",
"verification_successful_title": "Überprüfung erfolgreich", "verification_successful_title": "Überprüfung erfolgreich",
"verification_successful_message": "Integrität des Audit-Protokolls erfolgreich überprüft.", "verification_successful_message": "Integrität des Audit-Protokolls erfolgreich überprüft.",
"verification_failed_title": "Überprüfung fehlgeschlagen", "verification_failed_title": "Überprüfung fehlgeschlagen",
@@ -301,6 +622,7 @@
"id": "ID", "id": "ID",
"name": "Name", "name": "Name",
"state": "Status", "state": "Status",
"created_at": "Erstellt am", "created_at": "Erstellt am",
"processed_at": "Verarbeitet am", "processed_at": "Verarbeitet am",
"finished_at": "Beendet am", "finished_at": "Beendet am",
@@ -310,23 +632,83 @@
"next": "Weiter", "next": "Weiter",
"ingestion_source": "Ingestion-Quelle" "ingestion_source": "Ingestion-Quelle"
}, },
"journaling": {
"title": "SMTP-Journaling",
"header": "SMTP-Journaling-Quellen",
"meta_description": "Konfigurieren Sie Echtzeit-SMTP-Journaling-Endpunkte für lückenlose E-Mail-Archivierung von Unternehmens-MTAs.",
"header_description": "Empfangen Sie eine Echtzeitkopie jeder E-Mail direkt von Ihrem Mailserver über SMTP-Journaling und gewährleisten Sie so keinerlei Datenverlust.",
"create_new": "Quelle erstellen",
"no_sources_found": "Keine Journaling-Quellen konfiguriert.",
"name": "Name",
"allowed_ips": "Erlaubte IPs / CIDR",
"require_tls": "TLS erforderlich",
"smtp_username": "SMTP-Benutzername",
"smtp_password": "SMTP-Passwort",
"status": "Status",
"active": "Aktiv",
"paused": "Pausiert",
"total_received": "Empfangene E-Mails",
"last_received_at": "Zuletzt empfangen",
"created_at": "Erstellt am",
"actions": "Aktionen",
"create": "Erstellen",
"edit": "Bearbeiten",
"delete": "Löschen",
"pause": "Pausieren",
"activate": "Aktivieren",
"save": "Änderungen speichern",
"cancel": "Abbrechen",
"confirm": "Löschen bestätigen",
"name_placeholder": "z. B. MS365-Journal-Empfänger",
"allowed_ips_placeholder": "z. B. 40.107.0.0/16, 52.100.0.0/14",
"allowed_ips_hint": "Kommagetrennte IP-Adressen oder CIDR-Blöcke Ihrer Mailserver, die Journalberichte senden dürfen.",
"smtp_username_placeholder": "z. B. journal-tenant-123",
"smtp_password_placeholder": "Geben Sie ein starkes Passwort für die SMTP-Authentifizierung ein",
"smtp_auth_hint": "Optional. Falls konfiguriert, muss sich der MTA beim Verbinden mit diesen Anmeldedaten authentifizieren.",
"create_description": "Konfigurieren Sie einen neuen SMTP-Journaling-Endpunkt. Ihr MTA sendet Journalberichte an diesen Endpunkt zur Echtzeitarchivierung.",
"edit_description": "Aktualisieren Sie die Konfiguration dieser Journaling-Quelle.",
"delete_confirmation_title": "Diese Journaling-Quelle löschen?",
"delete_confirmation_description": "Dadurch werden der Journaling-Endpunkt und alle zugehörigen archivierten E-Mails endgültig gelöscht. Ihr MTA kann keine Journalberichte mehr an diesen Endpunkt senden.",
"deleting": "Löschen",
"smtp_connection_info": "SMTP-Verbindungsinformationen",
"smtp_host": "Host",
"smtp_port": "Port",
"routing_address": "Routing-Adresse",
"routing_address_hint": "Konfigurieren Sie diese Adresse als Journal-Empfänger in Ihrem MTA (Exchange, MS365, Postfix).",
"regenerate_address": "Adresse neu generieren",
"regenerate_address_warning": "Dadurch wird die aktuelle Adresse ungültig. Sie müssen Ihre MTA-Konfiguration mit der neuen Adresse aktualisieren.",
"regenerate_address_confirm": "Möchten Sie die Routing-Adresse wirklich neu generieren? Die aktuelle Adresse funktioniert sofort nicht mehr und Sie müssen Ihre MTA-Konfiguration aktualisieren.",
"regenerate_address_success": "Routing-Adresse erfolgreich neu generiert. Aktualisieren Sie Ihre MTA-Konfiguration mit der neuen Adresse.",
"regenerate_address_error": "Routing-Adresse konnte nicht neu generiert werden.",
"create_success": "Journaling-Quelle erfolgreich erstellt.",
"update_success": "Journaling-Quelle erfolgreich aktualisiert.",
"delete_success": "Journaling-Quelle erfolgreich gelöscht.",
"create_error": "Journaling-Quelle konnte nicht erstellt werden.",
"update_error": "Journaling-Quelle konnte nicht aktualisiert werden.",
"delete_error": "Journaling-Quelle konnte nicht gelöscht werden.",
"health_listening": "SMTP-Listener: Aktiv",
"health_down": "SMTP-Listener: Nicht erreichbar",
"never": "Nie"
},
"license_page": { "license_page": {
"title": "Enterprise-Lizenzstatus", "title": "Enterprise-Lizenzstatus",
"meta_description": "Zeigen Sie den aktuellen Status Ihrer Open Archiver Enterprise-Lizenz an.", "meta_description": "Zeigen Sie den aktuellen Status Ihrer Open Archiver Enterprise-Lizenz an.",
"revoked_title": "Lizenz widerrufen", "revoked_title": "Lizenz widerrufen",
"revoked_message": "Ihre Lizenz wurde vom Lizenzadministrator widerrufen. Enterprise-Funktionen werden deaktiviert {{grace_period}}. Bitte kontaktieren Sie Ihren Account Manager für Unterstützung.", "revoked_message": "Ihre Lizenz wurde vom Lizenzadministrator widerrufen. Enterprise-Funktionen werden deaktiviert {{grace_period}}. Bitte kontaktieren Sie Ihren Account Manager für Unterstützung.",
"revoked_grace_period": "am {{date}}", "notice_title": "Hinweis",
"revoked_immediately": "sofort",
"seat_limit_exceeded_title": "Sitzplatzlimit überschritten", "seat_limit_exceeded_title": "Sitzplatzlimit überschritten",
"seat_limit_exceeded_message": "Ihre Lizenz gilt für {{planSeats}} Benutzer, aber Sie verwenden derzeit {{activeSeats}}. Bitte kontaktieren Sie den Vertrieb, um Ihr Abonnement anzupassen.", "seat_limit_exceeded_message": "Ihre Lizenz gilt für {{planSeats}} Benutzer, aber Sie verwenden derzeit {{activeSeats}}. Bitte kontaktieren Sie den Vertrieb, um Ihr Abonnement anzupassen.",
"seat_limit_grace_deadline": "Enterprise-Funktionen werden am {{date}} deaktiviert, sofern die Anzahl der Plätze nicht reduziert wird.",
"customer": "Kunde", "customer": "Kunde",
"license_details": "Lizenzdetails", "license_details": "Lizenzdetails",
"license_status": "Lizenzstatus", "license_status": "Lizenzstatus",
"active": "Aktiv", "active": "Aktiv",
"expired": "Abgelaufen", "expired": "Abgelaufen",
"revoked": "Widerrufen", "revoked": "Widerrufen",
"overage": "Sitzplatzüberschreitung",
"unknown": "Unbekannt", "unknown": "Unbekannt",
"expires": "Läuft ab", "expires": "Läuft ab",
"last_checked": "Zuletzt überprüft",
"seat_usage": "Sitzplatznutzung", "seat_usage": "Sitzplatznutzung",
"seats_used": "{{activeSeats}} von {{planSeats}} Plätzen belegt", "seats_used": "{{activeSeats}} von {{planSeats}} Plätzen belegt",
"enabled_features": "Aktivierte Funktionen", "enabled_features": "Aktivierte Funktionen",
@@ -336,7 +718,12 @@
"enabled": "Aktiviert", "enabled": "Aktiviert",
"disabled": "Deaktiviert", "disabled": "Deaktiviert",
"could_not_load_title": "Lizenz konnte nicht geladen werden", "could_not_load_title": "Lizenz konnte nicht geladen werden",
"could_not_load_message": "Ein unerwarteter Fehler ist aufgetreten." "could_not_load_message": "Ein unerwarteter Fehler ist aufgetreten.",
"revalidate": "Lizenz erneut validieren",
"revalidating": "Wird validiert...",
"revalidate_success": "Lizenz erfolgreich erneut validiert.",
"revoked_grace_period": "am {{date}}",
"revoked_immediately": "sofort"
} }
} }
} }

View File

@@ -52,7 +52,11 @@
"retention_matching_policies": "Matching Policies", "retention_matching_policies": "Matching Policies",
"retention_delete_permanently": "Permanent Deletion", "retention_delete_permanently": "Permanent Deletion",
"retention_scheduled_deletion": "Scheduled Deletion", "retention_scheduled_deletion": "Scheduled Deletion",
"retention_policy_overridden_by_label": "This policy is overridden by retention label " "retention_policy_overridden_by_label": "This policy is overridden by retention label ",
"embedded_attachments": "Embedded Attachments",
"embedded": "Embedded",
"embedded_attachment_title": "Embedded Attachment",
"embedded_attachment_description": "This attachment is embedded in the original email file and cannot be downloaded separately. To get this attachment, download the complete email (.eml) file."
}, },
"ingestions": { "ingestions": {
"title": "Ingestion Sources", "title": "Ingestion Sources",
@@ -83,7 +87,19 @@
"confirm": "Confirm", "confirm": "Confirm",
"cancel": "Cancel", "cancel": "Cancel",
"bulk_delete_confirmation_title": "Are you sure you want to delete {{count}} selected ingestions?", "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." "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.",
"merged_sources": "merged sources",
"unmerge": "Unmerge",
"unmerge_success": "Source has been detached from its group.",
"unmerge_confirmation_title": "Unmerge this source?",
"unmerge_confirmation_description": "This will detach the child source from the group and make it a standalone ingestion. Be aware of the following:",
"unmerge_warning_emails": "Emails already ingested by this source are stored under the root source. They will remain with the root and will not be moved.",
"unmerge_warning_future": "Only new emails ingested after unmerging will be stored under this source.",
"unmerge_confirm": "Unmerge",
"unmerging": "Unmerging",
"delete_root_warning": "This ingestion has {{count}} merged source(s). Deleting it will also delete all merged sources and their data.",
"expand": "Expand",
"collapse": "Collapse"
}, },
"search": { "search": {
"title": "Search", "title": "Search",
@@ -233,7 +249,14 @@
"heads_up": "Heads up!", "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.", "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", "upload_failed": "Upload Failed, please try again",
"upload_network_error": "The server could not process the upload. The file may exceed the configured upload size limit (BODY_SIZE_LIMIT). For very large files, use the Local Path option instead." "upload_network_error": "The server could not process the upload. The file may exceed the configured upload size limit (BODY_SIZE_LIMIT). For very large files, use the Local Path option instead.",
"merge_into": "Merge into existing ingestion",
"merge_into_description": "Emails from this source will be grouped with the selected ingestion. Both sources sync independently but emails appear together.",
"merge_into_tooltip": "When merging, this new source becomes a child of the selected root ingestion. All emails fetched by this source will be physically stored under the root ingestion — not this one.<br><br>The root ingestion's <b>Preserve Original File</b> (GoBD compliance) setting governs the entire group. The setting on this form is ignored if merging is enabled.<br><br>Both sources sync independently on their own schedule.",
"merge_into_select": "Select ingestion to merge into",
"advanced_options": "Advanced Options",
"preserve_original_file": "Preserve Original File",
"preserve_original_file_tooltip": "When checked: Stores the exact, unmodified email file as received from the server. No attachments are stripped. Required for GoBD (Germany) and SEC 17a-4 compliance.<br><br>When unchecked: Strips non-inline attachments and stores them separately with deduplication, saving storage space."
}, },
"role_form": { "role_form": {
"policies_json": "Policies (JSON)", "policies_json": "Policies (JSON)",
@@ -609,6 +632,64 @@
"next": "Next", "next": "Next",
"ingestion_source": "Ingestion Source" "ingestion_source": "Ingestion Source"
}, },
"journaling": {
"title": "SMTP Journaling",
"header": "SMTP Journaling Sources",
"meta_description": "Configure real-time SMTP journaling endpoints for zero-gap email archiving from corporate MTAs.",
"header_description": "Receive a real-time copy of every email directly from your mail server via SMTP journaling, ensuring zero data loss.",
"create_new": "Create Source",
"no_sources_found": "No journaling sources configured.",
"name": "Name",
"allowed_ips": "Allowed IPs / CIDR",
"require_tls": "Require TLS",
"smtp_username": "SMTP Username",
"smtp_password": "SMTP Password",
"status": "Status",
"active": "Active",
"paused": "Paused",
"total_received": "Emails Received",
"last_received_at": "Last Received",
"created_at": "Created At",
"actions": "Actions",
"create": "Create",
"edit": "Edit",
"delete": "Delete",
"pause": "Pause",
"activate": "Activate",
"save": "Save Changes",
"cancel": "Cancel",
"confirm": "Confirm Delete",
"name_placeholder": "e.g. MS365 Journal Receiver",
"allowed_ips_placeholder": "e.g. 40.107.0.0/16, 52.100.0.0/14",
"allowed_ips_hint": "Comma-separated IP addresses or CIDR blocks of your mail server(s) that are permitted to send journal reports.",
"smtp_username_placeholder": "e.g. journal-tenant-123",
"smtp_password_placeholder": "Enter a strong password for SMTP AUTH",
"smtp_auth_hint": "Optional. If set, the MTA must authenticate with these credentials when connecting.",
"create_description": "Configure a new SMTP journaling endpoint. Your MTA will send journal reports to this endpoint for real-time archiving.",
"edit_description": "Update the configuration for this journaling source.",
"delete_confirmation_title": "Delete this journaling source?",
"delete_confirmation_description": "This will permanently delete the journaling endpoint and all associated archived emails. Your MTA will no longer be able to deliver journal reports to this endpoint.",
"deleting": "Deleting",
"smtp_connection_info": "SMTP Connection Info",
"smtp_host": "Host",
"smtp_port": "Port",
"routing_address": "Routing Address",
"routing_address_hint": "Configure this address as the journal recipient in your MTA (Exchange, MS365, Postfix).",
"regenerate_address": "Regenerate Address",
"regenerate_address_warning": "This will invalidate the current address. You must update your MTA configuration to use the new address.",
"regenerate_address_confirm": "Are you sure you want to regenerate the routing address? The current address will stop working immediately and you will need to update your MTA configuration.",
"regenerate_address_success": "Routing address regenerated successfully. Update your MTA configuration with the new address.",
"regenerate_address_error": "Failed to regenerate routing address.",
"create_success": "Journaling source created successfully.",
"update_success": "Journaling source updated successfully.",
"delete_success": "Journaling source deleted successfully.",
"create_error": "Failed to create journaling source.",
"update_error": "Failed to update journaling source.",
"delete_error": "Failed to delete journaling source.",
"health_listening": "SMTP Listener: Active",
"health_down": "SMTP Listener: Down",
"never": "Never"
},
"license_page": { "license_page": {
"title": "Enterprise License Status", "title": "Enterprise License Status",
"meta_description": "View the current status of your Open Archiver Enterprise license.", "meta_description": "View the current status of your Open Archiver Enterprise license.",
@@ -640,7 +721,9 @@
"could_not_load_message": "An unexpected error occurred.", "could_not_load_message": "An unexpected error occurred.",
"revalidate": "Revalidate License", "revalidate": "Revalidate License",
"revalidating": "Revalidating...", "revalidating": "Revalidating...",
"revalidate_success": "License revalidated successfully." "revalidate_success": "License revalidated successfully.",
"revoked_grace_period": "on {{date}}",
"revoked_immediately": "immediately"
} }
} }
} }

View File

@@ -7,7 +7,8 @@
"password": "Contraseña" "password": "Contraseña"
}, },
"common": { "common": {
"working": "Trabajando" "working": "Trabajando",
"read_docs": "Leer documentación"
}, },
"archive": { "archive": {
"title": "Archivo", "title": "Archivo",
@@ -32,7 +33,30 @@
"deleting": "Eliminando", "deleting": "Eliminando",
"confirm": "Confirmar", "confirm": "Confirmar",
"cancel": "Cancelar", "cancel": "Cancelar",
"not_found": "Correo electrónico no encontrado." "not_found": "Correo electrónico no encontrado.",
"integrity_report": "Informe de integridad",
"download_integrity_report_pdf": "Descargar informe de integridad (PDF)",
"downloading_integrity_report": "Generando...",
"integrity_report_download_error": "No se pudo generar el informe de integridad.",
"email_eml": "Correo electrónico (.eml)",
"valid": "Válido",
"invalid": "No válido",
"integrity_check_failed_title": "Error en la verificación de integridad",
"integrity_check_failed_message": "No se pudo verificar la integridad del correo electrónico y sus archivos adjuntos.",
"integrity_report_description": "Este informe verifica que el contenido de sus correos electrónicos archivados no ha sido alterado.",
"retention_policy": "Política de retención",
"retention_policy_description": "Muestra qué política de retención rige este correo electrónico y cuándo está programada su eliminación.",
"retention_no_policy": "No se aplica ninguna política: este correo electrónico no se eliminará automáticamente.",
"retention_period": "Período de retención",
"retention_action": "Acción al vencer",
"retention_matching_policies": "Políticas aplicables",
"retention_delete_permanently": "Eliminación permanente",
"retention_scheduled_deletion": "Eliminación programada",
"retention_policy_overridden_by_label": "Esta política está anulada por la etiqueta de retención ",
"embedded_attachments": "Archivos adjuntos incrustados",
"embedded": "Incrustado",
"embedded_attachment_title": "Archivo adjunto incrustado",
"embedded_attachment_description": "Este archivo adjunto está incrustado en el archivo de correo electrónico original y no se puede descargar por separado. Para obtenerlo, descargue el archivo de correo electrónico completo (.eml)."
}, },
"ingestions": { "ingestions": {
"title": "Fuentes de ingesta", "title": "Fuentes de ingesta",
@@ -63,7 +87,19 @@
"confirm": "Confirmar", "confirm": "Confirmar",
"cancel": "Cancelar", "cancel": "Cancelar",
"bulk_delete_confirmation_title": "¿Está seguro de que desea eliminar {{count}} ingestas seleccionadas?", "bulk_delete_confirmation_title": "¿Está seguro de que desea eliminar {{count}} ingestas seleccionadas?",
"bulk_delete_confirmation_description": "Esto eliminará todos los correos electrónicos archivados, archivos adjuntos, indexación y archivos asociados con estas ingestas. Si solo desea dejar de sincronizar nuevos correos electrónicos, puede pausar las ingestas en su lugar." "bulk_delete_confirmation_description": "Esto eliminará todos los correos electrónicos archivados, archivos adjuntos, indexación y archivos asociados con estas ingestas. Si solo desea dejar de sincronizar nuevos correos electrónicos, puede pausar las ingestas en su lugar.",
"merged_sources": "fuentes combinadas",
"unmerge": "Separar",
"unmerge_success": "La fuente ha sido desvinculada de su grupo.",
"unmerge_confirmation_title": "¿Separar esta fuente?",
"unmerge_confirmation_description": "Esto desvinculará la fuente secundaria del grupo y la convertirá en una ingesta independiente. Tenga en cuenta lo siguiente:",
"unmerge_warning_emails": "Los correos electrónicos ya ingestados por esta fuente están almacenados bajo la fuente raíz. Permanecerán allí y no serán movidos.",
"unmerge_warning_future": "Solo los nuevos correos electrónicos ingestados después de la separación se almacenarán bajo esta fuente.",
"unmerge_confirm": "Separar",
"unmerging": "Separando",
"delete_root_warning": "Esta ingesta tiene {{count}} fuente(s) combinada(s). Eliminarla también eliminará todas las fuentes combinadas y sus datos.",
"expand": "Expandir",
"collapse": "Contraer"
}, },
"search": { "search": {
"title": "Buscar", "title": "Buscar",
@@ -110,6 +146,23 @@
"confirm": "Confirmar", "confirm": "Confirmar",
"cancel": "Cancelar" "cancel": "Cancelar"
}, },
"account": {
"title": "Configuración de cuenta",
"description": "Administre su perfil y configuración de seguridad.",
"personal_info": "Información personal",
"personal_info_desc": "Actualice sus datos personales.",
"security": "Seguridad",
"security_desc": "Administre su contraseña y preferencias de seguridad.",
"edit_profile": "Editar perfil",
"change_password": "Cambiar contraseña",
"edit_profile_desc": "Realice cambios en su perfil aquí.",
"change_password_desc": "Cambie su contraseña. Deberá ingresar su contraseña actual.",
"current_password": "Contraseña actual",
"new_password": "Nueva contraseña",
"confirm_new_password": "Confirmar nueva contraseña",
"operation_successful": "Operación exitosa",
"passwords_do_not_match": "Las contraseñas no coinciden"
},
"system_settings": { "system_settings": {
"title": "Configuración del sistema", "title": "Configuración del sistema",
"system_settings": "Configuración del sistema", "system_settings": "Configuración del sistema",
@@ -146,6 +199,76 @@
"confirm": "Confirmar", "confirm": "Confirmar",
"cancel": "Cancelar" "cancel": "Cancelar"
}, },
"components": {
"charts": {
"emails_ingested": "Correos electrónicos ingestados",
"storage_used": "Almacenamiento utilizado",
"emails": "Correos electrónicos"
},
"common": {
"submitting": "Enviando...",
"submit": "Enviar",
"save": "Guardar"
},
"email_preview": {
"loading": "Cargando vista previa del correo electrónico...",
"render_error": "No se pudo renderizar la vista previa del correo electrónico.",
"not_available": "El archivo .eml sin procesar no está disponible para este correo electrónico."
},
"footer": {
"all_rights_reserved": "Todos los derechos reservados.",
"new_version_available": "Nueva versión disponible"
},
"ingestion_source_form": {
"provider_generic_imap": "IMAP genérico",
"provider_google_workspace": "Google Workspace",
"provider_microsoft_365": "Microsoft 365",
"provider_pst_import": "Importación de PST",
"provider_eml_import": "Importación de EML",
"provider_mbox_import": "Importación de Mbox",
"select_provider": "Seleccione un proveedor",
"import_method": "Método de importación",
"upload_file": "Cargar archivo",
"local_path": "Ruta local (recomendado para archivos grandes)",
"local_file_path": "Ruta de archivo local",
"service_account_key": "Clave de cuenta de servicio (JSON)",
"service_account_key_placeholder": "Pegue el contenido JSON de su clave de cuenta de servicio",
"impersonated_admin_email": "Correo electrónico de administrador suplantado",
"client_id": "ID de aplicación (cliente)",
"client_secret": "Valor secreto del cliente",
"client_secret_placeholder": "Ingrese el valor secreto, no el ID secreto",
"tenant_id": "ID de directorio (inquilino)",
"host": "Host",
"port": "Puerto",
"username": "Nombre de usuario",
"use_tls": "Usar TLS",
"allow_insecure_cert": "Permitir certificado no seguro",
"pst_file": "Archivo PST",
"eml_file": "Archivo EML",
"mbox_file": "Archivo Mbox",
"heads_up": "¡Atención!",
"org_wide_warning": "Tenga en cuenta que esta es una operación para toda la organización. Este tipo de ingestas importará e indexará <b>todos</b> los buzones de correo electrónico de su organización. Si desea importar solo buzones de correo electrónico específicos, utilice el conector IMAP.",
"upload_failed": "Error al cargar, por favor intente de nuevo",
"upload_network_error": "El servidor no pudo procesar la carga. El archivo puede superar el límite de tamaño configurado (BODY_SIZE_LIMIT). Para archivos muy grandes, utilice la opción de ruta local.",
"merge_into": "Combinar con ingesta existente",
"merge_into_description": "Los correos electrónicos de esta fuente se agruparán con la ingesta seleccionada. Ambas fuentes sincronizan de forma independiente pero los correos aparecen juntos.",
"merge_into_tooltip": "Al combinar, esta nueva fuente se convierte en secundaria de la ingesta raíz seleccionada. Todos los correos obtenidos por esta fuente se almacenarán físicamente bajo la ingesta raíz, no bajo esta.<br><br>La configuración <b>Conservar archivo original</b> (cumplimiento GoBD) de la ingesta raíz rige para todo el grupo. La configuración de este formulario se ignora si la combinación está habilitada.<br><br>Ambas fuentes sincronizan de forma independiente según su propio calendario.",
"merge_into_select": "Seleccionar ingesta para combinar",
"advanced_options": "Opciones avanzadas",
"preserve_original_file": "Conservar archivo original",
"preserve_original_file_tooltip": "Si está marcado: Almacena el archivo de correo electrónico exacto y sin modificar tal como se recibió del servidor. No se eliminan archivos adjuntos. Requerido para el cumplimiento de GoBD (Alemania) y SEC 17a-4.<br><br>Si no está marcado: Elimina los archivos adjuntos no en línea y los almacena por separado con deduplicación, ahorrando espacio de almacenamiento."
},
"role_form": {
"policies_json": "Políticas (JSON)",
"invalid_json": "Formato JSON no válido para las políticas."
},
"theme_switcher": {
"toggle_theme": "Cambiar tema"
},
"user_form": {
"select_role": "Seleccione un rol"
}
},
"setup": { "setup": {
"title": "Configuración", "title": "Configuración",
"description": "Configure la cuenta de administrador inicial para Open Archiver.", "description": "Configure la cuenta de administrador inicial para Open Archiver.",
@@ -167,61 +290,52 @@
"system": "Sistema", "system": "Sistema",
"users": "Usuarios", "users": "Usuarios",
"roles": "Roles", "roles": "Roles",
"logout": "Cerrar sesión" "api_keys": "Claves de API",
"account": "Cuenta",
"logout": "Cerrar sesión",
"admin": "Admin"
}, },
"components": { "api_keys_page": {
"charts": { "title": "Claves de API",
"emails_ingested": "Correos electrónicos ingeridos", "header": "Claves de API",
"storage_used": "Almacenamiento utilizado", "generate_new_key": "Generar nueva clave",
"emails": "Correos electrónicos" "name": "Nombre",
}, "key": "Clave",
"common": { "expires_at": "Vence el",
"submitting": "Enviando...", "created_at": "Creado el",
"submit": "Enviar", "actions": "Acciones",
"save": "Guardar" "delete": "Eliminar",
}, "no_keys_found": "No se encontraron claves de API.",
"email_preview": { "generate_modal_title": "Generar nueva clave de API",
"loading": "Cargando vista previa del correo electrónico...", "generate_modal_description": "Proporcione un nombre y una fecha de vencimiento para su nueva clave de API.",
"render_error": "No se pudo renderizar la vista previa del correo electrónico.", "expires_in": "Vence en",
"not_available": "El archivo .eml sin procesar no está disponible para este correo electrónico." "select_expiration": "Seleccione una fecha de vencimiento",
}, "30_days": "30 días",
"footer": { "60_days": "60 días",
"all_rights_reserved": "Todos los derechos reservados." "6_months": "6 meses",
}, "12_months": "12 meses",
"ingestion_source_form": { "24_months": "24 meses",
"provider_generic_imap": "IMAP genérico", "generate": "Generar",
"provider_google_workspace": "Google Workspace", "new_api_key": "Nueva clave de API",
"provider_microsoft_365": "Microsoft 365", "failed_to_delete": "No se pudo eliminar la clave de API",
"provider_pst_import": "Importación de PST", "api_key_deleted": "Clave de API eliminada",
"provider_eml_import": "Importación de EML", "generated_title": "Clave de API generada",
"select_provider": "Seleccione un proveedor", "generated_message": "Su clave de API ha sido generada. Cópiela y guárdela en un lugar seguro. Esta clave solo se mostrará una vez."
"service_account_key": "Clave de cuenta de servicio (JSON)", },
"service_account_key_placeholder": "Pegue el contenido JSON de su clave de cuenta de servicio", "archived_emails_page": {
"impersonated_admin_email": "Correo electrónico de administrador suplantado", "title": "Correos electrónicos archivados",
"client_id": "ID de aplicación (cliente)", "header": "Correos electrónicos archivados",
"client_secret": "Valor secreto del cliente", "select_ingestion_source": "Seleccione una fuente de ingesta",
"client_secret_placeholder": "Ingrese el valor secreto, no el ID secreto", "date": "Fecha",
"tenant_id": "ID de directorio (inquilino)", "subject": "Asunto",
"host": "Host", "sender": "Remitente",
"port": "Puerto", "inbox": "Bandeja de entrada",
"username": "Nombre de usuario", "path": "Ruta",
"use_tls": "Usar TLS", "actions": "Acciones",
"pst_file": "Archivo PST", "view": "Ver",
"eml_file": "Archivo EML", "no_emails_found": "No se encontraron correos electrónicos archivados.",
"heads_up": "¡Atención!", "prev": "Anterior",
"org_wide_warning": "Tenga en cuenta que esta es una operación para toda la organización. Este tipo de ingestas importará e indexará <b>todos</b> los buzones de correo electrónico de su organización. Si desea importar solo buzones de correo electrónico específicos, utilice el conector IMAP.", "next": "Siguiente"
"upload_failed": "Error al cargar, por favor intente de nuevo"
},
"role_form": {
"policies_json": "Políticas (JSON)",
"invalid_json": "Formato JSON no válido para las políticas."
},
"theme_switcher": {
"toggle_theme": "Cambiar tema"
},
"user_form": {
"select_role": "Seleccione un rol"
}
}, },
"dashboard_page": { "dashboard_page": {
"title": "Tablero", "title": "Tablero",
@@ -241,20 +355,372 @@
"top_10_senders": "Los 10 principales remitentes", "top_10_senders": "Los 10 principales remitentes",
"no_indexed_insights": "No hay información indexada disponible." "no_indexed_insights": "No hay información indexada disponible."
}, },
"archived_emails_page": { "retention_policies": {
"title": "Correos electrónicos archivados", "title": "Políticas de retención",
"header": "Correos electrónicos archivados", "header": "Políticas de retención",
"select_ingestion_source": "Seleccione una fuente de ingesta", "meta_description": "Gestione las políticas de retención de datos para automatizar el ciclo de vida del correo electrónico y el cumplimiento normativo.",
"date": "Fecha", "create_new": "Crear nueva política",
"subject": "Asunto", "no_policies_found": "No se encontraron políticas de retención.",
"sender": "Remitente", "name": "Nombre",
"inbox": "Bandeja de entrada", "description": "Descripción",
"path": "Ruta", "priority": "Prioridad",
"retention_period": "Período de retención",
"retention_period_days": "Período de retención (días)",
"action_on_expiry": "Acción al vencer",
"delete_permanently": "Eliminar permanentemente",
"status": "Estado",
"active": "Activo",
"inactive": "Inactivo",
"conditions": "Condiciones",
"conditions_description": "Defina reglas para hacer coincidir correos electrónicos. Si no se establecen condiciones, la política se aplica a todos los correos electrónicos.",
"logical_operator": "Operador lógico",
"and": "Y",
"or": "O",
"add_rule": "Agregar regla",
"remove_rule": "Eliminar regla",
"field": "Campo",
"field_sender": "Remitente",
"field_recipient": "Destinatario",
"field_subject": "Asunto",
"field_attachment_type": "Tipo de adjunto",
"operator": "Operador",
"operator_equals": "Igual a",
"operator_not_equals": "No igual a",
"operator_contains": "Contiene",
"operator_not_contains": "No contiene",
"operator_starts_with": "Comienza con",
"operator_ends_with": "Termina con",
"operator_domain_match": "Coincidencia de dominio",
"operator_regex_match": "Coincidencia de expresión regular",
"value": "Valor",
"value_placeholder": "p. ej. usuario@ejemplo.com",
"edit": "Editar",
"delete": "Eliminar",
"create": "Crear",
"save": "Guardar cambios",
"cancel": "Cancelar",
"create_description": "Cree una nueva política de retención para gestionar el ciclo de vida de los correos electrónicos archivados.",
"edit_description": "Actualice la configuración de esta política de retención.",
"delete_confirmation_title": "¿Eliminar esta política de retención?",
"delete_confirmation_description": "Esta acción no se puede deshacer. Los correos electrónicos coincidentes con esta política ya no estarán sujetos a eliminación automática.",
"deleting": "Eliminando",
"confirm": "Confirmar",
"days": "días",
"no_conditions": "Todos los correos electrónicos (sin filtro)",
"rules": "reglas",
"simulator_title": "Simulador de políticas",
"simulator_description": "Pruebe los metadatos de un correo electrónico contra todas las políticas activas para ver qué período de retención se aplicaría.",
"simulator_sender": "Correo del remitente",
"simulator_sender_placeholder": "p. ej. juan@finanzas.empresa.es",
"simulator_recipients": "Destinatarios",
"simulator_recipients_placeholder": "Separados por comas, p. ej. ana@empresa.es, pedro@empresa.es",
"simulator_subject": "Asunto",
"simulator_subject_placeholder": "p. ej. Informe fiscal T4",
"simulator_attachment_types": "Tipos de adjuntos",
"simulator_attachment_types_placeholder": "Separados por comas, p. ej. .pdf, .xlsx",
"simulator_run": "Ejecutar simulación",
"simulator_running": "Ejecutando...",
"simulator_result_title": "Resultado de la simulación",
"simulator_no_match": "Ninguna política activa coincidió con este correo electrónico. No estará sujeto a eliminación automática.",
"simulator_matched": "Coincidencia — se aplica un período de retención de {{days}} días.",
"simulator_matching_policies": "IDs de políticas coincidentes",
"simulator_no_result": "Ejecute una simulación para ver qué políticas se aplican a un correo electrónico determinado.",
"simulator_ingestion_source": "Simular para fuente de ingesta",
"simulator_ingestion_source_description": "Seleccione una fuente de ingesta para probar políticas con ámbito. Deje en blanco para evaluar contra todas las políticas independientemente del ámbito.",
"simulator_ingestion_all": "Todas las fuentes (ignorar ámbito)",
"ingestion_scope": "Ámbito de ingesta",
"ingestion_scope_description": "Restrinja esta política a fuentes de ingesta específicas. Deje todas sin marcar para aplicar a todas las fuentes.",
"ingestion_scope_all": "Todas las fuentes de ingesta",
"ingestion_scope_selected": "{{count}} fuente(s) seleccionada(s) — esta política solo se aplicará a correos electrónicos de esas fuentes.",
"create_success": "Política de retención creada correctamente.",
"update_success": "Política de retención actualizada correctamente.",
"delete_success": "Política de retención eliminada correctamente.",
"delete_error": "No se pudo eliminar la política de retención."
},
"retention_labels": {
"title": "Etiquetas de retención",
"header": "Etiquetas de retención",
"meta_description": "Gestione las etiquetas de retención para anulaciones de cumplimiento a nivel de elemento en correos electrónicos archivados individuales.",
"create_new": "Crear etiqueta",
"no_labels_found": "No se encontraron etiquetas de retención.",
"name": "Nombre",
"description": "Descripción",
"retention_period": "Período de retención",
"retention_period_days": "Período de retención (días)",
"applied_count": "Correos aplicados",
"status": "Estado",
"enabled": "Habilitado",
"disabled": "Deshabilitado",
"created_at": "Creado el",
"actions": "Acciones", "actions": "Acciones",
"view": "Ver", "create": "Crear",
"no_emails_found": "No se encontraron correos electrónicos archivados.", "edit": "Editar",
"delete": "Eliminar",
"disable": "Deshabilitar",
"save": "Guardar cambios",
"cancel": "Cancelar",
"days": "días",
"create_description": "Cree una nueva etiqueta de retención. Una vez aplicada a correos electrónicos, el período de retención de la etiqueta no puede modificarse.",
"edit_description": "Actualice los detalles de esta etiqueta de retención.",
"delete_confirmation_title": "¿Eliminar esta etiqueta de retención?",
"delete_confirmation_description": "Esta acción eliminará permanentemente la etiqueta. No podrá aplicarse a nuevos correos electrónicos.",
"disable_confirmation_title": "¿Deshabilitar esta etiqueta de retención?",
"disable_confirmation_description": "Esta etiqueta está actualmente aplicada a correos electrónicos archivados y no puede eliminarse. Se deshabilitará para que no pueda aplicarse a nuevos correos, pero los correos ya etiquetados conservarán esta etiqueta aunque no tendrá efecto.",
"force_delete_confirmation_title": "¿Eliminar permanentemente esta etiqueta deshabilitada?",
"force_delete_confirmation_description": "Esta etiqueta está deshabilitada pero aún tiene asociaciones de correo electrónico. Eliminarla borrará todas esas asociaciones y la etiqueta de forma permanente. Esta acción no se puede deshacer.",
"deleting": "Procesando",
"confirm": "Confirmar",
"create_success": "Etiqueta de retención creada correctamente.",
"update_success": "Etiqueta de retención actualizada correctamente.",
"delete_success": "Etiqueta de retención eliminada correctamente.",
"disable_success": "Etiqueta de retención deshabilitada correctamente.",
"delete_error": "No se pudo eliminar la etiqueta de retención.",
"create_error": "No se pudo crear la etiqueta de retención.",
"update_error": "No se pudo actualizar la etiqueta de retención.",
"retention_period_locked": "El período de retención no puede modificarse mientras la etiqueta esté aplicada a correos electrónicos.",
"name_placeholder": "p. ej. Registro fiscal - 10 años",
"description_placeholder": "p. ej. Aplicado a documentos fiscales que requieren retención extendida."
},
"archive_labels": {
"section_title": "Etiqueta de retención",
"section_description": "Anule el calendario de retención de este correo electrónico con una etiqueta específica.",
"current_label": "Etiqueta actual",
"no_label": "Sin etiqueta aplicada",
"select_label": "Seleccionar etiqueta",
"select_label_placeholder": "Elegir etiqueta de retención...",
"apply": "Aplicar etiqueta",
"applying": "Aplicando...",
"remove": "Eliminar etiqueta",
"removing": "Eliminando...",
"apply_success": "Etiqueta de retención aplicada correctamente.",
"remove_success": "Etiqueta de retención eliminada correctamente.",
"apply_error": "No se pudo aplicar la etiqueta de retención.",
"remove_error": "No se pudo eliminar la etiqueta de retención.",
"label_overrides_policy": "Esta etiqueta anula las políticas de retención generales para este correo electrónico.",
"no_labels_available": "No hay etiquetas de retención disponibles. Cree etiquetas en la configuración de cumplimiento.",
"label_inactive": "Inactivo",
"label_inactive_note": "Esta etiqueta ha sido deshabilitada. Ya no proporciona una anulación de retención ni una fecha de eliminación programada para este correo electrónico. Puede eliminarla y aplicar una etiqueta activa si es necesario."
},
"legal_holds": {
"title": "Retenciones legales",
"header": "Retenciones legales",
"meta_description": "Gestione las retenciones legales para preservar correos electrónicos de la eliminación automática durante litigios o investigaciones regulatorias.",
"header_description": "Las retenciones legales suspenden la eliminación automática de registros específicos relevantes para litigios o investigaciones regulatorias.",
"create_new": "Crear retención",
"no_holds_found": "No se encontraron retenciones legales.",
"name": "Nombre",
"reason": "Motivo / Descripción",
"no_reason": "Sin motivo proporcionado",
"email_count": "Correos protegidos",
"status": "Estado",
"active": "Activo",
"inactive": "Inactivo",
"created_at": "Creado el",
"actions": "Acciones",
"create": "Crear",
"edit": "Editar",
"delete": "Eliminar",
"activate": "Activar",
"deactivate": "Desactivar",
"bulk_apply": "Aplicación masiva mediante búsqueda",
"release_all": "Liberar todos los correos",
"save": "Guardar cambios",
"cancel": "Cancelar",
"confirm": "Confirmar eliminación",
"name_placeholder": "p. ej. Litigio Proyecto Titán - 2026",
"reason_placeholder": "p. ej. Litigio pendiente relacionado con el Proyecto Titán. Todas las comunicaciones deben conservarse.",
"create_description": "Cree una nueva retención legal para evitar la eliminación automática de correos electrónicos relevantes.",
"edit_description": "Actualice el nombre o la descripción de esta retención legal.",
"delete_confirmation_title": "¿Eliminar permanentemente esta retención legal?",
"delete_confirmation_description": "Esto eliminará permanentemente la retención y todas las asociaciones de correo electrónico. Los correos previamente protegidos estarán sujetos a las reglas de retención normales en la próxima ejecución del trabajador del ciclo de vida.",
"bulk_apply_title": "Aplicar retención legal masivamente mediante búsqueda",
"bulk_apply_description": "Busque correos electrónicos usando filtros de texto completo y metadatos. Todos los correos coincidentes se colocarán bajo esta retención legal. La consulta exacta se guarda en el registro de auditoría como prueba del alcance.",
"bulk_query": "Palabras clave de búsqueda",
"bulk_query_placeholder": "p. ej. Proyecto Titán confidencial",
"bulk_query_hint": "Busca en el cuerpo del correo, el asunto y el contenido de los adjuntos a través del índice de texto completo.",
"bulk_from": "De (correo del remitente)",
"bulk_date_start": "Fecha desde",
"bulk_date_end": "Fecha hasta",
"bulk_apply_warning": "Esta acción se aplicará a TODOS los correos electrónicos que coincidan con su búsqueda en todo el archivo. La consulta de búsqueda se registrará permanentemente en el registro de auditoría.",
"bulk_apply_confirm": "Aplicar retención a los correos coincidentes",
"release_all_title": "¿Liberar todos los correos de esta retención?",
"release_all_description": "Todos los correos electrónicos perderán su inmunidad de retención legal. Serán evaluados contra las políticas de retención estándar en la próxima ejecución del trabajador del ciclo de vida y pueden eliminarse permanentemente.",
"release_all_confirm": "Liberar todos los correos",
"create_success": "Retención legal creada correctamente.",
"update_success": "Retención legal actualizada correctamente.",
"delete_success": "Retención legal eliminada correctamente.",
"activated_success": "Retención legal activada. Los correos protegidos ahora son inmunes a la eliminación.",
"deactivated_success": "Retención legal desactivada. Los correos ya no están protegidos por esta retención.",
"bulk_apply_success": "Retención legal aplicada correctamente.",
"release_all_success": "Todos los correos liberados de la retención.",
"create_error": "No se pudo crear la retención legal.",
"update_error": "No se pudo actualizar la retención legal.",
"delete_error": "No se pudo eliminar la retención legal.",
"bulk_apply_error": "Error en la aplicación masiva.",
"release_all_error": "No se pudieron liberar los correos de la retención."
},
"archive_legal_holds": {
"section_title": "Retenciones legales",
"section_description": "Suspenda la eliminación automática de este correo electrónico colocándolo bajo una retención legal.",
"no_holds": "No hay retenciones legales aplicadas a este correo electrónico.",
"hold_name": "Nombre de la retención",
"hold_status": "Estado",
"applied_at": "Aplicado el",
"apply_hold": "Aplicar retención",
"apply_hold_placeholder": "Seleccionar retención legal...",
"apply": "Aplicar retención",
"applying": "Aplicando...",
"remove": "Eliminar",
"removing": "Eliminando...",
"apply_success": "Retención legal aplicada a este correo electrónico.",
"remove_success": "Retención legal eliminada de este correo electrónico.",
"apply_error": "No se pudo aplicar la retención legal.",
"remove_error": "No se pudo eliminar la retención legal.",
"immune_notice": "Este correo electrónico está protegido por una retención legal activa y no puede eliminarse.",
"no_active_holds": "No hay retenciones legales activas disponibles. Cree retenciones en la configuración de cumplimiento."
},
"audit_log": {
"title": "Registro de auditoría",
"header": "Registro de auditoría",
"verify_integrity": "Verificar integridad del registro",
"log_entries": "Entradas del registro",
"timestamp": "Marca de tiempo",
"actor": "Actor",
"action": "Acción",
"target": "Destino",
"details": "Detalles",
"ip_address": "Dirección IP",
"target_type": "Tipo de destino",
"target_id": "ID de destino",
"no_logs_found": "No se encontraron registros de auditoría.",
"prev": "Anterior", "prev": "Anterior",
"next": "Siguiente" "next": "Siguiente",
"log_entry_details": "Detalles de entrada del registro",
"viewing_details_for": "Viendo los detalles completos de la entrada del registro #",
"actor_id": "ID del actor",
"previous_hash": "Hash anterior",
"current_hash": "Hash actual",
"close": "Cerrar",
"verification_successful_title": "Verificación exitosa",
"verification_successful_message": "Integridad del registro de auditoría verificada correctamente.",
"verification_failed_title": "Verificación fallida",
"verification_failed_message": "La verificación de integridad del registro de auditoría falló. Revise los registros del sistema para más detalles.",
"verification_error_message": "Ocurrió un error inesperado durante la verificación. Por favor, inténtelo de nuevo."
},
"jobs": {
"title": "Colas de trabajo",
"queues": "Colas de trabajo",
"active": "Activo",
"completed": "Completado",
"failed": "Fallido",
"delayed": "Retrasado",
"waiting": "En espera",
"paused": "Pausado",
"back_to_queues": "Volver a las colas",
"queue_overview": "Resumen de colas",
"jobs": "Trabajos",
"id": "ID",
"name": "Nombre",
"state": "Estado",
"created_at": "Creado el",
"processed_at": "Procesado el",
"finished_at": "Finalizado el",
"showing": "Mostrando",
"of": "de",
"previous": "Anterior",
"next": "Siguiente",
"ingestion_source": "Fuente de ingesta"
},
"journaling": {
"title": "Registro SMTP",
"header": "Fuentes de registro SMTP",
"meta_description": "Configure puntos de conexión de registro SMTP en tiempo real para el archivado de correo electrónico sin interrupciones desde MTA corporativos.",
"header_description": "Reciba una copia en tiempo real de cada correo electrónico directamente desde su servidor de correo mediante el registro SMTP, garantizando cero pérdida de datos.",
"create_new": "Crear fuente",
"no_sources_found": "No hay fuentes de registro configuradas.",
"name": "Nombre",
"allowed_ips": "IPs permitidas / CIDR",
"require_tls": "Requerir TLS",
"smtp_username": "Usuario SMTP",
"smtp_password": "Contraseña SMTP",
"status": "Estado",
"active": "Activo",
"paused": "Pausado",
"total_received": "Correos recibidos",
"last_received_at": "Último recibido",
"created_at": "Creado el",
"actions": "Acciones",
"create": "Crear",
"edit": "Editar",
"delete": "Eliminar",
"pause": "Pausar",
"activate": "Activar",
"save": "Guardar cambios",
"cancel": "Cancelar",
"confirm": "Confirmar eliminación",
"name_placeholder": "p. ej. Receptor de diario MS365",
"allowed_ips_placeholder": "p. ej. 40.107.0.0/16, 52.100.0.0/14",
"allowed_ips_hint": "Direcciones IP o bloques CIDR separados por comas de sus servidores de correo autorizados a enviar informes de diario.",
"smtp_username_placeholder": "p. ej. journal-tenant-123",
"smtp_password_placeholder": "Ingrese una contraseña segura para la autenticación SMTP",
"smtp_auth_hint": "Opcional. Si se configura, el MTA debe autenticarse con estas credenciales al conectarse.",
"create_description": "Configure un nuevo punto de conexión de registro SMTP. Su MTA enviará informes de diario a este punto para el archivado en tiempo real.",
"edit_description": "Actualice la configuración de esta fuente de registro.",
"delete_confirmation_title": "¿Eliminar esta fuente de registro?",
"delete_confirmation_description": "Esto eliminará permanentemente el punto de conexión de registro y todos los correos electrónicos archivados asociados. Su MTA ya no podrá enviar informes de diario a este punto.",
"deleting": "Eliminando",
"smtp_connection_info": "Información de conexión SMTP",
"smtp_host": "Host",
"smtp_port": "Puerto",
"routing_address": "Dirección de enrutamiento",
"routing_address_hint": "Configure esta dirección como destinatario del diario en su MTA (Exchange, MS365, Postfix).",
"regenerate_address": "Regenerar dirección",
"regenerate_address_warning": "Esto invalidará la dirección actual. Debe actualizar su configuración de MTA para usar la nueva dirección.",
"regenerate_address_confirm": "¿Está seguro de que desea regenerar la dirección de enrutamiento? La dirección actual dejará de funcionar inmediatamente y deberá actualizar su configuración de MTA.",
"regenerate_address_success": "Dirección de enrutamiento regenerada correctamente. Actualice su configuración de MTA con la nueva dirección.",
"regenerate_address_error": "No se pudo regenerar la dirección de enrutamiento.",
"create_success": "Fuente de registro creada correctamente.",
"update_success": "Fuente de registro actualizada correctamente.",
"delete_success": "Fuente de registro eliminada correctamente.",
"create_error": "No se pudo crear la fuente de registro.",
"update_error": "No se pudo actualizar la fuente de registro.",
"delete_error": "No se pudo eliminar la fuente de registro.",
"health_listening": "Listener SMTP: Activo",
"health_down": "Listener SMTP: Inactivo",
"never": "Nunca"
},
"license_page": {
"title": "Estado de la licencia Enterprise",
"meta_description": "Vea el estado actual de su licencia Open Archiver Enterprise.",
"revoked_title": "Licencia no válida",
"revoked_message": "Su licencia ha sido revocada o el período de gracia por exceso de plazas ha expirado. Todas las funciones Enterprise están ahora deshabilitadas. Comuníquese con su gestor de cuenta para obtener asistencia.",
"notice_title": "Aviso",
"seat_limit_exceeded_title": "Límite de plazas superado",
"seat_limit_exceeded_message": "Su licencia cubre {{planSeats}} plazas pero actualmente hay {{activeSeats}} en uso. Reduzca el uso o actualice su plan.",
"seat_limit_grace_deadline": "Las funciones Enterprise se deshabilitarán el {{date}} a menos que se reduzca el número de plazas.",
"customer": "Cliente",
"license_details": "Detalles de la licencia",
"license_status": "Estado de la licencia",
"active": "Activo",
"expired": "Vencido",
"revoked": "Revocado",
"overage": "Exceso de plazas",
"unknown": "Desconocido",
"expires": "Vence",
"last_checked": "Última verificación",
"seat_usage": "Uso de plazas",
"seats_used": "{{activeSeats}} de {{planSeats}} plazas usadas",
"enabled_features": "Funciones habilitadas",
"enabled_features_description": "Las siguientes funciones Enterprise están actualmente habilitadas.",
"feature": "Función",
"status": "Estado",
"enabled": "Habilitado",
"disabled": "Deshabilitado",
"could_not_load_title": "No se pudo cargar la licencia",
"could_not_load_message": "Ocurrió un error inesperado.",
"revalidate": "Revalidar licencia",
"revalidating": "Revalidando...",
"revalidate_success": "Licencia revalidada correctamente."
} }
} }
} }

View File

@@ -7,7 +7,8 @@
"password": "Mot de passe" "password": "Mot de passe"
}, },
"common": { "common": {
"working": "Travail en cours" "working": "Travail en cours",
"read_docs": "Lire la documentation"
}, },
"archive": { "archive": {
"title": "Archive", "title": "Archive",
@@ -32,7 +33,30 @@
"deleting": "Suppression en cours", "deleting": "Suppression en cours",
"confirm": "Confirmer", "confirm": "Confirmer",
"cancel": "Annuler", "cancel": "Annuler",
"not_found": "Email non trouvé." "not_found": "Email non trouvé.",
"integrity_report": "Rapport d'intégrité",
"download_integrity_report_pdf": "Télécharger le rapport d'intégrité (PDF)",
"downloading_integrity_report": "Génération en cours...",
"integrity_report_download_error": "Échec de la génération du rapport d'intégrité.",
"email_eml": "Email (.eml)",
"valid": "Valide",
"invalid": "Non valide",
"integrity_check_failed_title": "Échec de la vérification d'intégrité",
"integrity_check_failed_message": "Impossible de vérifier l'intégrité de l'email et de ses pièces jointes.",
"integrity_report_description": "Ce rapport vérifie que le contenu de vos emails archivés n'a pas été modifié.",
"retention_policy": "Politique de conservation",
"retention_policy_description": "Indique quelle politique de conservation régit cet email et quand sa suppression est prévue.",
"retention_no_policy": "Aucune politique applicable — cet email ne sera pas supprimé automatiquement.",
"retention_period": "Durée de conservation",
"retention_action": "Action à l'expiration",
"retention_matching_policies": "Politiques applicables",
"retention_delete_permanently": "Suppression définitive",
"retention_scheduled_deletion": "Suppression planifiée",
"retention_policy_overridden_by_label": "Cette politique est remplacée par l'étiquette de conservation ",
"embedded_attachments": "Pièces jointes intégrées",
"embedded": "Intégré",
"embedded_attachment_title": "Pièce jointe intégrée",
"embedded_attachment_description": "Cette pièce jointe est intégrée dans le fichier email d'origine et ne peut pas être téléchargée séparément. Pour l'obtenir, téléchargez le fichier email complet (.eml)."
}, },
"ingestions": { "ingestions": {
"title": "Sources d'ingestion", "title": "Sources d'ingestion",
@@ -63,7 +87,19 @@
"confirm": "Confirmer", "confirm": "Confirmer",
"cancel": "Annuler", "cancel": "Annuler",
"bulk_delete_confirmation_title": "Êtes-vous sûr de vouloir supprimer {{count}} ingestions sélectionnées ?", "bulk_delete_confirmation_title": "Êtes-vous sûr de vouloir supprimer {{count}} ingestions sélectionnées ?",
"bulk_delete_confirmation_description": "Cela supprimera tous les emails archivés, les pièces jointes, l'indexation et les fichiers associés à ces ingestions. Si vous souhaitez uniquement arrêter la synchronisation des nouveaux emails, vous pouvez suspendre les ingestions à la place." "bulk_delete_confirmation_description": "Cela supprimera tous les emails archivés, les pièces jointes, l'indexation et les fichiers associés à ces ingestions. Si vous souhaitez uniquement arrêter la synchronisation des nouveaux emails, vous pouvez suspendre les ingestions à la place.",
"merged_sources": "sources fusionnées",
"unmerge": "Dissocier",
"unmerge_success": "La source a été dissociée de son groupe.",
"unmerge_confirmation_title": "Dissocier cette source ?",
"unmerge_confirmation_description": "Cela dissociera la source secondaire du groupe et en fera une ingestion indépendante. Veuillez noter ce qui suit :",
"unmerge_warning_emails": "Les emails déjà ingérés par cette source sont stockés sous la source racine. Ils y resteront et ne seront pas déplacés.",
"unmerge_warning_future": "Seuls les nouveaux emails ingérés après la dissociation seront stockés sous cette source.",
"unmerge_confirm": "Dissocier",
"unmerging": "Dissociation en cours",
"delete_root_warning": "Cette ingestion possède {{count}} source(s) fusionnée(s). Sa suppression entraînera également la suppression de toutes les sources fusionnées et de leurs données.",
"expand": "Développer",
"collapse": "Réduire"
}, },
"search": { "search": {
"title": "Recherche", "title": "Recherche",
@@ -110,6 +146,23 @@
"confirm": "Confirmer", "confirm": "Confirmer",
"cancel": "Annuler" "cancel": "Annuler"
}, },
"account": {
"title": "Paramètres du compte",
"description": "Gérez votre profil et vos paramètres de sécurité.",
"personal_info": "Informations personnelles",
"personal_info_desc": "Mettez à jour vos données personnelles.",
"security": "Sécurité",
"security_desc": "Gérez votre mot de passe et vos préférences de sécurité.",
"edit_profile": "Modifier le profil",
"change_password": "Changer le mot de passe",
"edit_profile_desc": "Modifiez votre profil ici.",
"change_password_desc": "Changez votre mot de passe. Vous devrez saisir votre mot de passe actuel.",
"current_password": "Mot de passe actuel",
"new_password": "Nouveau mot de passe",
"confirm_new_password": "Confirmer le nouveau mot de passe",
"operation_successful": "Opération réussie",
"passwords_do_not_match": "Les mots de passe ne correspondent pas"
},
"system_settings": { "system_settings": {
"title": "Paramètres système", "title": "Paramètres système",
"system_settings": "Paramètres système", "system_settings": "Paramètres système",
@@ -146,6 +199,76 @@
"confirm": "Confirmer", "confirm": "Confirmer",
"cancel": "Annuler" "cancel": "Annuler"
}, },
"components": {
"charts": {
"emails_ingested": "E-mails ingérés",
"storage_used": "Stockage utilisé",
"emails": "E-mails"
},
"common": {
"submitting": "Soumission...",
"submit": "Soumettre",
"save": "Enregistrer"
},
"email_preview": {
"loading": "Chargement de l'aperçu de l'email...",
"render_error": "Impossible de rendre l'aperçu de l'email.",
"not_available": "Le fichier .eml brut n'est pas disponible pour cet email."
},
"footer": {
"all_rights_reserved": "Tous droits réservés.",
"new_version_available": "Nouvelle version disponible"
},
"ingestion_source_form": {
"provider_generic_imap": "IMAP générique",
"provider_google_workspace": "Google Workspace",
"provider_microsoft_365": "Microsoft 365",
"provider_pst_import": "Importation PST",
"provider_eml_import": "Importation EML",
"provider_mbox_import": "Importation Mbox",
"select_provider": "Sélectionnez un fournisseur",
"import_method": "Méthode d'importation",
"upload_file": "Téléverser un fichier",
"local_path": "Chemin local (recommandé pour les fichiers volumineux)",
"local_file_path": "Chemin de fichier local",
"service_account_key": "Clé de compte de service (JSON)",
"service_account_key_placeholder": "Collez le contenu JSON de votre clé de compte de service",
"impersonated_admin_email": "Email de l'administrateur impersonné",
"client_id": "ID de l'application (client)",
"client_secret": "Valeur secrète du client",
"client_secret_placeholder": "Entrez la valeur secrète, pas l'ID secret",
"tenant_id": "ID du répertoire (locataire)",
"host": "Hôte",
"port": "Port",
"username": "Nom d'utilisateur",
"use_tls": "Utiliser TLS",
"allow_insecure_cert": "Autoriser les certificats non sécurisés",
"pst_file": "Fichier PST",
"eml_file": "Fichier EML",
"mbox_file": "Fichier Mbox",
"heads_up": "Attention !",
"org_wide_warning": "Veuillez noter qu'il s'agit d'une opération à l'échelle de l'organisation. Ce type d'ingestion importera et indexera <b>toutes</b> les boîtes de réception de votre organisation. Si vous souhaitez importer uniquement des boîtes de réception spécifiques, utilisez le connecteur IMAP.",
"upload_failed": "Échec du téléversement, veuillez réessayer",
"upload_network_error": "Le serveur n'a pas pu traiter le téléversement. Le fichier dépasse peut-être la limite de taille configurée (BODY_SIZE_LIMIT). Pour les fichiers très volumineux, utilisez l'option Chemin local.",
"merge_into": "Fusionner avec une ingestion existante",
"merge_into_description": "Les emails de cette source seront regroupés avec l'ingestion sélectionnée. Les deux sources se synchronisent indépendamment mais les emails apparaissent ensemble.",
"merge_into_tooltip": "Lors de la fusion, cette nouvelle source devient une source secondaire de l'ingestion racine sélectionnée. Tous les emails récupérés par cette source seront physiquement stockés sous l'ingestion racine, pas sous celle-ci.<br><br>Le paramètre <b>Conserver le fichier original</b> (conformité GoBD) de l'ingestion racine s'applique à l'ensemble du groupe. Le paramètre de ce formulaire est ignoré si la fusion est activée.<br><br>Les deux sources se synchronisent indépendamment selon leur propre calendrier.",
"merge_into_select": "Sélectionner l'ingestion à fusionner",
"advanced_options": "Options avancées",
"preserve_original_file": "Conserver le fichier original",
"preserve_original_file_tooltip": "Si coché : Stocke le fichier email exact et non modifié tel que reçu du serveur. Aucune pièce jointe n'est supprimée. Requis pour la conformité GoBD (Allemagne) et SEC 17a-4.<br><br>Si décoché : Supprime les pièces jointes non intégrées et les stocke séparément avec déduplication, économisant de l'espace de stockage."
},
"role_form": {
"policies_json": "Politiques (JSON)",
"invalid_json": "Format JSON invalide pour les politiques."
},
"theme_switcher": {
"toggle_theme": "Changer de thème"
},
"user_form": {
"select_role": "Sélectionnez un rôle"
}
},
"setup": { "setup": {
"title": "Configuration", "title": "Configuration",
"description": "Configurez le compte administrateur initial pour Open Archiver.", "description": "Configurez le compte administrateur initial pour Open Archiver.",
@@ -167,61 +290,52 @@
"system": "Système", "system": "Système",
"users": "Utilisateurs", "users": "Utilisateurs",
"roles": "Rôles", "roles": "Rôles",
"logout": "Déconnexion" "api_keys": "Clés API",
"account": "Compte",
"logout": "Déconnexion",
"admin": "Admin"
}, },
"components": { "api_keys_page": {
"charts": { "title": "Clés API",
"emails_ingested": "E-mails ingérés", "header": "Clés API",
"storage_used": "Stockage utilisé", "generate_new_key": "Générer une nouvelle clé",
"emails": "E-mails" "name": "Nom",
}, "key": "Clé",
"common": { "expires_at": "Expire le",
"submitting": "Soumission...", "created_at": "Créé le",
"submit": "Soumettre", "actions": "Actions",
"save": "Enregistrer" "delete": "Supprimer",
}, "no_keys_found": "Aucune clé API trouvée.",
"email_preview": { "generate_modal_title": "Générer une nouvelle clé API",
"loading": "Chargement de l'aperçu de l'email...", "generate_modal_description": "Veuillez fournir un nom et une date d'expiration pour votre nouvelle clé API.",
"render_error": "Impossible de rendre l'aperçu de l'email.", "expires_in": "Expire dans",
"not_available": "Le fichier .eml brut n'est pas disponible pour cet email." "select_expiration": "Sélectionnez une expiration",
}, "30_days": "30 jours",
"footer": { "60_days": "60 jours",
"all_rights_reserved": "Tous droits réservés." "6_months": "6 mois",
}, "12_months": "12 mois",
"ingestion_source_form": { "24_months": "24 mois",
"provider_generic_imap": "IMAP générique", "generate": "Générer",
"provider_google_workspace": "Google Workspace", "new_api_key": "Nouvelle clé API",
"provider_microsoft_365": "Microsoft 365", "failed_to_delete": "Échec de la suppression de la clé API",
"provider_pst_import": "Importation PST", "api_key_deleted": "Clé API supprimée",
"provider_eml_import": "Importation EML", "generated_title": "Clé API générée",
"select_provider": "Sélectionnez un fournisseur", "generated_message": "Votre clé API a été générée. Veuillez la copier et la sauvegarder dans un endroit sécurisé. Cette clé ne sera affichée qu'une seule fois."
"service_account_key": "Clé de compte de service (JSON)", },
"service_account_key_placeholder": "Collez le contenu JSON de votre clé de compte de service", "archived_emails_page": {
"impersonated_admin_email": "Email de l'administrateur impersonné", "title": "E-mails archivés",
"client_id": "ID de l'application (client)", "header": "E-mails archivés",
"client_secret": "Valeur secrète du client", "select_ingestion_source": "Sélectionnez une source d'ingestion",
"client_secret_placeholder": "Entrez la valeur secrète, pas l'ID secret", "date": "Date",
"tenant_id": "ID du répertoire (locataire)", "subject": "Sujet",
"host": "Hôte", "sender": "Expéditeur",
"port": "Port", "inbox": "Boîte de réception",
"username": "Nom d'utilisateur", "path": "Chemin",
"use_tls": "Utiliser TLS", "actions": "Actions",
"pst_file": "Fichier PST", "view": "Voir",
"eml_file": "Fichier EML", "no_emails_found": "Aucun e-mail archivé trouvé.",
"heads_up": "Attention !", "prev": "Préc",
"org_wide_warning": "Veuillez noter qu'il s'agit d'une opération à l'échelle de l'organisation. Ce type d'ingestion importera et indexera <b>toutes</b> les boîtes de réception de votre organisation. Si vous souhaitez importer uniquement des boîtes de réception spécifiques, utilisez le connecteur IMAP.", "next": "Suiv"
"upload_failed": "Échec du téléchargement, veuillez réessayer"
},
"role_form": {
"policies_json": "Politiques (JSON)",
"invalid_json": "Format JSON invalide pour les politiques."
},
"theme_switcher": {
"toggle_theme": "Changer de thème"
},
"user_form": {
"select_role": "Sélectionnez un rôle"
}
}, },
"dashboard_page": { "dashboard_page": {
"title": "Tableau de bord", "title": "Tableau de bord",
@@ -241,20 +355,372 @@
"top_10_senders": "Top 10 des expéditeurs", "top_10_senders": "Top 10 des expéditeurs",
"no_indexed_insights": "Aucune information indexée disponible." "no_indexed_insights": "Aucune information indexée disponible."
}, },
"archived_emails_page": { "retention_policies": {
"title": "E-mails archivés", "title": "Politiques de conservation",
"header": "E-mails archivés", "header": "Politiques de conservation",
"select_ingestion_source": "Sélectionnez une source d'ingestion", "meta_description": "Gérez les politiques de conservation des données pour automatiser le cycle de vie des emails et la conformité.",
"date": "Date", "create_new": "Créer une nouvelle politique",
"subject": "Sujet", "no_policies_found": "Aucune politique de conservation trouvée.",
"sender": "Expéditeur", "name": "Nom",
"inbox": "Boîte de réception", "description": "Description",
"path": "Chemin", "priority": "Priorité",
"retention_period": "Durée de conservation",
"retention_period_days": "Durée de conservation (jours)",
"action_on_expiry": "Action à l'expiration",
"delete_permanently": "Supprimer définitivement",
"status": "Statut",
"active": "Actif",
"inactive": "Inactif",
"conditions": "Conditions",
"conditions_description": "Définissez des règles pour faire correspondre les emails. Si aucune condition n'est définie, la politique s'applique à tous les emails.",
"logical_operator": "Opérateur logique",
"and": "ET",
"or": "OU",
"add_rule": "Ajouter une règle",
"remove_rule": "Supprimer la règle",
"field": "Champ",
"field_sender": "Expéditeur",
"field_recipient": "Destinataire",
"field_subject": "Sujet",
"field_attachment_type": "Type de pièce jointe",
"operator": "Opérateur",
"operator_equals": "Égal à",
"operator_not_equals": "Différent de",
"operator_contains": "Contient",
"operator_not_contains": "Ne contient pas",
"operator_starts_with": "Commence par",
"operator_ends_with": "Se termine par",
"operator_domain_match": "Correspondance de domaine",
"operator_regex_match": "Correspondance regex",
"value": "Valeur",
"value_placeholder": "ex. utilisateur@exemple.com",
"edit": "Modifier",
"delete": "Supprimer",
"create": "Créer",
"save": "Enregistrer les modifications",
"cancel": "Annuler",
"create_description": "Créez une nouvelle politique de conservation pour gérer le cycle de vie des emails archivés.",
"edit_description": "Mettez à jour les paramètres de cette politique de conservation.",
"delete_confirmation_title": "Supprimer cette politique de conservation ?",
"delete_confirmation_description": "Cette action est irréversible. Les emails correspondant à cette politique ne seront plus soumis à la suppression automatique.",
"deleting": "Suppression en cours",
"confirm": "Confirmer",
"days": "jours",
"no_conditions": "Tous les emails (sans filtre)",
"rules": "règles",
"simulator_title": "Simulateur de politique",
"simulator_description": "Testez les métadonnées d'un email contre toutes les politiques actives pour voir quelle durée de conservation s'appliquerait.",
"simulator_sender": "Email de l'expéditeur",
"simulator_sender_placeholder": "ex. jean@finances.entreprise.fr",
"simulator_recipients": "Destinataires",
"simulator_recipients_placeholder": "Séparés par des virgules, ex. marie@entreprise.fr, paul@entreprise.fr",
"simulator_subject": "Sujet",
"simulator_subject_placeholder": "ex. Rapport fiscal T4",
"simulator_attachment_types": "Types de pièces jointes",
"simulator_attachment_types_placeholder": "Séparés par des virgules, ex. .pdf, .xlsx",
"simulator_run": "Lancer la simulation",
"simulator_running": "En cours...",
"simulator_result_title": "Résultat de la simulation",
"simulator_no_match": "Aucune politique active ne correspond à cet email. Il ne sera pas soumis à une suppression automatique.",
"simulator_matched": "Correspondance — durée de conservation de {{days}} jours applicable.",
"simulator_matching_policies": "IDs des politiques correspondantes",
"simulator_no_result": "Lancez une simulation pour voir quelles politiques s'appliquent à un email donné.",
"simulator_ingestion_source": "Simuler pour la source d'ingestion",
"simulator_ingestion_source_description": "Sélectionnez une source d'ingestion pour tester les politiques limitées. Laissez vide pour évaluer toutes les politiques quel que soit leur périmètre.",
"simulator_ingestion_all": "Toutes les sources (ignorer le périmètre)",
"ingestion_scope": "Périmètre d'ingestion",
"ingestion_scope_description": "Limitez cette politique à des sources d'ingestion spécifiques. Laissez tout décoché pour l'appliquer à toutes les sources.",
"ingestion_scope_all": "Toutes les sources d'ingestion",
"ingestion_scope_selected": "{{count}} source(s) sélectionnée(s) — cette politique s'appliquera uniquement aux emails de ces sources.",
"create_success": "Politique de conservation créée avec succès.",
"update_success": "Politique de conservation mise à jour avec succès.",
"delete_success": "Politique de conservation supprimée avec succès.",
"delete_error": "Échec de la suppression de la politique de conservation."
},
"retention_labels": {
"title": "Étiquettes de conservation",
"header": "Étiquettes de conservation",
"meta_description": "Gérez les étiquettes de conservation pour les remplacements de conformité au niveau des éléments sur les emails archivés individuels.",
"create_new": "Créer une étiquette",
"no_labels_found": "Aucune étiquette de conservation trouvée.",
"name": "Nom",
"description": "Description",
"retention_period": "Durée de conservation",
"retention_period_days": "Durée de conservation (jours)",
"applied_count": "Emails appliqués",
"status": "Statut",
"enabled": "Activé",
"disabled": "Désactivé",
"created_at": "Créé le",
"actions": "Actions", "actions": "Actions",
"view": "Voir", "create": "Créer",
"no_emails_found": "Aucun e-mail archivé trouvé.", "edit": "Modifier",
"delete": "Supprimer",
"disable": "Désactiver",
"save": "Enregistrer les modifications",
"cancel": "Annuler",
"days": "jours",
"create_description": "Créez une nouvelle étiquette de conservation. Une fois appliquée aux emails, la durée de conservation de l'étiquette ne peut pas être modifiée.",
"edit_description": "Mettez à jour les détails de cette étiquette de conservation.",
"delete_confirmation_title": "Supprimer cette étiquette de conservation ?",
"delete_confirmation_description": "Cette action supprimera définitivement l'étiquette. Elle ne pourra plus être appliquée aux nouveaux emails.",
"disable_confirmation_title": "Désactiver cette étiquette de conservation ?",
"disable_confirmation_description": "Cette étiquette est actuellement appliquée à des emails archivés et ne peut pas être supprimée. Elle sera désactivée afin de ne plus pouvoir être appliquée aux nouveaux emails, mais les emails déjà étiquetés conserveront cette étiquette même si elle n'aura plus d'effet.",
"force_delete_confirmation_title": "Supprimer définitivement cette étiquette désactivée ?",
"force_delete_confirmation_description": "Cette étiquette est désactivée mais possède encore des associations d'emails. Sa suppression effacera toutes ces associations et supprimera définitivement l'étiquette. Cette action est irréversible.",
"deleting": "Traitement en cours",
"confirm": "Confirmer",
"create_success": "Étiquette de conservation créée avec succès.",
"update_success": "Étiquette de conservation mise à jour avec succès.",
"delete_success": "Étiquette de conservation supprimée avec succès.",
"disable_success": "Étiquette de conservation désactivée avec succès.",
"delete_error": "Échec de la suppression de l'étiquette de conservation.",
"create_error": "Échec de la création de l'étiquette de conservation.",
"update_error": "Échec de la mise à jour de l'étiquette de conservation.",
"retention_period_locked": "La durée de conservation ne peut pas être modifiée tant que l'étiquette est appliquée à des emails.",
"name_placeholder": "ex. Document fiscal - 10 ans",
"description_placeholder": "ex. Appliqué aux documents fiscaux nécessitant une conservation prolongée."
},
"archive_labels": {
"section_title": "Étiquette de conservation",
"section_description": "Remplacez le calendrier de conservation de cet email par une étiquette spécifique.",
"current_label": "Étiquette actuelle",
"no_label": "Aucune étiquette appliquée",
"select_label": "Sélectionner une étiquette",
"select_label_placeholder": "Choisir une étiquette de conservation...",
"apply": "Appliquer l'étiquette",
"applying": "Application en cours...",
"remove": "Supprimer l'étiquette",
"removing": "Suppression en cours...",
"apply_success": "Étiquette de conservation appliquée avec succès.",
"remove_success": "Étiquette de conservation supprimée avec succès.",
"apply_error": "Échec de l'application de l'étiquette de conservation.",
"remove_error": "Échec de la suppression de l'étiquette de conservation.",
"label_overrides_policy": "Cette étiquette remplace les politiques de conservation générales pour cet email.",
"no_labels_available": "Aucune étiquette de conservation disponible. Créez des étiquettes dans les paramètres de conformité.",
"label_inactive": "Inactif",
"label_inactive_note": "Cette étiquette a été désactivée. Elle ne fournit plus de remplacement de conservation ni de date de suppression planifiée pour cet email. Vous pouvez la supprimer et appliquer une étiquette active si nécessaire."
},
"legal_holds": {
"title": "Conservations légales",
"header": "Conservations légales",
"meta_description": "Gérez les conservations légales pour préserver les emails de la suppression automatique pendant les litiges ou enquêtes réglementaires.",
"header_description": "Les conservations légales suspendent la suppression automatique des enregistrements spécifiques pertinents pour les litiges ou enquêtes réglementaires.",
"create_new": "Créer une conservation",
"no_holds_found": "Aucune conservation légale trouvée.",
"name": "Nom",
"reason": "Motif / Description",
"no_reason": "Aucun motif fourni",
"email_count": "Emails protégés",
"status": "Statut",
"active": "Actif",
"inactive": "Inactif",
"created_at": "Créé le",
"actions": "Actions",
"create": "Créer",
"edit": "Modifier",
"delete": "Supprimer",
"activate": "Activer",
"deactivate": "Désactiver",
"bulk_apply": "Application en masse via recherche",
"release_all": "Libérer tous les emails",
"save": "Enregistrer les modifications",
"cancel": "Annuler",
"confirm": "Confirmer la suppression",
"name_placeholder": "ex. Litige Projet Titan - 2026",
"reason_placeholder": "ex. Litige en cours lié au Projet Titan. Toutes les communications doivent être conservées.",
"create_description": "Créez une nouvelle conservation légale pour empêcher la suppression automatique des emails pertinents.",
"edit_description": "Mettez à jour le nom ou la description de cette conservation légale.",
"delete_confirmation_title": "Supprimer définitivement cette conservation légale ?",
"delete_confirmation_description": "Cela supprimera définitivement la conservation et toutes les associations d'emails. Les emails précédemment protégés seront soumis aux règles de conservation normales lors de la prochaine exécution du worker du cycle de vie.",
"bulk_apply_title": "Appliquer la conservation légale en masse via recherche",
"bulk_apply_description": "Recherchez des emails en utilisant des filtres de texte intégral et de métadonnées. Tous les emails correspondants seront placés sous cette conservation légale. La requête exacte est sauvegardée dans le journal d'audit comme preuve du périmètre.",
"bulk_query": "Mots-clés de recherche",
"bulk_query_placeholder": "ex. Projet Titan confidentiel",
"bulk_query_hint": "Recherche dans le corps de l'email, le sujet et le contenu des pièces jointes via l'index de texte intégral.",
"bulk_from": "De (email de l'expéditeur)",
"bulk_date_start": "Date de début",
"bulk_date_end": "Date de fin",
"bulk_apply_warning": "Cette action s'appliquera à TOUS les emails correspondant à votre recherche dans l'ensemble de l'archive. La requête de recherche sera enregistrée définitivement dans le journal d'audit.",
"bulk_apply_confirm": "Appliquer la conservation aux emails correspondants",
"release_all_title": "Libérer tous les emails de cette conservation ?",
"release_all_description": "Tous les emails perdront leur immunité de conservation légale. Ils seront évalués par rapport aux politiques de conservation standard lors de la prochaine exécution du worker du cycle de vie et pourront être supprimés définitivement.",
"release_all_confirm": "Libérer tous les emails",
"create_success": "Conservation légale créée avec succès.",
"update_success": "Conservation légale mise à jour avec succès.",
"delete_success": "Conservation légale supprimée avec succès.",
"activated_success": "Conservation légale activée. Les emails protégés sont désormais immunisés contre la suppression.",
"deactivated_success": "Conservation légale désactivée. Les emails ne sont plus protégés par cette conservation.",
"bulk_apply_success": "Conservation légale appliquée avec succès.",
"release_all_success": "Tous les emails libérés de la conservation.",
"create_error": "Échec de la création de la conservation légale.",
"update_error": "Échec de la mise à jour de la conservation légale.",
"delete_error": "Échec de la suppression de la conservation légale.",
"bulk_apply_error": "Échec de l'application en masse.",
"release_all_error": "Échec de la libération des emails de la conservation."
},
"archive_legal_holds": {
"section_title": "Conservations légales",
"section_description": "Suspendez la suppression automatique de cet email en le plaçant sous une conservation légale.",
"no_holds": "Aucune conservation légale appliquée à cet email.",
"hold_name": "Nom de la conservation",
"hold_status": "Statut",
"applied_at": "Appliqué le",
"apply_hold": "Appliquer une conservation",
"apply_hold_placeholder": "Sélectionner une conservation légale...",
"apply": "Appliquer la conservation",
"applying": "Application en cours...",
"remove": "Supprimer",
"removing": "Suppression en cours...",
"apply_success": "Conservation légale appliquée à cet email.",
"remove_success": "Conservation légale supprimée de cet email.",
"apply_error": "Échec de l'application de la conservation légale.",
"remove_error": "Échec de la suppression de la conservation légale.",
"immune_notice": "Cet email est protégé par une conservation légale active et ne peut pas être supprimé.",
"no_active_holds": "Aucune conservation légale active disponible. Créez des conservations dans les paramètres de conformité."
},
"audit_log": {
"title": "Journal d'audit",
"header": "Journal d'audit",
"verify_integrity": "Vérifier l'intégrité du journal",
"log_entries": "Entrées du journal",
"timestamp": "Horodatage",
"actor": "Acteur",
"action": "Action",
"target": "Cible",
"details": "Détails",
"ip_address": "Adresse IP",
"target_type": "Type de cible",
"target_id": "ID de cible",
"no_logs_found": "Aucun journal d'audit trouvé.",
"prev": "Préc", "prev": "Préc",
"next": "Suiv" "next": "Suiv",
"log_entry_details": "Détails de l'entrée du journal",
"viewing_details_for": "Affichage des détails complets de l'entrée du journal #",
"actor_id": "ID de l'acteur",
"previous_hash": "Hash précédent",
"current_hash": "Hash actuel",
"close": "Fermer",
"verification_successful_title": "Vérification réussie",
"verification_successful_message": "Intégrité du journal d'audit vérifiée avec succès.",
"verification_failed_title": "Échec de la vérification",
"verification_failed_message": "La vérification d'intégrité du journal d'audit a échoué. Veuillez consulter les journaux système pour plus de détails.",
"verification_error_message": "Une erreur inattendue s'est produite lors de la vérification. Veuillez réessayer."
},
"jobs": {
"title": "Files de travaux",
"queues": "Files de travaux",
"active": "Actif",
"completed": "Terminé",
"failed": "Échoué",
"delayed": "Retardé",
"waiting": "En attente",
"paused": "En pause",
"back_to_queues": "Retour aux files",
"queue_overview": "Aperçu des files",
"jobs": "Travaux",
"id": "ID",
"name": "Nom",
"state": "État",
"created_at": "Créé le",
"processed_at": "Traité le",
"finished_at": "Terminé le",
"showing": "Affichage",
"of": "sur",
"previous": "Précédent",
"next": "Suivant",
"ingestion_source": "Source d'ingestion"
},
"journaling": {
"title": "Journalisation SMTP",
"header": "Sources de journalisation SMTP",
"meta_description": "Configurez des points de terminaison de journalisation SMTP en temps réel pour l'archivage d'emails sans interruption depuis les MTA d'entreprise.",
"header_description": "Recevez une copie en temps réel de chaque email directement depuis votre serveur de messagerie via la journalisation SMTP, garantissant zéro perte de données.",
"create_new": "Créer une source",
"no_sources_found": "Aucune source de journalisation configurée.",
"name": "Nom",
"allowed_ips": "IPs autorisées / CIDR",
"require_tls": "Exiger TLS",
"smtp_username": "Nom d'utilisateur SMTP",
"smtp_password": "Mot de passe SMTP",
"status": "Statut",
"active": "Actif",
"paused": "En pause",
"total_received": "Emails reçus",
"last_received_at": "Dernier reçu",
"created_at": "Créé le",
"actions": "Actions",
"create": "Créer",
"edit": "Modifier",
"delete": "Supprimer",
"pause": "Mettre en pause",
"activate": "Activer",
"save": "Enregistrer les modifications",
"cancel": "Annuler",
"confirm": "Confirmer la suppression",
"name_placeholder": "ex. Récepteur journal MS365",
"allowed_ips_placeholder": "ex. 40.107.0.0/16, 52.100.0.0/14",
"allowed_ips_hint": "Adresses IP ou blocs CIDR séparés par des virgules de vos serveurs de messagerie autorisés à envoyer des rapports de journal.",
"smtp_username_placeholder": "ex. journal-tenant-123",
"smtp_password_placeholder": "Entrez un mot de passe fort pour l'authentification SMTP",
"smtp_auth_hint": "Optionnel. Si configuré, le MTA doit s'authentifier avec ces identifiants lors de la connexion.",
"create_description": "Configurez un nouveau point de terminaison de journalisation SMTP. Votre MTA enverra des rapports de journal à ce point pour l'archivage en temps réel.",
"edit_description": "Mettez à jour la configuration de cette source de journalisation.",
"delete_confirmation_title": "Supprimer cette source de journalisation ?",
"delete_confirmation_description": "Cela supprimera définitivement le point de terminaison de journalisation et tous les emails archivés associés. Votre MTA ne pourra plus envoyer de rapports de journal à ce point.",
"deleting": "Suppression en cours",
"smtp_connection_info": "Informations de connexion SMTP",
"smtp_host": "Hôte",
"smtp_port": "Port",
"routing_address": "Adresse de routage",
"routing_address_hint": "Configurez cette adresse comme destinataire du journal dans votre MTA (Exchange, MS365, Postfix).",
"regenerate_address": "Régénérer l'adresse",
"regenerate_address_warning": "Cela invalidera l'adresse actuelle. Vous devez mettre à jour votre configuration MTA pour utiliser la nouvelle adresse.",
"regenerate_address_confirm": "Êtes-vous sûr de vouloir régénérer l'adresse de routage ? L'adresse actuelle cessera de fonctionner immédiatement et vous devrez mettre à jour votre configuration MTA.",
"regenerate_address_success": "Adresse de routage régénérée avec succès. Mettez à jour votre configuration MTA avec la nouvelle adresse.",
"regenerate_address_error": "Échec de la régénération de l'adresse de routage.",
"create_success": "Source de journalisation créée avec succès.",
"update_success": "Source de journalisation mise à jour avec succès.",
"delete_success": "Source de journalisation supprimée avec succès.",
"create_error": "Échec de la création de la source de journalisation.",
"update_error": "Échec de la mise à jour de la source de journalisation.",
"delete_error": "Échec de la suppression de la source de journalisation.",
"health_listening": "Listener SMTP : Actif",
"health_down": "Listener SMTP : Inactif",
"never": "Jamais"
},
"license_page": {
"title": "Statut de la licence Enterprise",
"meta_description": "Consultez le statut actuel de votre licence Open Archiver Enterprise.",
"revoked_title": "Licence non valide",
"revoked_message": "Votre licence a été révoquée ou votre période de grâce pour dépassement de sièges a expiré. Toutes les fonctionnalités Enterprise sont désormais désactivées. Veuillez contacter votre gestionnaire de compte pour obtenir de l'aide.",
"notice_title": "Avis",
"seat_limit_exceeded_title": "Limite de sièges dépassée",
"seat_limit_exceeded_message": "Votre licence couvre {{planSeats}} sièges mais {{activeSeats}} sont actuellement utilisés. Veuillez réduire l'utilisation ou mettre à niveau votre plan.",
"seat_limit_grace_deadline": "Les fonctionnalités Enterprise seront désactivées le {{date}} à moins que le nombre de sièges ne soit réduit.",
"customer": "Client",
"license_details": "Détails de la licence",
"license_status": "Statut de la licence",
"active": "Actif",
"expired": "Expiré",
"revoked": "Révoqué",
"overage": "Dépassement de sièges",
"unknown": "Inconnu",
"expires": "Expire",
"last_checked": "Dernière vérification",
"seat_usage": "Utilisation des sièges",
"seats_used": "{{activeSeats}} sur {{planSeats}} sièges utilisés",
"enabled_features": "Fonctionnalités activées",
"enabled_features_description": "Les fonctionnalités Enterprise suivantes sont actuellement activées.",
"feature": "Fonctionnalité",
"status": "Statut",
"enabled": "Activé",
"disabled": "Désactivé",
"could_not_load_title": "Impossible de charger la licence",
"could_not_load_message": "Une erreur inattendue s'est produite.",
"revalidate": "Revalider la licence",
"revalidating": "Revalidation en cours...",
"revalidate_success": "Licence revalidée avec succès."
} }
} }
} }

View File

@@ -75,6 +75,16 @@
]; ];
const enterpriseNavItems: NavItem[] = [ const enterpriseNavItems: NavItem[] = [
{
label: $t('app.archive.title'),
subMenu: [
{
href: '/dashboard/ingestions/journaling',
label: $t('app.journaling.title'),
},
],
position: 1,
},
{ {
label: 'Compliance', label: 'Compliance',
subMenu: [ subMenu: [

View File

@@ -37,7 +37,7 @@
<div class="space-y-6"> <div class="space-y-6">
<h1 class="text-2xl font-bold">{$t('app.license_page.title')}</h1> <h1 class="text-2xl font-bold">{$t('app.license_page.title')}</h1>
{#if data.licenseStatus.remoteStatus === 'REVOKED'} {#if data.licenseStatus.remoteStatus === 'INVALID'}
<Card class="border-destructive"> <Card class="border-destructive">
<CardHeader> <CardHeader>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
@@ -112,7 +112,7 @@
> >
{:else if data.licenseStatus.isExpired} {:else if data.licenseStatus.isExpired}
<Badge variant="destructive">{$t('app.license_page.expired')}</Badge> <Badge variant="destructive">{$t('app.license_page.expired')}</Badge>
{:else if data.licenseStatus.remoteStatus === 'REVOKED'} {:else if data.licenseStatus.remoteStatus === 'INVALID'}
<Badge variant="destructive">{$t('app.license_page.revoked')}</Badge> <Badge variant="destructive">{$t('app.license_page.revoked')}</Badge>
{:else} {:else}
<Badge variant="secondary">{$t('app.license_page.unknown')}</Badge> <Badge variant="secondary">{$t('app.license_page.unknown')}</Badge>

View File

@@ -29,6 +29,8 @@
import { page } from '$app/state'; import { page } from '$app/state';
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import type { LegalHold, EmailLegalHoldInfo } from '@open-archiver/types'; import type { LegalHold, EmailLegalHoldInfo } from '@open-archiver/types';
import PostalMime, { type Attachment as PostalAttachment } from 'postal-mime';
import { Paperclip } from 'lucide-svelte';
let { data, form }: { data: PageData; form: ActionData } = $props(); let { data, form }: { data: PageData; form: ActionData } = $props();
let email = $derived(data.email); let email = $derived(data.email);
@@ -77,6 +79,51 @@
// --- Integrity report PDF download state (enterprise only) --- // --- Integrity report PDF download state (enterprise only) ---
let isDownloadingReport = $state(false); let isDownloadingReport = $state(false);
// --- Embedded attachment state (parsed from raw EML) ---
/** Non-inline attachments parsed from the raw EML via postal-mime */
let embeddedAttachments = $state<PostalAttachment[]>([]);
let isEmbeddedAttachmentDialogOpen = $state(false);
let selectedEmbeddedFilename = $state('');
/** Parse raw EML to extract non-inline attachments for display */
$effect(() => {
async function parseEmlAttachments() {
const raw = email?.raw;
if (!raw) return;
try {
let buffer: Uint8Array;
if (raw && typeof raw === 'object' && 'type' in raw && raw.type === 'Buffer') {
buffer = new Uint8Array(
(raw as unknown as { type: 'Buffer'; data: number[] }).data
);
} else {
buffer = new Uint8Array(raw as unknown as ArrayLike<number>);
}
const parsed = await new PostalMime().parse(buffer);
// Filter to non-inline attachments (those with a filename and no contentId,
// or with disposition=attachment)
embeddedAttachments = parsed.attachments.filter(
(att) => att.filename && (att.disposition === 'attachment' || !att.contentId)
);
} catch (error) {
console.error('Failed to parse EML for embedded attachments:', error);
}
}
parseEmlAttachments();
});
/**
* Opens the confirmation dialog when a user tries to download an
* embedded attachment. Since embedded attachments are not stored
* separately, the user must download the entire EML file.
*/
function handleEmbeddedAttachmentDownload(filename: string) {
selectedEmbeddedFilename = filename;
isEmbeddedAttachmentDialogOpen = true;
}
// React to form results for label and hold actions // React to form results for label and hold actions
$effect(() => { $effect(() => {
if (form) { if (form) {
@@ -164,7 +211,8 @@
const response = await api(`/enterprise/integrity-report/${email.id}/pdf`); const response = await api(`/enterprise/integrity-report/${email.id}/pdf`);
if (!response.ok) { if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`); const res = JSON.parse(await response.text());
throw new Error(res.message);
} }
const blob = await response.blob(); const blob = await response.blob();
@@ -178,10 +226,11 @@
a.remove(); a.remove();
} catch (error) { } catch (error) {
console.error('Integrity report download failed:', error); console.error('Integrity report download failed:', error);
setAlert({ setAlert({
type: 'error', type: 'error',
title: $t('app.archive.integrity_report_download_error'), title: $t('app.archive.integrity_report_download_error'),
message: '', message: (error as string) || '',
duration: 5000, duration: 5000,
show: true, show: true,
}); });
@@ -311,6 +360,53 @@
</ul> </ul>
</div> </div>
{/if} {/if}
{#if embeddedAttachments.length > 0 && (!email.attachments || email.attachments.length === 0)}
<div>
<h3 class="font-semibold">
{$t('app.archive.embedded_attachments')}
</h3>
<ul class="mt-2 space-y-2">
{#each embeddedAttachments as attachment}
<li
class="flex items-center justify-between rounded-md border p-2"
>
<div class="flex min-w-0 items-center gap-2">
<Paperclip
class="text-muted-foreground h-4 w-4 flex-shrink-0"
/>
<span class="truncate">
{attachment.filename}
{#if typeof attachment.content === 'string'}
({formatBytes(attachment.content.length)})
{:else if attachment.content}
({formatBytes(
attachment.content.byteLength
)})
{/if}
</span>
<Badge
variant="secondary"
class="flex-shrink-0 text-xs"
>
{$t('app.archive.embedded')}
</Badge>
</div>
<Button
variant="outline"
size="sm"
class="ml-2 flex-shrink-0 text-xs"
onclick={() =>
handleEmbeddedAttachmentDownload(
attachment.filename || 'attachment'
)}
>
{$t('app.archive.download')}
</Button>
</li>
{/each}
</ul>
</div>
{/if}
</div> </div>
</Card.Content> </Card.Content>
</Card.Root> </Card.Root>
@@ -918,6 +1014,38 @@
</Dialog.Footer> </Dialog.Footer>
</Dialog.Content> </Dialog.Content>
</Dialog.Root> </Dialog.Root>
<!-- Embedded attachment download confirmation modal -->
<Dialog.Root bind:open={isEmbeddedAttachmentDialogOpen}>
<Dialog.Content class="sm:max-w-lg">
<Dialog.Header>
<Dialog.Title>
{$t('app.archive.embedded_attachment_title')}
</Dialog.Title>
<Dialog.Description>
<span class="font-medium">{selectedEmbeddedFilename}</span>
<br /><br />
{$t('app.archive.embedded_attachment_description')}
</Dialog.Description>
</Dialog.Header>
<Dialog.Footer class="sm:justify-start">
<Button
type="button"
onclick={() => {
download(email.storagePath, `${email.subject || 'email'}.eml`);
isEmbeddedAttachmentDialogOpen = false;
}}
>
{$t('app.archive.download_eml')}
</Button>
<Dialog.Close>
<Button type="button" variant="secondary">
{$t('app.archive.cancel')}
</Button>
</Dialog.Close>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
{:else} {:else}
<p>{$t('app.archive.not_found')}</p> <p>{$t('app.archive.not_found')}</p>
{/if} {/if}

View File

@@ -1,5 +1,5 @@
import { api } from '$lib/server/api'; import { api } from '$lib/server/api';
import { error } from '@sveltejs/kit'; import { error, fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
import type { LegalHold, SearchQuery } from '@open-archiver/types'; import type { LegalHold, SearchQuery } from '@open-archiver/types';
@@ -40,7 +40,10 @@ export const actions: Actions = {
const res = await response.json(); const res = await response.json();
if (!response.ok) { if (!response.ok) {
return { success: false, message: res.message || 'Failed to create legal hold.' }; return fail(response.status, {
success: false,
message: res.message || 'Failed to create legal hold.',
});
} }
return { success: true }; return { success: true };
@@ -63,7 +66,10 @@ export const actions: Actions = {
const res = await response.json(); const res = await response.json();
if (!response.ok) { if (!response.ok) {
return { success: false, message: res.message || 'Failed to update legal hold.' }; return fail(response.status, {
success: false,
message: res.message || 'Failed to update legal hold.',
});
} }
return { success: true }; return { success: true };
@@ -82,7 +88,10 @@ export const actions: Actions = {
const res = await response.json(); const res = await response.json();
if (!response.ok) { if (!response.ok) {
return { success: false, message: res.message || 'Failed to update legal hold.' }; return fail(response.status, {
success: false,
message: res.message || 'Failed to update legal hold.',
});
} }
return { success: true, isActive }; return { success: true, isActive };
@@ -98,10 +107,10 @@ export const actions: Actions = {
if (!response.ok) { if (!response.ok) {
const res = await response.json().catch(() => ({})); const res = await response.json().catch(() => ({}));
return { return fail(response.status, {
success: false, success: false,
message: (res as { message?: string }).message || 'Failed to delete legal hold.', message: (res as { message?: string }).message || 'Failed to delete legal hold.',
}; });
} }
return { success: true }; return { success: true };
@@ -116,7 +125,7 @@ export const actions: Actions = {
try { try {
searchQuery = JSON.parse(rawQuery) as SearchQuery; searchQuery = JSON.parse(rawQuery) as SearchQuery;
} catch { } catch {
return { success: false, message: 'Invalid search query format.' }; return fail(400, { success: false, message: 'Invalid search query format.' });
} }
const response = await api(`/enterprise/legal-holds/holds/${holdId}/bulk-apply`, event, { const response = await api(`/enterprise/legal-holds/holds/${holdId}/bulk-apply`, event, {
@@ -127,10 +136,10 @@ export const actions: Actions = {
const res = await response.json(); const res = await response.json();
if (!response.ok) { if (!response.ok) {
return { return fail(response.status, {
success: false, success: false,
message: (res as { message?: string }).message || 'Bulk apply failed.', message: (res as { message?: string }).message || 'Bulk apply failed.',
}; });
} }
const result = res as { emailsLinked: number }; const result = res as { emailsLinked: number };
@@ -148,11 +157,11 @@ export const actions: Actions = {
const res = await response.json(); const res = await response.json();
if (!response.ok) { if (!response.ok) {
return { return fail(response.status, {
success: false, success: false,
message: message:
(res as { message?: string }).message || 'Failed to release emails from hold.', (res as { message?: string }).message || 'Failed to release emails from hold.',
}; });
} }
const result = res as { emailsReleased: number }; const result = res as { emailsReleased: number };

View File

@@ -186,10 +186,7 @@
action="?/toggleActive" action="?/toggleActive"
use:enhance={() => { use:enhance={() => {
return async ({ result, update }) => { return async ({ result, update }) => {
if ( if (result.type === 'success') {
result.type === 'success' &&
result.data?.success !== false
) {
const newState = result.data const newState = result.data
?.isActive as boolean; ?.isActive as boolean;
setAlert({ setAlert({
@@ -205,10 +202,7 @@
duration: 3000, duration: 3000,
show: true, show: true,
}); });
} else if ( } else if (result.type === 'failure') {
result.type === 'success' &&
result.data?.success === false
) {
setAlert({ setAlert({
type: 'error', type: 'error',
title: $t('app.legal_holds.update_error'), title: $t('app.legal_holds.update_error'),
@@ -275,7 +269,7 @@
isFormLoading = true; isFormLoading = true;
return async ({ result, update }) => { return async ({ result, update }) => {
isFormLoading = false; isFormLoading = false;
if (result.type === 'success' && result.data?.success !== false) { if (result.type === 'success') {
isCreateOpen = false; isCreateOpen = false;
setAlert({ setAlert({
type: 'success', type: 'success',
@@ -284,7 +278,7 @@
duration: 3000, duration: 3000,
show: true, show: true,
}); });
} else if (result.type === 'success' && result.data?.success === false) { } else if (result.type === 'failure') {
setAlert({ setAlert({
type: 'error', type: 'error',
title: $t('app.legal_holds.create_error'), title: $t('app.legal_holds.create_error'),
@@ -353,7 +347,7 @@
isFormLoading = true; isFormLoading = true;
return async ({ result, update }) => { return async ({ result, update }) => {
isFormLoading = false; isFormLoading = false;
if (result.type === 'success' && result.data?.success !== false) { if (result.type === 'success') {
isEditOpen = false; isEditOpen = false;
selectedHold = null; selectedHold = null;
setAlert({ setAlert({
@@ -363,7 +357,7 @@
duration: 3000, duration: 3000,
show: true, show: true,
}); });
} else if (result.type === 'success' && result.data?.success === false) { } else if (result.type === 'failure') {
setAlert({ setAlert({
type: 'error', type: 'error',
title: $t('app.legal_holds.update_error'), title: $t('app.legal_holds.update_error'),
@@ -425,7 +419,7 @@
isFormLoading = true; isFormLoading = true;
return async ({ result, update }) => { return async ({ result, update }) => {
isFormLoading = false; isFormLoading = false;
if (result.type === 'success' && result.data?.success !== false) { if (result.type === 'success') {
isBulkApplyOpen = false; isBulkApplyOpen = false;
const count = result.data?.emailsLinked as number; const count = result.data?.emailsLinked as number;
setAlert({ setAlert({
@@ -435,7 +429,7 @@
duration: 5000, duration: 5000,
show: true, show: true,
}); });
} else if (result.type === 'success' && result.data?.success === false) { } else if (result.type === 'failure') {
setAlert({ setAlert({
type: 'error', type: 'error',
title: $t('app.legal_holds.bulk_apply_error'), title: $t('app.legal_holds.bulk_apply_error'),
@@ -539,7 +533,7 @@
isFormLoading = true; isFormLoading = true;
return async ({ result, update }) => { return async ({ result, update }) => {
isFormLoading = false; isFormLoading = false;
if (result.type === 'success' && result.data?.success !== false) { if (result.type === 'success') {
isReleaseAllOpen = false; isReleaseAllOpen = false;
const count = result.data?.emailsReleased as number; const count = result.data?.emailsReleased as number;
setAlert({ setAlert({
@@ -550,14 +544,11 @@
show: true, show: true,
}); });
selectedHold = null; selectedHold = null;
} else { } else if (result.type === 'failure') {
setAlert({ setAlert({
type: 'error', type: 'error',
title: $t('app.legal_holds.release_all_error'), title: $t('app.legal_holds.release_all_error'),
message: message: String(result.data?.message ?? ''),
result.type === 'success'
? String(result.data?.message ?? '')
: '',
duration: 5000, duration: 5000,
show: true, show: true,
}); });
@@ -605,7 +596,7 @@
isFormLoading = true; isFormLoading = true;
return async ({ result, update }) => { return async ({ result, update }) => {
isFormLoading = false; isFormLoading = false;
if (result.type === 'success' && result.data?.success !== false) { if (result.type === 'success') {
isDeleteOpen = false; isDeleteOpen = false;
setAlert({ setAlert({
type: 'success', type: 'success',
@@ -615,14 +606,11 @@
show: true, show: true,
}); });
selectedHold = null; selectedHold = null;
} else { } else if (result.type === 'failure') {
setAlert({ setAlert({
type: 'error', type: 'error',
title: $t('app.legal_holds.delete_error'), title: $t('app.legal_holds.delete_error'),
message: message: String(result.data?.message ?? ''),
result.type === 'success'
? String(result.data?.message ?? '')
: '',
duration: 5000, duration: 5000,
show: true, show: true,
}); });

View File

@@ -1,5 +1,5 @@
import { api } from '$lib/server/api'; import { api } from '$lib/server/api';
import { error } from '@sveltejs/kit'; import { error, fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
import type { RetentionLabel } from '@open-archiver/types'; import type { RetentionLabel } from '@open-archiver/types';
@@ -41,7 +41,10 @@ export const actions: Actions = {
const res = await response.json(); const res = await response.json();
if (!response.ok) { if (!response.ok) {
return { success: false, message: res.message || 'Failed to create label' }; return fail(response.status, {
success: false,
message: res.message || 'Failed to create label',
});
} }
return { success: true }; return { success: true };
@@ -70,7 +73,10 @@ export const actions: Actions = {
const res = await response.json(); const res = await response.json();
if (!response.ok) { if (!response.ok) {
return { success: false, message: res.message || 'Failed to update label' }; return fail(response.status, {
success: false,
message: res.message || 'Failed to update label',
});
} }
return { success: true }; return { success: true };
@@ -86,7 +92,10 @@ export const actions: Actions = {
if (!response.ok) { if (!response.ok) {
const res = await response.json().catch(() => ({})); const res = await response.json().catch(() => ({}));
return { success: false, message: res.message || 'Failed to delete label' }; return fail(response.status, {
success: false,
message: res.message || 'Failed to delete label',
});
} }
const result = await response.json(); const result = await response.json();

View File

@@ -171,7 +171,7 @@
isFormLoading = true; isFormLoading = true;
return async ({ result, update }) => { return async ({ result, update }) => {
isFormLoading = false; isFormLoading = false;
if (result.type === 'success' && result.data?.success !== false) { if (result.type === 'success') {
isCreateOpen = false; isCreateOpen = false;
setAlert({ setAlert({
type: 'success', type: 'success',
@@ -180,7 +180,7 @@
duration: 3000, duration: 3000,
show: true, show: true,
}); });
} else if (result.type === 'success' && result.data?.success === false) { } else if (result.type === 'failure') {
setAlert({ setAlert({
type: 'error', type: 'error',
title: $t('app.retention_labels.create_error'), title: $t('app.retention_labels.create_error'),
@@ -261,7 +261,7 @@
isFormLoading = true; isFormLoading = true;
return async ({ result, update }) => { return async ({ result, update }) => {
isFormLoading = false; isFormLoading = false;
if (result.type === 'success' && result.data?.success !== false) { if (result.type === 'success') {
isEditOpen = false; isEditOpen = false;
selectedLabel = null; selectedLabel = null;
setAlert({ setAlert({
@@ -271,7 +271,7 @@
duration: 3000, duration: 3000,
show: true, show: true,
}); });
} else if (result.type === 'success' && result.data?.success === false) { } else if (result.type === 'failure') {
setAlert({ setAlert({
type: 'error', type: 'error',
title: $t('app.retention_labels.update_error'), title: $t('app.retention_labels.update_error'),
@@ -376,7 +376,7 @@
isDeleting = true; isDeleting = true;
return async ({ result, update }) => { return async ({ result, update }) => {
isDeleting = false; isDeleting = false;
if (result.type === 'success' && result.data?.success !== false) { if (result.type === 'success') {
isDeleteOpen = false; isDeleteOpen = false;
const action = result.data?.action; const action = result.data?.action;
setAlert({ setAlert({
@@ -390,14 +390,11 @@
show: true, show: true,
}); });
selectedLabel = null; selectedLabel = null;
} else { } else if (result.type === 'failure') {
setAlert({ setAlert({
type: 'error', type: 'error',
title: $t('app.retention_labels.delete_error'), title: $t('app.retention_labels.delete_error'),
message: message: String(result.data?.message ?? ''),
result.type === 'success'
? String(result.data?.message ?? '')
: '',
duration: 5000, duration: 5000,
show: true, show: true,
}); });

View File

@@ -1,5 +1,5 @@
import { api } from '$lib/server/api'; import { api } from '$lib/server/api';
import { error } from '@sveltejs/kit'; import { error, fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
import type { import type {
RetentionPolicy, RetentionPolicy,
@@ -76,7 +76,10 @@ export const actions: Actions = {
const res = await response.json(); const res = await response.json();
if (!response.ok) { if (!response.ok) {
return { success: false, message: res.message || 'Failed to create policy' }; return fail(response.status, {
success: false,
message: res.message || 'Failed to create policy',
});
} }
return { success: true }; return { success: true };
@@ -118,7 +121,10 @@ export const actions: Actions = {
const res = await response.json(); const res = await response.json();
if (!response.ok) { if (!response.ok) {
return { success: false, message: res.message || 'Failed to update policy' }; return fail(response.status, {
success: false,
message: res.message || 'Failed to update policy',
});
} }
return { success: true }; return { success: true };
@@ -134,7 +140,10 @@ export const actions: Actions = {
if (!response.ok) { if (!response.ok) {
const res = await response.json().catch(() => ({})); const res = await response.json().catch(() => ({}));
return { success: false, message: res.message || 'Failed to delete policy' }; return fail(response.status, {
success: false,
message: res.message || 'Failed to delete policy',
});
} }
return { success: true }; return { success: true };
@@ -173,11 +182,11 @@ export const actions: Actions = {
const res = await response.json(); const res = await response.json();
if (!response.ok) { if (!response.ok) {
return { return fail(response.status, {
success: false, success: false,
message: res.message || 'Failed to evaluate policies', message: res.message || 'Failed to evaluate policies',
evaluationResult: null as PolicyEvaluationResult | null, evaluationResult: null as PolicyEvaluationResult | null,
}; });
} }
return { return {

View File

@@ -46,7 +46,7 @@
// React to form results (errors and evaluation results) // React to form results (errors and evaluation results)
$effect(() => { $effect(() => {
if (form && form.success === false && form.message) { if (form && 'success' in form && form.success === false && form.message) {
toast.error(form.message); toast.error(form.message);
} }
if (form && 'evaluationResult' in form) { if (form && 'evaluationResult' in form) {
@@ -449,8 +449,13 @@
isDeleteOpen = false; isDeleteOpen = false;
selectedPolicy = null; selectedPolicy = null;
toast.success($t('app.retention_policies.delete_success')); toast.success($t('app.retention_policies.delete_success'));
} else { } else if (result.type === 'failure') {
toast.error($t('app.retention_policies.delete_error')); toast.error(
String(
result.data?.message ??
$t('app.retention_policies.delete_error')
)
);
} }
await update(); await update();
}; };

View File

@@ -1,6 +1,6 @@
import { api } from '$lib/server/api'; import { api } from '$lib/server/api';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
import type { IngestionSource } from '@open-archiver/types'; import type { SafeIngestionSource } from '@open-archiver/types';
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
export const load: PageServerLoad = async (event) => { export const load: PageServerLoad = async (event) => {
const response = await api('/ingestion-sources', event); const response = await api('/ingestion-sources', event);
@@ -8,7 +8,7 @@ export const load: PageServerLoad = async (event) => {
if (!response.ok) { if (!response.ok) {
throw error(response.status, responseText.message || 'Failed to fetch ingestions.'); throw error(response.status, responseText.message || 'Failed to fetch ingestions.');
} }
const ingestionSources: IngestionSource[] = responseText; const ingestionSources: SafeIngestionSource[] = responseText;
return { return {
ingestionSources, ingestionSources,
}; };

View File

@@ -3,43 +3,94 @@
import * as Table from '$lib/components/ui/table'; import * as Table from '$lib/components/ui/table';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu'; import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { MoreHorizontal, Trash, RefreshCw } from 'lucide-svelte'; import { MoreHorizontal, Trash, RefreshCw, ChevronRight } from 'lucide-svelte';
import * as Dialog from '$lib/components/ui/dialog'; import * as Dialog from '$lib/components/ui/dialog';
import { Switch } from '$lib/components/ui/switch'; import { Switch } from '$lib/components/ui/switch';
import { Checkbox } from '$lib/components/ui/checkbox'; import { Checkbox } from '$lib/components/ui/checkbox';
import IngestionSourceForm from '$lib/components/custom/IngestionSourceForm.svelte'; import IngestionSourceForm from '$lib/components/custom/IngestionSourceForm.svelte';
import { api } from '$lib/api.client'; import { api } from '$lib/api.client';
import type { IngestionSource, CreateIngestionSourceDto } from '@open-archiver/types'; import type { SafeIngestionSource, CreateIngestionSourceDto } from '@open-archiver/types';
import Badge from '$lib/components/ui/badge/badge.svelte'; import Badge from '$lib/components/ui/badge/badge.svelte';
import { setAlert } from '$lib/components/custom/alert/alert-state.svelte'; import { setAlert } from '$lib/components/custom/alert/alert-state.svelte';
import * as HoverCard from '$lib/components/ui/hover-card/index.js'; import * as HoverCard from '$lib/components/ui/hover-card/index.js';
import { t } from '$lib/translations'; import { t } from '$lib/translations';
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
let ingestionSources = $state(data.ingestionSources); let ingestionSources = $state(data.ingestionSources as SafeIngestionSource[]);
let isDialogOpen = $state(false); let isDialogOpen = $state(false);
let isDeleteDialogOpen = $state(false); let isDeleteDialogOpen = $state(false);
let selectedSource = $state<IngestionSource | null>(null); let selectedSource = $state<SafeIngestionSource | null>(null);
let sourceToDelete = $state<IngestionSource | null>(null); let sourceToDelete = $state<SafeIngestionSource | null>(null);
let isDeleting = $state(false); let isDeleting = $state(false);
let selectedIds = $state<string[]>([]); let selectedIds = $state<string[]>([]);
let isBulkDeleteDialogOpen = $state(false); let isBulkDeleteDialogOpen = $state(false);
let isUnmergeDialogOpen = $state(false);
let sourceToUnmerge = $state<SafeIngestionSource | null>(null);
let isUnmerging = $state(false);
/** Tracks which root source groups are expanded in the table */
let expandedGroups = $state<Set<string>>(new Set());
// Group sources: roots (mergedIntoId is null/undefined) and their children
const rootSources = $derived(ingestionSources.filter((s) => !s.mergedIntoId));
/** Returns children for a given root source ID */
function getChildren(rootId: string): SafeIngestionSource[] {
return ingestionSources.filter((s) => s.mergedIntoId === rootId);
}
/** Returns aggregated status for a group.
* If the root is paused but children are still active, show 'active'
* so the group does not appear fully paused when children are running. */
function getGroupStatus(
root: SafeIngestionSource,
children: SafeIngestionSource[]
): SafeIngestionSource['status'] {
const all = [root, ...children];
if (all.some((s) => s.status === 'error')) return 'error';
if (all.some((s) => s.status === 'syncing')) return 'syncing';
if (all.some((s) => s.status === 'importing')) return 'importing';
if (all.every((s) => s.status === 'paused')) return 'paused';
// Root paused but some children are active/imported — show active so the
// group badge reflects that ingestion is still ongoing via the children.
if (
root.status === 'paused' &&
children.some((s) => ['active', 'imported', 'syncing', 'importing'].includes(s.status))
)
return 'partially_active';
if (all.every((s) => ['imported', 'active'].includes(s.status))) return 'active';
return root.status;
}
const toggleGroup = (rootId: string) => {
const next = new Set(expandedGroups);
if (next.has(rootId)) {
next.delete(rootId);
} else {
next.add(rootId);
}
expandedGroups = next;
};
const openCreateDialog = () => { const openCreateDialog = () => {
selectedSource = null; selectedSource = null;
isDialogOpen = true; isDialogOpen = true;
}; };
const openEditDialog = (source: IngestionSource) => { const openEditDialog = (source: SafeIngestionSource) => {
selectedSource = source; selectedSource = source as SafeIngestionSource;
isDialogOpen = true; isDialogOpen = true;
}; };
const openDeleteDialog = (source: IngestionSource) => { const openDeleteDialog = (source: SafeIngestionSource) => {
sourceToDelete = source; sourceToDelete = source;
isDeleteDialogOpen = true; isDeleteDialogOpen = true;
}; };
/** Count of children that will be deleted alongside a root source */
const deleteChildCount = $derived(
sourceToDelete && !sourceToDelete.mergedIntoId ? getChildren(sourceToDelete.id).length : 0
);
const confirmDelete = async () => { const confirmDelete = async () => {
if (!sourceToDelete) return; if (!sourceToDelete) return;
isDeleting = true; isDeleting = true;
@@ -56,7 +107,11 @@
}); });
return; return;
} }
ingestionSources = ingestionSources.filter((s) => s.id !== sourceToDelete!.id); // Remove the deleted source and any children from state
const deletedId = sourceToDelete.id;
ingestionSources = ingestionSources.filter(
(s) => s.id !== deletedId && s.mergedIntoId !== deletedId
);
isDeleteDialogOpen = false; isDeleteDialogOpen = false;
sourceToDelete = null; sourceToDelete = null;
} finally { } finally {
@@ -77,16 +132,15 @@
}); });
return; return;
} }
const updatedSources = ingestionSources.map((s) => { ingestionSources = ingestionSources.map((s) => {
if (s.id === id) { if (s.id === id) {
return { ...s, status: 'syncing' as const }; return { ...s, status: 'syncing' as const };
} }
return s; return s;
}); });
ingestionSources = updatedSources;
}; };
const handleToggle = async (source: IngestionSource) => { const handleToggle = async (source: SafeIngestionSource) => {
try { try {
const isPaused = source.status === 'paused'; const isPaused = source.status === 'paused';
const newStatus = isPaused ? 'active' : 'paused'; const newStatus = isPaused ? 'active' : 'paused';
@@ -126,6 +180,46 @@
} }
}; };
const openUnmergeDialog = (source: SafeIngestionSource) => {
sourceToUnmerge = source;
isUnmergeDialogOpen = true;
};
const confirmUnmerge = async () => {
if (!sourceToUnmerge) return;
isUnmerging = true;
try {
const res = await api(`/ingestion-sources/${sourceToUnmerge.id}/unmerge`, {
method: 'POST',
});
if (!res.ok) {
const errorBody = await res.json();
throw Error(errorBody.message || 'Unmerge failed');
}
const updated: SafeIngestionSource = await res.json();
ingestionSources = ingestionSources.map((s) => (s.id === updated.id ? updated : s));
isUnmergeDialogOpen = false;
sourceToUnmerge = null;
setAlert({
type: 'success',
title: $t('app.ingestions.unmerge_success'),
message: '',
duration: 3000,
show: true,
});
} catch (e) {
setAlert({
type: 'error',
title: 'Failed to unmerge',
message: e instanceof Error ? e.message : JSON.stringify(e),
duration: 5000,
show: true,
});
} finally {
isUnmerging = false;
}
};
const handleBulkDelete = async () => { const handleBulkDelete = async () => {
isDeleting = true; isDeleting = true;
try { try {
@@ -143,7 +237,11 @@
return; return;
} }
} }
ingestionSources = ingestionSources.filter((s) => !selectedIds.includes(s.id)); // Remove deleted roots and their children from local state
// (backend cascades child deletion, so we mirror that here)
ingestionSources = ingestionSources.filter(
(s) => !selectedIds.includes(s.id) && !selectedIds.includes(s.mergedIntoId ?? '')
);
selectedIds = []; selectedIds = [];
isBulkDeleteDialogOpen = false; isBulkDeleteDialogOpen = false;
} finally { } finally {
@@ -166,13 +264,25 @@
}); });
} }
} }
const updatedSources = ingestionSources.map((s) => { // Backend cascades force sync to non-file-based children,
// so optimistically mark root + eligible children as syncing
const fileBasedProviders = ['pst_import', 'eml_import', 'mbox_import'];
ingestionSources = ingestionSources.map((s) => {
// Mark selected roots as syncing
if (selectedIds.includes(s.id)) { if (selectedIds.includes(s.id)) {
return { ...s, status: 'syncing' as const }; return { ...s, status: 'syncing' as const };
} }
// Mark non-file-based children of selected roots as syncing
if (
s.mergedIntoId &&
selectedIds.includes(s.mergedIntoId) &&
!fileBasedProviders.includes(s.provider) &&
(s.status === 'active' || s.status === 'error')
) {
return { ...s, status: 'syncing' as const };
}
return s; return s;
}); });
ingestionSources = updatedSources;
selectedIds = []; selectedIds = [];
} catch (e) { } catch (e) {
setAlert({ setAlert({
@@ -230,10 +340,12 @@
} }
}; };
function getStatusClasses(status: IngestionSource['status']): string { function getStatusClasses(status: SafeIngestionSource['status']): string {
switch (status) { switch (status) {
case 'active': case 'active':
return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300'; return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300';
case 'partially_active':
return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300';
case 'imported': case 'imported':
return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300'; return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300';
case 'paused': case 'paused':
@@ -299,13 +411,13 @@
<Checkbox <Checkbox
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
if (checked) { if (checked) {
selectedIds = ingestionSources.map((s) => s.id); selectedIds = rootSources.map((s) => s.id);
} else { } else {
selectedIds = []; selectedIds = [];
} }
}} }}
checked={ingestionSources.length > 0 && checked={rootSources.length > 0 &&
selectedIds.length === ingestionSources.length selectedIds.length === rootSources.length
? true ? true
: ((selectedIds.length > 0 ? 'indeterminate' : false) as any)} : ((selectedIds.length > 0 ? 'indeterminate' : false) as any)}
/> />
@@ -319,8 +431,16 @@
</Table.Row> </Table.Row>
</Table.Header> </Table.Header>
<Table.Body> <Table.Body>
{#if ingestionSources.length > 0} {#if rootSources.length > 0}
{#each ingestionSources as source (source.id)} {#each rootSources as source (source.id)}
{@const children = getChildren(source.id)}
{@const hasChildren = children.length > 0}
{@const isExpanded = expandedGroups.has(source.id)}
{@const displayStatus = hasChildren
? getGroupStatus(source, children)
: source.status}
<!-- Root row -->
<Table.Row> <Table.Row>
<Table.Cell> <Table.Cell>
<Checkbox <Checkbox
@@ -337,11 +457,34 @@
/> />
</Table.Cell> </Table.Cell>
<Table.Cell> <Table.Cell>
<a <div class="flex items-center gap-1">
class="link" {#if hasChildren}
href="/dashboard/archived-emails?ingestionSourceId={source.id}" <button
>{source.name}</a class="cursor-pointer rounded p-0.5 hover:bg-gray-100 dark:hover:bg-gray-800"
> onclick={() => toggleGroup(source.id)}
aria-label={isExpanded
? $t('app.ingestions.collapse')
: $t('app.ingestions.expand')}
>
<ChevronRight
class="h-4 w-4 transition-transform {isExpanded
? 'rotate-90'
: ''}"
/>
</button>
{/if}
<a
class="link"
href="/dashboard/archived-emails?ingestionSourceId={source.id}"
>{source.name}</a
>
{#if hasChildren}
<span class="text-muted-foreground ml-1 text-xs"
>({children.length}
{$t('app.ingestions.merged_sources')})</span
>
{/if}
</div>
</Table.Cell> </Table.Cell>
<Table.Cell class="capitalize" <Table.Cell class="capitalize"
>{source.provider.split('_').join(' ')}</Table.Cell >{source.provider.split('_').join(' ')}</Table.Cell
@@ -351,13 +494,13 @@
<HoverCard.Trigger> <HoverCard.Trigger>
<Badge <Badge
class="{getStatusClasses( class="{getStatusClasses(
source.status displayStatus
)} cursor-pointer capitalize" )} cursor-pointer capitalize"
> >
{source.status.split('_').join(' ')} {displayStatus.split('_').join(' ')}
</Badge> </Badge>
</HoverCard.Trigger> </HoverCard.Trigger>
<HoverCard.Content class="{getStatusClasses(source.status)} "> <HoverCard.Content class="{getStatusClasses(displayStatus)} ">
<div class="flex flex-col space-y-4 text-sm"> <div class="flex flex-col space-y-4 text-sm">
<p class=" font-mono"> <p class=" font-mono">
<b>{$t('app.ingestions.last_sync_message')}:</b> <b>{$t('app.ingestions.last_sync_message')}:</b>
@@ -374,8 +517,6 @@
class="cursor-pointer" class="cursor-pointer"
checked={source.status !== 'paused'} checked={source.status !== 'paused'}
onCheckedChange={() => handleToggle(source)} onCheckedChange={() => handleToggle(source)}
disabled={source.status === 'importing' ||
source.status === 'syncing'}
/> />
</Table.Cell> </Table.Cell>
<Table.Cell <Table.Cell
@@ -413,6 +554,120 @@
</DropdownMenu.Root> </DropdownMenu.Root>
</Table.Cell> </Table.Cell>
</Table.Row> </Table.Row>
<!-- Child rows (shown when group is expanded) -->
{#if hasChildren && isExpanded}
{#each children as child (child.id)}
<Table.Row class="bg-muted/30">
<Table.Cell>
<!-- No checkbox for children -->
</Table.Cell>
<Table.Cell>
<div class="flex items-center gap-1 pl-6">
<span class="text-muted-foreground mr-1"></span>
<!-- Child emails are stored under the root source — link to root -->
<a
class="link"
href="/dashboard/archived-emails?ingestionSourceId={child.mergedIntoId}"
>{child.name}</a
>
</div>
</Table.Cell>
<Table.Cell class="capitalize"
>{child.provider.split('_').join(' ')}</Table.Cell
>
<Table.Cell class="min-w-24">
<HoverCard.Root>
<HoverCard.Trigger>
<Badge
class="{getStatusClasses(
child.status
)} cursor-pointer capitalize"
>
{child.status.split('_').join(' ')}
</Badge>
</HoverCard.Trigger>
<HoverCard.Content
class="{getStatusClasses(child.status)} "
>
<div class="flex flex-col space-y-4 text-sm">
<p class=" font-mono">
<b
>{$t(
'app.ingestions.last_sync_message'
)}:</b
>
{child.lastSyncStatusMessage ||
$t('app.ingestions.empty')}
</p>
</div>
</HoverCard.Content>
</HoverCard.Root>
</Table.Cell>
<Table.Cell>
<Switch
id={`active-switch-${child.id}`}
class="cursor-pointer"
checked={child.status !== 'paused'}
onCheckedChange={() => handleToggle(child)}
/>
</Table.Cell>
<Table.Cell
>{new Date(
child.createdAt
).toLocaleDateString()}</Table.Cell
>
<Table.Cell class="text-right">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button
{...props}
variant="ghost"
class="h-8 w-8 p-0"
>
<span class="sr-only"
>{$t('app.ingestions.open_menu')}</span
>
<MoreHorizontal class="h-4 w-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Label
>{$t(
'app.ingestions.actions'
)}</DropdownMenu.Label
>
<DropdownMenu.Item
onclick={() => openEditDialog(child)}
>{$t('app.ingestions.edit')}</DropdownMenu.Item
>
<DropdownMenu.Item
onclick={() => handleSync(child.id)}
>{$t(
'app.ingestions.force_sync'
)}</DropdownMenu.Item
>
<DropdownMenu.Item
onclick={() => openUnmergeDialog(child)}
>
{$t('app.ingestions.unmerge')}
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item
class="text-red-600"
onclick={() => openDeleteDialog(child)}
>{$t(
'app.ingestions.delete'
)}</DropdownMenu.Item
>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
</Table.Row>
{/each}
{/if}
{/each} {/each}
{:else} {:else}
<Table.Row> <Table.Row>
@@ -451,7 +706,11 @@
> >
</Dialog.Description> </Dialog.Description>
</Dialog.Header> </Dialog.Header>
<IngestionSourceForm source={selectedSource} onSubmit={handleFormSubmit} /> <IngestionSourceForm
source={selectedSource}
existingSources={ingestionSources}
onSubmit={handleFormSubmit}
/>
</Dialog.Content> </Dialog.Content>
</Dialog.Root> </Dialog.Root>
@@ -461,6 +720,13 @@
<Dialog.Title>{$t('app.ingestions.delete_confirmation_title')}</Dialog.Title> <Dialog.Title>{$t('app.ingestions.delete_confirmation_title')}</Dialog.Title>
<Dialog.Description> <Dialog.Description>
{$t('app.ingestions.delete_confirmation_description')} {$t('app.ingestions.delete_confirmation_description')}
{#if deleteChildCount > 0}
<p class="mt-2 font-semibold text-red-600">
{$t('app.ingestions.delete_root_warning', {
count: deleteChildCount,
} as any)}
</p>
{/if}
</Dialog.Description> </Dialog.Description>
</Dialog.Header> </Dialog.Header>
<Dialog.Footer class="sm:justify-start"> <Dialog.Footer class="sm:justify-start">
@@ -512,3 +778,31 @@
</Dialog.Footer> </Dialog.Footer>
</Dialog.Content> </Dialog.Content>
</Dialog.Root> </Dialog.Root>
<!-- Unmerge confirmation modal -->
<Dialog.Root bind:open={isUnmergeDialogOpen}>
<Dialog.Content class="sm:max-w-lg">
<Dialog.Header>
<Dialog.Title>{$t('app.ingestions.unmerge_confirmation_title')}</Dialog.Title>
<Dialog.Description>
{$t('app.ingestions.unmerge_confirmation_description')}
</Dialog.Description>
</Dialog.Header>
<ul class="text-muted-foreground my-2 ml-4 list-disc space-y-1 text-sm">
<li>{$t('app.ingestions.unmerge_warning_emails')}</li>
<li>{$t('app.ingestions.unmerge_warning_future')}</li>
</ul>
<Dialog.Footer class="sm:justify-start">
<Button type="button" variant="default" onclick={confirmUnmerge} disabled={isUnmerging}>
{#if isUnmerging}
{$t('app.ingestions.unmerging')}...
{:else}
{$t('app.ingestions.unmerge_confirm')}
{/if}
</Button>
<Dialog.Close>
<Button type="button" variant="secondary">{$t('app.ingestions.cancel')}</Button>
</Dialog.Close>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,171 @@
import { api } from '$lib/server/api';
import { error, fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import type { JournalingSource } from '@open-archiver/types';
export const load: PageServerLoad = async (event) => {
if (!event.locals.enterpriseMode) {
throw error(
403,
'This feature is only available in the Enterprise Edition. Please contact Open Archiver to upgrade.'
);
}
const sourcesRes = await api('/enterprise/journaling', event);
const sourcesJson = await sourcesRes.json();
if (!sourcesRes.ok) {
throw error(sourcesRes.status, sourcesJson.message || JSON.stringify(sourcesJson));
}
const sources: JournalingSource[] = sourcesJson;
// Fetch SMTP listener health status
const healthRes = await api('/enterprise/journaling/health', event);
const healthJson = (await healthRes.json()) as { smtp: string; port: string };
return {
sources,
smtpHealth: healthRes.ok ? healthJson : { smtp: 'down', port: '2525' },
};
};
export const actions: Actions = {
create: async (event) => {
const data = await event.request.formData();
const rawIps = (data.get('allowedIps') as string) || '';
const allowedIps = rawIps
.split(',')
.map((ip) => ip.trim())
.filter(Boolean);
const body: Record<string, unknown> = {
name: data.get('name') as string,
allowedIps,
requireTls: data.get('requireTls') === 'on',
};
const smtpUsername = data.get('smtpUsername') as string;
const smtpPassword = data.get('smtpPassword') as string;
if (smtpUsername) body.smtpUsername = smtpUsername;
if (smtpPassword) body.smtpPassword = smtpPassword;
const response = await api('/enterprise/journaling', event, {
method: 'POST',
body: JSON.stringify(body),
});
const res = await response.json();
if (!response.ok) {
return fail(response.status, {
success: false,
message: res.message || 'Failed to create journaling source.',
});
}
return { success: true };
},
update: async (event) => {
const data = await event.request.formData();
const id = data.get('id') as string;
const rawIps = (data.get('allowedIps') as string) || '';
const allowedIps = rawIps
.split(',')
.map((ip) => ip.trim())
.filter(Boolean);
const body: Record<string, unknown> = {
name: data.get('name') as string,
allowedIps,
requireTls: data.get('requireTls') === 'on',
};
const smtpUsername = data.get('smtpUsername') as string;
const smtpPassword = data.get('smtpPassword') as string;
if (smtpUsername) body.smtpUsername = smtpUsername;
if (smtpPassword) body.smtpPassword = smtpPassword;
const response = await api(`/enterprise/journaling/${id}`, event, {
method: 'PUT',
body: JSON.stringify(body),
});
const res = await response.json();
if (!response.ok) {
return fail(response.status, {
success: false,
message: res.message || 'Failed to update journaling source.',
});
}
return { success: true };
},
toggleStatus: async (event) => {
const data = await event.request.formData();
const id = data.get('id') as string;
const status = data.get('status') as string;
const response = await api(`/enterprise/journaling/${id}`, event, {
method: 'PUT',
body: JSON.stringify({ status }),
});
const res = await response.json();
if (!response.ok) {
return fail(response.status, {
success: false,
message: res.message || 'Failed to update status.',
});
}
return { success: true, status };
},
regenerateAddress: async (event) => {
const data = await event.request.formData();
const id = data.get('id') as string;
const response = await api(`/enterprise/journaling/${id}/regenerate-address`, event, {
method: 'POST',
});
if (!response.ok) {
const res = await response.json().catch(() => ({}));
return fail(response.status, {
success: false,
message:
(res as { message?: string }).message ||
'Failed to regenerate routing address.',
});
}
return { success: true };
},
delete: async (event) => {
const data = await event.request.formData();
const id = data.get('id') as string;
const response = await api(`/enterprise/journaling/${id}`, event, {
method: 'DELETE',
});
if (!response.ok) {
const res = await response.json().catch(() => ({}));
return fail(response.status, {
success: false,
message:
(res as { message?: string }).message || 'Failed to delete journaling source.',
});
}
return { success: true };
},
};

View File

@@ -0,0 +1,737 @@
<script lang="ts">
import type { ActionData, PageData } from './$types';
import { t } from '$lib/translations';
import { Button } from '$lib/components/ui/button';
import { Badge } from '$lib/components/ui/badge';
import * as Table from '$lib/components/ui/table';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { enhance } from '$app/forms';
import { invalidateAll } from '$app/navigation';
import { MoreHorizontal, Plus, Radio, Mail, Copy, Check, RefreshCw } from 'lucide-svelte';
import { setAlert } from '$lib/components/custom/alert/alert-state.svelte';
import type { JournalingSource } from '@open-archiver/types';
let { data }: { data: PageData; form: ActionData } = $props();
let sources = $derived(data.sources);
let smtpHealth = $derived(data.smtpHealth);
// --- Dialog state ---
let isCreateOpen = $state(false);
let isEditOpen = $state(false);
let isDeleteOpen = $state(false);
let isRegenerateOpen = $state(false);
let selectedSource = $state<JournalingSource | null>(null);
let isFormLoading = $state(false);
let copiedField = $state<string | null>(null);
function openEdit(source: JournalingSource) {
selectedSource = source;
isEditOpen = true;
}
function openDelete(source: JournalingSource) {
selectedSource = source;
isDeleteOpen = true;
}
async function copyToClipboard(text: string, field: string) {
await navigator.clipboard.writeText(text);
copiedField = field;
setTimeout(() => (copiedField = null), 2000);
}
/** Programmatically submit the regenerateAddress action (avoids nested <form>). */
async function handleRegenerateAddress(sourceId: string) {
isFormLoading = true;
try {
const formData = new FormData();
formData.set('id', sourceId);
const res = await fetch('?/regenerateAddress', {
method: 'POST',
body: formData,
});
const result = await res.json();
// SvelteKit actions return { type, status, data } wrapped structure
const data = result?.data;
const success = Array.isArray(data)
? data[0]?.success !== false
: data?.success !== false;
if (success) {
setAlert({
type: 'success',
title: $t('app.journaling.regenerate_address_success'),
message: '',
duration: 5000,
show: true,
});
} else {
const msg = Array.isArray(data) ? data[0]?.message : data?.message;
setAlert({
type: 'error',
title: $t('app.journaling.regenerate_address_error'),
message: String(msg ?? ''),
duration: 5000,
show: true,
});
}
} catch {
setAlert({
type: 'error',
title: $t('app.journaling.regenerate_address_error'),
message: '',
duration: 5000,
show: true,
});
} finally {
isFormLoading = false;
isEditOpen = false;
selectedSource = null;
// Re-run the load function to get updated data without a full page reload
await invalidateAll();
}
}
</script>
<svelte:head>
<title>{$t('app.journaling.title')} - Open Archiver</title>
<meta name="description" content={$t('app.journaling.meta_description')} />
<meta
name="keywords"
content="SMTP journaling, email archiving, journal reports, MTA integration, Exchange journaling, zero-gap archiving"
/>
</svelte:head>
<div class="mb-6 flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold">{$t('app.journaling.header')}</h1>
<p class="text-muted-foreground mt-1 text-sm">
{$t('app.journaling.header_description')}
</p>
</div>
<Button onclick={() => (isCreateOpen = true)}>
<Plus class="mr-1.5 h-4 w-4" />
{$t('app.journaling.create_new')}
</Button>
</div>
<!-- SMTP Listener health badge -->
<div class="mb-4 flex items-center gap-3">
<div class="flex items-center gap-2">
<Radio
class="h-4 w-4 {smtpHealth.smtp === 'listening'
? 'text-green-500'
: 'text-destructive'}"
/>
<span class="text-sm font-medium">
{smtpHealth.smtp === 'listening'
? $t('app.journaling.health_listening')
: $t('app.journaling.health_down')}
</span>
</div>
<Badge variant="outline" class="font-mono text-xs">
{$t('app.journaling.smtp_port')}: {smtpHealth.port}
</Badge>
</div>
<div class="rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>{$t('app.journaling.name')}</Table.Head>
<Table.Head>{$t('app.journaling.allowed_ips')}</Table.Head>
<Table.Head>{$t('app.journaling.total_received')}</Table.Head>
<Table.Head>{$t('app.journaling.status')}</Table.Head>
<Table.Head>{$t('app.journaling.last_received_at')}</Table.Head>
<Table.Head class="text-right">{$t('app.journaling.actions')}</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if sources && sources.length > 0}
{#each sources as source (source.id)}
<Table.Row>
<Table.Cell class="font-medium">
<div>
<div>{source.name}</div>
<div class="mt-1 flex items-center gap-1">
<code
class="bg-muted rounded px-1.5 py-0.5 font-mono text-[11px]"
>{source.routingAddress}</code
>
<button
type="button"
class="text-muted-foreground hover:text-foreground"
onclick={() =>
copyToClipboard(
source.routingAddress,
`route-${source.id}`
)}
>
{#if copiedField === `route-${source.id}`}
<Check class="h-3 w-3 text-green-500" />
{:else}
<Copy class="h-3 w-3" />
{/if}
</button>
</div>
</div>
</Table.Cell>
<Table.Cell>
<div class="flex flex-wrap gap-1">
{#each source.allowedIps.slice(0, 3) as ip}
<Badge variant="outline" class="font-mono text-[10px]">
{ip}
</Badge>
{/each}
{#if source.allowedIps.length > 3}
<Badge variant="secondary" class="text-[10px]">
+{source.allowedIps.length - 3}
</Badge>
{/if}
</div>
</Table.Cell>
<Table.Cell>
<div class="flex items-center gap-1.5">
<Mail class="text-muted-foreground h-3.5 w-3.5" />
<Badge variant={source.totalReceived > 0 ? 'secondary' : 'outline'}>
{source.totalReceived}
</Badge>
</div>
</Table.Cell>
<Table.Cell>
{#if source.status === 'active'}
<Badge class="bg-green-600 text-white">
{$t('app.journaling.active')}
</Badge>
{:else}
<Badge variant="secondary">
{$t('app.journaling.paused')}
</Badge>
{/if}
</Table.Cell>
<Table.Cell>
{#if source.lastReceivedAt}
{new Date(source.lastReceivedAt).toLocaleString()}
{:else}
<span class="text-muted-foreground text-xs italic">
{$t('app.journaling.never')}
</span>
{/if}
</Table.Cell>
<Table.Cell class="text-right">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button
{...props}
variant="ghost"
size="icon"
class="h-8 w-8"
aria-label={$t('app.ingestions.open_menu')}
>
<MoreHorizontal class="h-4 w-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item onclick={() => openEdit(source)}>
{$t('app.journaling.edit')}
</DropdownMenu.Item>
<!-- Toggle active/paused -->
<form
method="POST"
action="?/toggleStatus"
use:enhance={() => {
return async ({ result, update }) => {
if (result.type === 'success') {
setAlert({
type: 'success',
title: $t('app.journaling.update_success'),
message: '',
duration: 3000,
show: true,
});
} else if (result.type === 'failure') {
setAlert({
type: 'error',
title: $t('app.journaling.update_error'),
message: String(result.data?.message ?? ''),
duration: 5000,
show: true,
});
}
await update();
};
}}
>
<input type="hidden" name="id" value={source.id} />
<input
type="hidden"
name="status"
value={source.status === 'active' ? 'paused' : 'active'}
/>
<DropdownMenu.Item>
<button type="submit" class="w-full text-left">
{source.status === 'active'
? $t('app.journaling.pause')
: $t('app.journaling.activate')}
</button>
</DropdownMenu.Item>
</form>
<DropdownMenu.Separator />
<DropdownMenu.Item
class="text-destructive focus:text-destructive"
onclick={() => openDelete(source)}
>
{$t('app.journaling.delete')}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
</Table.Row>
{/each}
{:else}
<Table.Row>
<Table.Cell colspan={6} class="h-24 text-center">
{$t('app.journaling.no_sources_found')}
</Table.Cell>
</Table.Row>
{/if}
</Table.Body>
</Table.Root>
</div>
<!-- Create dialog -->
<Dialog.Root bind:open={isCreateOpen}>
<Dialog.Content class="sm:max-w-[560px]">
<Dialog.Header>
<Dialog.Title>{$t('app.journaling.create')}</Dialog.Title>
<Dialog.Description>
{$t('app.journaling.create_description')}
</Dialog.Description>
</Dialog.Header>
<form
method="POST"
action="?/create"
class="space-y-4"
use:enhance={() => {
isFormLoading = true;
return async ({ result, update }) => {
isFormLoading = false;
if (result.type === 'success') {
isCreateOpen = false;
setAlert({
type: 'success',
title: $t('app.journaling.create_success'),
message: '',
duration: 3000,
show: true,
});
} else if (result.type === 'failure') {
setAlert({
type: 'error',
title: $t('app.journaling.create_error'),
message: String(result.data?.message ?? ''),
duration: 5000,
show: true,
});
}
await update();
};
}}
>
<div class="space-y-1.5">
<Label for="create-name">{$t('app.journaling.name')}</Label>
<Input
id="create-name"
name="name"
required
placeholder={$t('app.journaling.name_placeholder')}
/>
</div>
<div class="space-y-1.5">
<Label for="create-ips">{$t('app.journaling.allowed_ips')}</Label>
<Input
id="create-ips"
name="allowedIps"
required
placeholder={$t('app.journaling.allowed_ips_placeholder')}
/>
<p class="text-muted-foreground text-xs">
{$t('app.journaling.allowed_ips_hint')}
</p>
</div>
<div class="flex items-center gap-2">
<input
type="checkbox"
id="create-tls"
name="requireTls"
class="h-4 w-4 rounded border"
checked
/>
<Label for="create-tls">{$t('app.journaling.require_tls')}</Label>
</div>
<div class="space-y-3 rounded-md border p-3">
<p class="text-muted-foreground text-xs font-medium">
{$t('app.journaling.smtp_auth_hint')}
</p>
<div class="space-y-1.5">
<Label for="create-username">{$t('app.journaling.smtp_username')}</Label>
<Input
id="create-username"
name="smtpUsername"
placeholder={$t('app.journaling.smtp_username_placeholder')}
/>
</div>
<div class="space-y-1.5">
<Label for="create-password">{$t('app.journaling.smtp_password')}</Label>
<Input
id="create-password"
name="smtpPassword"
type="password"
placeholder={$t('app.journaling.smtp_password_placeholder')}
/>
</div>
</div>
<div class="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onclick={() => (isCreateOpen = false)}
disabled={isFormLoading}
>
{$t('app.journaling.cancel')}
</Button>
<Button type="submit" disabled={isFormLoading}>
{#if isFormLoading}
{$t('app.common.working')}
{:else}
{$t('app.journaling.create')}
{/if}
</Button>
</div>
</form>
</Dialog.Content>
</Dialog.Root>
<!-- Edit dialog -->
<Dialog.Root bind:open={isEditOpen}>
<Dialog.Content class="sm:max-w-[560px]">
<Dialog.Header>
<Dialog.Title>{$t('app.journaling.edit')}</Dialog.Title>
<Dialog.Description>
{$t('app.journaling.edit_description')}
</Dialog.Description>
</Dialog.Header>
{#if selectedSource}
<form
method="POST"
action="?/update"
class="space-y-4"
use:enhance={() => {
isFormLoading = true;
return async ({ result, update }) => {
isFormLoading = false;
if (result.type === 'success') {
isEditOpen = false;
selectedSource = null;
setAlert({
type: 'success',
title: $t('app.journaling.update_success'),
message: '',
duration: 3000,
show: true,
});
} else if (result.type === 'failure') {
setAlert({
type: 'error',
title: $t('app.journaling.update_error'),
message: String(result.data?.message ?? ''),
duration: 5000,
show: true,
});
}
await update();
};
}}
>
<input type="hidden" name="id" value={selectedSource.id} />
<!-- SMTP Connection Info (read-only) -->
<div class="bg-muted/30 rounded-md border p-3">
<p class="mb-2 text-xs font-medium">
{$t('app.journaling.smtp_connection_info')}
</p>
<!-- Routing Address (most important) -->
<div class="mb-3">
<span class="text-muted-foreground text-[10px]"
>{$t('app.journaling.routing_address')}</span
>
<div class="mt-0.5 flex items-center gap-1.5">
<code class="bg-muted rounded px-2 py-1 font-mono text-sm font-medium"
>{selectedSource.routingAddress}</code
>
<button
type="button"
class="text-muted-foreground hover:text-foreground"
onclick={() =>
copyToClipboard(
selectedSource?.routingAddress ?? '',
'routing'
)}
>
{#if copiedField === 'routing'}
<Check class="h-3.5 w-3.5 text-green-500" />
{:else}
<Copy class="h-3.5 w-3.5" />
{/if}
</button>
</div>
<p class="text-muted-foreground mt-1 text-[10px]">
{$t('app.journaling.routing_address_hint')}
</p>
<!-- Regenerate address — opens confirmation dialog -->
<div class="mt-2 flex items-start gap-2">
<Button
type="button"
variant="outline"
size="sm"
class="h-7 text-[11px]"
disabled={isFormLoading}
onclick={() => (isRegenerateOpen = true)}
>
<RefreshCw class="mr-1 h-3 w-3" />
{$t('app.journaling.regenerate_address')}
</Button>
<p class="text-destructive flex-1 text-[10px] leading-tight">
{$t('app.journaling.regenerate_address_warning')}
</p>
</div>
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<span class="text-muted-foreground text-[10px]"
>{$t('app.journaling.smtp_host')}</span
>
<div class="flex items-center gap-1">
<code class="text-xs"
>{typeof window !== 'undefined'
? window.location.hostname
: 'localhost'}</code
>
<button
type="button"
class="text-muted-foreground hover:text-foreground"
onclick={() =>
copyToClipboard(
typeof window !== 'undefined'
? window.location.hostname
: 'localhost',
'host'
)}
>
{#if copiedField === 'host'}
<Check class="h-3 w-3 text-green-500" />
{:else}
<Copy class="h-3 w-3" />
{/if}
</button>
</div>
</div>
<div>
<span class="text-muted-foreground text-[10px]"
>{$t('app.journaling.smtp_port')}</span
>
<div class="flex items-center gap-1">
<code class="text-xs">{smtpHealth.port}</code>
<button
type="button"
class="text-muted-foreground hover:text-foreground"
onclick={() => copyToClipboard(smtpHealth.port, 'port')}
>
{#if copiedField === 'port'}
<Check class="h-3 w-3 text-green-500" />
{:else}
<Copy class="h-3 w-3" />
{/if}
</button>
</div>
</div>
</div>
</div>
<div class="space-y-1.5">
<Label for="edit-name">{$t('app.journaling.name')}</Label>
<Input id="edit-name" name="name" required value={selectedSource.name} />
</div>
<div class="space-y-1.5">
<Label for="edit-ips">{$t('app.journaling.allowed_ips')}</Label>
<Input
id="edit-ips"
name="allowedIps"
required
value={selectedSource.allowedIps.join(', ')}
/>
<p class="text-muted-foreground text-xs">
{$t('app.journaling.allowed_ips_hint')}
</p>
</div>
<div class="flex items-center gap-2">
<input
type="checkbox"
id="edit-tls"
name="requireTls"
class="h-4 w-4 rounded border"
checked={selectedSource.requireTls}
/>
<Label for="edit-tls">{$t('app.journaling.require_tls')}</Label>
</div>
<div class="space-y-3 rounded-md border p-3">
<p class="text-muted-foreground text-xs font-medium">
{$t('app.journaling.smtp_auth_hint')}
</p>
<div class="space-y-1.5">
<Label for="edit-username">{$t('app.journaling.smtp_username')}</Label>
<Input
id="edit-username"
name="smtpUsername"
value={selectedSource.smtpUsername ?? ''}
placeholder={$t('app.journaling.smtp_username_placeholder')}
/>
</div>
<div class="space-y-1.5">
<Label for="edit-password">{$t('app.journaling.smtp_password')}</Label>
<Input
id="edit-password"
name="smtpPassword"
type="password"
placeholder={$t('app.journaling.smtp_password_placeholder')}
/>
</div>
</div>
<div class="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onclick={() => (isEditOpen = false)}
disabled={isFormLoading}
>
{$t('app.journaling.cancel')}
</Button>
<Button type="submit" disabled={isFormLoading}>
{#if isFormLoading}
{$t('app.common.working')}
{:else}
{$t('app.journaling.save')}
{/if}
</Button>
</div>
</form>
{/if}
</Dialog.Content>
</Dialog.Root>
<!-- Delete confirmation dialog -->
<Dialog.Root bind:open={isDeleteOpen}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>{$t('app.journaling.delete_confirmation_title')}</Dialog.Title>
<Dialog.Description>
{$t('app.journaling.delete_confirmation_description')}
</Dialog.Description>
</Dialog.Header>
<Dialog.Footer>
<Button
variant="outline"
onclick={() => (isDeleteOpen = false)}
disabled={isFormLoading}
>
{$t('app.journaling.cancel')}
</Button>
{#if selectedSource}
<form
method="POST"
action="?/delete"
use:enhance={() => {
isFormLoading = true;
return async ({ result, update }) => {
isFormLoading = false;
if (result.type === 'success') {
isDeleteOpen = false;
setAlert({
type: 'success',
title: $t('app.journaling.delete_success'),
message: '',
duration: 3000,
show: true,
});
selectedSource = null;
} else if (result.type === 'failure') {
setAlert({
type: 'error',
title: $t('app.journaling.delete_error'),
message: String(result.data?.message ?? ''),
duration: 5000,
show: true,
});
}
await update();
};
}}
>
<input type="hidden" name="id" value={selectedSource.id} />
<Button type="submit" variant="destructive" disabled={isFormLoading}>
{#if isFormLoading}
{$t('app.journaling.deleting')}
{:else}
{$t('app.journaling.confirm')}
{/if}
</Button>
</form>
{/if}
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<!-- Regenerate address confirmation dialog -->
<Dialog.Root bind:open={isRegenerateOpen}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>{$t('app.journaling.regenerate_address')}</Dialog.Title>
<Dialog.Description>
{$t('app.journaling.regenerate_address_confirm')}
</Dialog.Description>
</Dialog.Header>
<Dialog.Footer>
<Button
variant="outline"
onclick={() => (isRegenerateOpen = false)}
disabled={isFormLoading}
>
{$t('app.journaling.cancel')}
</Button>
<Button
variant="destructive"
disabled={isFormLoading}
onclick={() => {
isRegenerateOpen = false;
handleRegenerateAddress(selectedSource?.id ?? '');
}}
>
{#if isFormLoading}
{$t('app.common.working')}
{:else}
{$t('app.journaling.regenerate_address')}
{/if}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View File

@@ -43,6 +43,7 @@ export interface ArchivedEmail {
isIndexed: boolean; isIndexed: boolean;
hasAttachments: boolean; hasAttachments: boolean;
isOnLegalHold: boolean; isOnLegalHold: boolean;
isJournaled: boolean | null;
archivedAt: Date; archivedAt: Date;
attachments?: Attachment[]; attachments?: Attachment[];
raw?: Buffer; raw?: Buffer;

View File

@@ -27,6 +27,7 @@ export const AuditLogTargetTypes = [
'ArchivedEmail', 'ArchivedEmail',
'Dashboard', 'Dashboard',
'IngestionSource', 'IngestionSource',
'JournalingSource',
'RetentionPolicy', 'RetentionPolicy',
'RetentionLabel', 'RetentionLabel',
'LegalHold', 'LegalHold',

View File

@@ -45,8 +45,10 @@ export interface EmailObject {
attachments: EmailAttachment[]; attachments: EmailAttachment[];
/** The date and time when the email was received. */ /** The date and time when the email was received. */
receivedAt: Date; receivedAt: Date;
/** An optional buffer containing the full raw EML content of the email, which is useful for archival and compliance purposes. */ /** Path to a temporary file on disk containing the raw EML bytes.
eml?: Buffer; * Connectors write the raw email to tmpdir() and pass only the path,
* keeping large buffers off the JS heap between yield and processEmail(). */
tempFilePath: string;
/** The email address of the user whose mailbox this email belongs to. */ /** The email address of the user whose mailbox this email belongs to. */
userEmail?: string; userEmail?: string;
/** The folder path of the email in the source mailbox. */ /** The folder path of the email in the source mailbox. */

View File

@@ -14,3 +14,4 @@ export * from './integrity.types';
export * from './jobs.types'; export * from './jobs.types';
export * from './license.types'; export * from './license.types';
export * from './retention.types'; export * from './retention.types';
export * from './journaling.types';

View File

@@ -24,7 +24,8 @@ export type IngestionProvider =
| 'generic_imap' | 'generic_imap'
| 'pst_import' | 'pst_import'
| 'eml_import' | 'eml_import'
| 'mbox_import'; | 'mbox_import'
| 'smtp_journaling';
export type IngestionStatus = export type IngestionStatus =
| 'active' | 'active'
@@ -34,7 +35,8 @@ export type IngestionStatus =
| 'syncing' | 'syncing'
| 'importing' | 'importing'
| 'auth_success' | 'auth_success'
| 'imported'; | 'imported'
| 'partially_active'; // For sources with merged children where some are active and others are not
export interface BaseIngestionCredentials { export interface BaseIngestionCredentials {
type: IngestionProvider; type: IngestionProvider;
@@ -91,6 +93,12 @@ export interface MboxImportCredentials extends BaseIngestionCredentials {
localFilePath?: string; localFilePath?: string;
} }
export interface SmtpJournalingCredentials extends BaseIngestionCredentials {
type: 'smtp_journaling';
/** The ID of the journaling_sources row that owns this ingestion source */
journalingSourceId: string;
}
// Discriminated union for all possible credential types // Discriminated union for all possible credential types
export type IngestionCredentials = export type IngestionCredentials =
| GenericImapCredentials | GenericImapCredentials
@@ -98,7 +106,8 @@ export type IngestionCredentials =
| Microsoft365Credentials | Microsoft365Credentials
| PSTImportCredentials | PSTImportCredentials
| EMLImportCredentials | EMLImportCredentials
| MboxImportCredentials; | MboxImportCredentials
| SmtpJournalingCredentials;
export interface IngestionSource { export interface IngestionSource {
id: string; id: string;
@@ -112,6 +121,12 @@ export interface IngestionSource {
lastSyncFinishedAt?: Date | null; lastSyncFinishedAt?: Date | null;
lastSyncStatusMessage?: string | null; lastSyncStatusMessage?: string | null;
syncState?: SyncState | null; syncState?: SyncState | null;
/** When true, the raw EML file is stored without any modification (no attachment
* stripping). Required for GoBD / SEC 17a-4 compliance. Defaults to false. */
preserveOriginalFile: boolean;
/** The ID of the root ingestion source this child is merged into.
* Null or undefined when this source is a standalone root. */
mergedIntoId?: string | null;
} }
/** /**
@@ -125,6 +140,10 @@ export interface CreateIngestionSourceDto {
name: string; name: string;
provider: IngestionProvider; provider: IngestionProvider;
providerConfig: Record<string, any>; providerConfig: Record<string, any>;
/** Store the unmodified raw EML for GoBD compliance. Defaults to false. */
preserveOriginalFile?: boolean;
/** Merge this new source into an existing root source's group. */
mergedIntoId?: string;
} }
export interface UpdateIngestionSourceDto { export interface UpdateIngestionSourceDto {
@@ -136,6 +155,8 @@ export interface UpdateIngestionSourceDto {
lastSyncFinishedAt?: Date; lastSyncFinishedAt?: Date;
lastSyncStatusMessage?: string; lastSyncStatusMessage?: string;
syncState?: SyncState; syncState?: SyncState;
/** Set or clear the merge parent. Use null to unmerge. */
mergedIntoId?: string | null;
} }
export interface IContinuousSyncJob { export interface IContinuousSyncJob {

View File

@@ -0,0 +1,65 @@
/** Status of a journaling source's SMTP listener */
export type JournalingSourceStatus = 'active' | 'paused';
/** Represents a configured journaling source */
export interface JournalingSource {
id: string;
name: string;
/** CIDR blocks or IP addresses allowed to send journal reports */
allowedIps: string[];
/** Whether to reject plain-text (non-TLS) connections */
requireTls: boolean;
/** Optional SMTP AUTH username for the journal endpoint */
smtpUsername: string | null;
status: JournalingSourceStatus;
/** The backing ingestion source ID that owns archived emails */
ingestionSourceId: string;
/**
* The SMTP routing address the admin must configure in their MTA
* (e.g. journal-abc12345@archive.yourdomain.com).
* Computed server-side from the source ID and SMTP_JOURNALING_DOMAIN.
*/
routingAddress: string;
/** Total number of emails received via this journaling source */
totalReceived: number;
/** Timestamp of the last email received */
lastReceivedAt: Date | null;
createdAt: Date;
updatedAt: Date;
}
/** DTO for creating a new journaling source */
export interface CreateJournalingSourceDto {
name: string;
allowedIps: string[];
requireTls?: boolean;
smtpUsername?: string;
smtpPassword?: string;
}
/** DTO for updating an existing journaling source */
export interface UpdateJournalingSourceDto {
name?: string;
allowedIps?: string[];
requireTls?: boolean;
status?: JournalingSourceStatus;
smtpUsername?: string;
smtpPassword?: string;
}
/** Job data for the journal-inbound BullMQ job */
export interface IJournalInboundJob {
/** The journaling source ID that received the email */
journalingSourceId: string;
/**
* Path to the temp file containing the raw email data on the local filesystem.
* Raw emails are written to disk instead of embedded in the Redis job payload
* to avoid Redis memory pressure (base64 inflates 50MB → ~67MB per job).
* The worker is responsible for deleting this file after processing.
*/
tempFilePath: string;
/** IP address of the sending MTA */
remoteAddress: string;
/** Timestamp when the SMTP listener received the email */
receivedAt: string;
}

View File

@@ -6,6 +6,7 @@ export enum OpenArchiverFeature {
RETENTION_POLICY = 'retention-policy', RETENTION_POLICY = 'retention-policy',
LEGAL_HOLDS = 'legal-holds', LEGAL_HOLDS = 'legal-holds',
INTEGRITY_REPORT = 'integrity-report', INTEGRITY_REPORT = 'integrity-report',
JOURNALING = 'journaling',
SSO = 'sso', SSO = 'sso',
STATUS = 'status', STATUS = 'status',
ALL = 'all', ALL = 'all',

15
pnpm-lock.yaml generated
View File

@@ -334,6 +334,9 @@ importers:
tailwind-variants: tailwind-variants:
specifier: ^1.0.0 specifier: ^1.0.0
version: 1.0.0(tailwindcss@4.1.11) version: 1.0.0(tailwindcss@4.1.11)
tippy.js:
specifier: ^6.3.7
version: 6.3.7
devDependencies: devDependencies:
'@internationalized/date': '@internationalized/date':
specifier: ^3.8.2 specifier: ^3.8.2
@@ -1313,6 +1316,9 @@ packages:
'@polka/url@1.0.0-next.29': '@polka/url@1.0.0-next.29':
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
'@popperjs/core@2.11.8':
resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==}
'@rollup/plugin-commonjs@28.0.6': '@rollup/plugin-commonjs@28.0.6':
resolution: {integrity: sha512-XSQB1K7FUU5QP+3lOQmVCE3I0FcbbNvmNT4VJSj93iUjayaARrTQeoRdiYQoftAJBLrR9t2agwAd3ekaTgHNlw==} resolution: {integrity: sha512-XSQB1K7FUU5QP+3lOQmVCE3I0FcbbNvmNT4VJSj93iUjayaARrTQeoRdiYQoftAJBLrR9t2agwAd3ekaTgHNlw==}
engines: {node: '>=16.0.0 || 14 >= 14.17'} engines: {node: '>=16.0.0 || 14 >= 14.17'}
@@ -4580,6 +4586,9 @@ packages:
resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==} resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
tippy.js@6.3.7:
resolution: {integrity: sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==}
tlds@1.259.0: tlds@1.259.0:
resolution: {integrity: sha512-AldGGlDP0PNgwppe2quAvuBl18UcjuNtOnDuUkqhd6ipPqrYYBt3aTxK1QTsBVknk97lS2JcafWMghjGWFtunw==} resolution: {integrity: sha512-AldGGlDP0PNgwppe2quAvuBl18UcjuNtOnDuUkqhd6ipPqrYYBt3aTxK1QTsBVknk97lS2JcafWMghjGWFtunw==}
hasBin: true hasBin: true
@@ -5996,6 +6005,8 @@ snapshots:
'@polka/url@1.0.0-next.29': {} '@polka/url@1.0.0-next.29': {}
'@popperjs/core@2.11.8': {}
'@rollup/plugin-commonjs@28.0.6(rollup@4.44.2)': '@rollup/plugin-commonjs@28.0.6(rollup@4.44.2)':
dependencies: dependencies:
'@rollup/pluginutils': 5.2.0(rollup@4.44.2) '@rollup/pluginutils': 5.2.0(rollup@4.44.2)
@@ -9535,6 +9546,10 @@ snapshots:
fdir: 6.4.6(picomatch@4.0.2) fdir: 6.4.6(picomatch@4.0.2)
picomatch: 4.0.2 picomatch: 4.0.2
tippy.js@6.3.7:
dependencies:
'@popperjs/core': 2.11.8
tlds@1.259.0: {} tlds@1.259.0: {}
to-regex-range@5.0.1: to-regex-range@5.0.1: