mirror of
https://github.com/LogicLabs-OU/OpenArchiver.git
synced 2026-04-06 00:31:57 +02:00
V0.5.0 release (#335)
* adding exports to backend package, page icons update * Integrity report PDF generation * Fixed inline attachment images not displaying in the email preview by modifying `EmailPreview.svelte`. The email HTML references embedded images via `cid:` URIs (e.g., `src="cid:ii_19c6d5f8d5eee7bd6d91"`), but the component never resolved those `cid:` references to actual image data, even though `postal-mime` already parses inline attachments with their `contentId` and binary `content`. The `emailHtml` derived value now calls `resolveContentIdReferences()` before rendering, so inline/embedded images display correctly in the iframe preview. * feat: strip non-inline attachments from EML before storage Add nodemailer dependency and emlUtils helper to remove non-inline attachments from .eml buffers during ingestion. This avoids double-storing attachment data since attachments are already stored separately. * upload error handing for file based ingestion * Use Postgres for sync session management * Google workspace / MS 365 duplicate check, avoid extra API call when previous ingestion fails * OpenAPI specs for API docs * code formatting * ran duplicate check for IMAP import, optimize message listing * Version update
This commit is contained in:
@@ -5,6 +5,10 @@
|
||||
"license": "SEE LICENSE IN LICENSE file",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": "./dist/index.js",
|
||||
"./*": "./dist/*.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc && pnpm copy-assets",
|
||||
"dev": "tsc --watch",
|
||||
@@ -52,6 +56,7 @@
|
||||
"mammoth": "^1.9.1",
|
||||
"meilisearch": "^0.51.0",
|
||||
"multer": "^2.0.2",
|
||||
"nodemailer": "^8.0.2",
|
||||
"pdf2json": "^3.1.6",
|
||||
"pg": "^8.16.3",
|
||||
"pino": "^9.7.0",
|
||||
@@ -73,7 +78,10 @@
|
||||
"@types/microsoft-graph": "^2.40.1",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^24.0.12",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"@types/swagger-jsdoc": "^6.0.4",
|
||||
"@types/yauzl": "^2.10.3",
|
||||
"swagger-jsdoc": "^6.2.8",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.8.3"
|
||||
|
||||
735
packages/backend/scripts/generate-openapi-spec.mjs
Normal file
735
packages/backend/scripts/generate-openapi-spec.mjs
Normal file
@@ -0,0 +1,735 @@
|
||||
/**
|
||||
* Generates the OpenAPI specification from swagger-jsdoc annotations in the route files.
|
||||
* Outputs the spec to docs/api/openapi.json for use with vitepress-openapi.
|
||||
*
|
||||
* Run: node packages/backend/scripts/generate-openapi-spec.mjs
|
||||
*/
|
||||
import swaggerJsdoc from 'swagger-jsdoc';
|
||||
import { writeFileSync, mkdirSync } from 'fs';
|
||||
import { resolve, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const options = {
|
||||
definition: {
|
||||
openapi: '3.1.0',
|
||||
info: {
|
||||
title: 'Open Archiver API',
|
||||
version: '1.0.0',
|
||||
description:
|
||||
'REST API for Open Archiver — an open-source email archiving platform. All authenticated endpoints require a Bearer JWT token obtained from `POST /v1/auth/login`, or an API key passed as a Bearer token.',
|
||||
license: {
|
||||
name: 'SEE LICENSE IN LICENSE',
|
||||
url: 'https://github.com/LogicLabs-OU/OpenArchiver/blob/main/LICENSE',
|
||||
},
|
||||
contact: {
|
||||
name: 'Open Archiver',
|
||||
url: 'https://openarchiver.com',
|
||||
},
|
||||
},
|
||||
servers: [
|
||||
{
|
||||
url: 'http://localhost:3001',
|
||||
description: 'Local development',
|
||||
},
|
||||
],
|
||||
// Both security schemes apply globally; individual endpoints may override
|
||||
security: [{ bearerAuth: [] }, { apiKeyAuth: [] }],
|
||||
components: {
|
||||
securitySchemes: {
|
||||
bearerAuth: {
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
bearerFormat: 'JWT',
|
||||
description:
|
||||
'JWT obtained from `POST /v1/auth/login`. Pass as `Authorization: Bearer <token>`.',
|
||||
},
|
||||
apiKeyAuth: {
|
||||
type: 'apiKey',
|
||||
in: 'header',
|
||||
name: 'X-API-KEY',
|
||||
description:
|
||||
'API key generated via `POST /v1/api-keys`. Pass as `X-API-KEY: <key>`.',
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
Unauthorized: {
|
||||
description: 'Authentication is required or the token is invalid/expired.',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/ErrorMessage' },
|
||||
example: { message: 'Unauthorized' },
|
||||
},
|
||||
},
|
||||
},
|
||||
Forbidden: {
|
||||
description:
|
||||
'The authenticated user does not have permission to perform this action.',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/ErrorMessage' },
|
||||
example: { message: 'Forbidden' },
|
||||
},
|
||||
},
|
||||
},
|
||||
NotFound: {
|
||||
description: 'The requested resource was not found.',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/ErrorMessage' },
|
||||
example: { message: 'Not found' },
|
||||
},
|
||||
},
|
||||
},
|
||||
InternalServerError: {
|
||||
description: 'An unexpected error occurred on the server.',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/ErrorMessage' },
|
||||
example: { message: 'Internal server error' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
schemas: {
|
||||
// --- Shared utility schemas ---
|
||||
ErrorMessage: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
message: {
|
||||
type: 'string',
|
||||
description: 'Human-readable error description.',
|
||||
example: 'An error occurred.',
|
||||
},
|
||||
},
|
||||
required: ['message'],
|
||||
},
|
||||
MessageResponse: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
message: {
|
||||
type: 'string',
|
||||
example: 'Operation completed successfully.',
|
||||
},
|
||||
},
|
||||
required: ['message'],
|
||||
},
|
||||
ValidationError: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
message: {
|
||||
type: 'string',
|
||||
example: 'Request body is invalid.',
|
||||
},
|
||||
errors: {
|
||||
type: 'string',
|
||||
description: 'Zod validation error details.',
|
||||
},
|
||||
},
|
||||
required: ['message'],
|
||||
},
|
||||
// --- Auth ---
|
||||
LoginResponse: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
description: 'JWT for authenticating subsequent requests.',
|
||||
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
|
||||
},
|
||||
user: {
|
||||
$ref: '#/components/schemas/User',
|
||||
},
|
||||
},
|
||||
required: ['accessToken', 'user'],
|
||||
},
|
||||
// --- Users ---
|
||||
User: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', example: 'clx1y2z3a0000b4d2' },
|
||||
first_name: { type: 'string', nullable: true, example: 'Jane' },
|
||||
last_name: { type: 'string', nullable: true, example: 'Doe' },
|
||||
email: {
|
||||
type: 'string',
|
||||
format: 'email',
|
||||
example: 'jane.doe@example.com',
|
||||
},
|
||||
role: {
|
||||
$ref: '#/components/schemas/Role',
|
||||
nullable: true,
|
||||
},
|
||||
createdAt: { type: 'string', format: 'date-time' },
|
||||
},
|
||||
required: ['id', 'email', 'createdAt'],
|
||||
},
|
||||
// --- IAM ---
|
||||
Role: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', example: 'clx1y2z3a0000b4d2' },
|
||||
slug: { type: 'string', nullable: true, example: 'predefined_super_admin' },
|
||||
name: { type: 'string', example: 'Super Admin' },
|
||||
policies: {
|
||||
type: 'array',
|
||||
items: { $ref: '#/components/schemas/CaslPolicy' },
|
||||
},
|
||||
createdAt: { type: 'string', format: 'date-time' },
|
||||
updatedAt: { type: 'string', format: 'date-time' },
|
||||
},
|
||||
required: ['id', 'name', 'policies', 'createdAt', 'updatedAt'],
|
||||
},
|
||||
CaslPolicy: {
|
||||
type: 'object',
|
||||
description:
|
||||
'An CASL-style permission policy statement. `action` and `subject` can be strings or arrays of strings. `conditions` optionally restricts access to specific resource attributes.',
|
||||
properties: {
|
||||
action: {
|
||||
oneOf: [
|
||||
{
|
||||
type: 'string',
|
||||
example: 'read',
|
||||
},
|
||||
{
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
example: ['read', 'search'],
|
||||
},
|
||||
],
|
||||
},
|
||||
subject: {
|
||||
oneOf: [
|
||||
{
|
||||
type: 'string',
|
||||
example: 'archive',
|
||||
},
|
||||
{
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
example: ['archive', 'ingestion'],
|
||||
},
|
||||
],
|
||||
},
|
||||
conditions: {
|
||||
type: 'object',
|
||||
description:
|
||||
'Optional attribute-level conditions. Supports `${user.id}` interpolation.',
|
||||
example: { userId: '${user.id}' },
|
||||
},
|
||||
},
|
||||
required: ['action', 'subject'],
|
||||
},
|
||||
// --- API Keys ---
|
||||
ApiKey: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', example: 'clx1y2z3a0000b4d2' },
|
||||
name: { type: 'string', example: 'CI/CD Pipeline Key' },
|
||||
key: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Partial/masked key — the raw value is only available at creation time.',
|
||||
example: 'oa_live_abc1...',
|
||||
},
|
||||
expiresAt: { type: 'string', format: 'date-time' },
|
||||
createdAt: { type: 'string', format: 'date-time' },
|
||||
},
|
||||
required: ['id', 'name', 'expiresAt', 'createdAt'],
|
||||
},
|
||||
// --- Ingestion ---
|
||||
SafeIngestionSource: {
|
||||
type: 'object',
|
||||
description: 'An ingestion source with sensitive credential fields removed.',
|
||||
properties: {
|
||||
id: { type: 'string', example: 'clx1y2z3a0000b4d2' },
|
||||
name: { type: 'string', example: 'Company Google Workspace' },
|
||||
provider: {
|
||||
type: 'string',
|
||||
enum: [
|
||||
'google_workspace',
|
||||
'microsoft_365',
|
||||
'generic_imap',
|
||||
'pst_import',
|
||||
'eml_import',
|
||||
'mbox_import',
|
||||
],
|
||||
example: 'google_workspace',
|
||||
},
|
||||
status: {
|
||||
type: 'string',
|
||||
enum: [
|
||||
'active',
|
||||
'paused',
|
||||
'error',
|
||||
'pending_auth',
|
||||
'syncing',
|
||||
'importing',
|
||||
'auth_success',
|
||||
'imported',
|
||||
],
|
||||
example: 'active',
|
||||
},
|
||||
createdAt: { type: 'string', format: 'date-time' },
|
||||
updatedAt: { type: 'string', format: 'date-time' },
|
||||
lastSyncStartedAt: { type: 'string', format: 'date-time', nullable: true },
|
||||
lastSyncFinishedAt: { type: 'string', format: 'date-time', nullable: true },
|
||||
lastSyncStatusMessage: { type: 'string', nullable: true },
|
||||
},
|
||||
required: ['id', 'name', 'provider', 'status', 'createdAt', 'updatedAt'],
|
||||
},
|
||||
CreateIngestionSourceDto: {
|
||||
type: 'object',
|
||||
required: ['name', 'provider', 'providerConfig'],
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string',
|
||||
example: 'Company Google Workspace',
|
||||
},
|
||||
provider: {
|
||||
type: 'string',
|
||||
enum: [
|
||||
'google_workspace',
|
||||
'microsoft_365',
|
||||
'generic_imap',
|
||||
'pst_import',
|
||||
'eml_import',
|
||||
'mbox_import',
|
||||
],
|
||||
},
|
||||
providerConfig: {
|
||||
type: 'object',
|
||||
description:
|
||||
'Provider-specific configuration. See the ingestion source guides for the required fields per provider.',
|
||||
example: {
|
||||
serviceAccountKeyJson: '{"type":"service_account",...}',
|
||||
impersonatedAdminEmail: 'admin@example.com',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
UpdateIngestionSourceDto: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
provider: {
|
||||
type: 'string',
|
||||
enum: [
|
||||
'google_workspace',
|
||||
'microsoft_365',
|
||||
'generic_imap',
|
||||
'pst_import',
|
||||
'eml_import',
|
||||
'mbox_import',
|
||||
],
|
||||
},
|
||||
status: {
|
||||
type: 'string',
|
||||
enum: [
|
||||
'active',
|
||||
'paused',
|
||||
'error',
|
||||
'pending_auth',
|
||||
'syncing',
|
||||
'importing',
|
||||
'auth_success',
|
||||
'imported',
|
||||
],
|
||||
},
|
||||
providerConfig: { type: 'object' },
|
||||
},
|
||||
},
|
||||
// --- Archived Emails ---
|
||||
Recipient: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', nullable: true, example: 'John Doe' },
|
||||
email: {
|
||||
type: 'string',
|
||||
format: 'email',
|
||||
example: 'john.doe@example.com',
|
||||
},
|
||||
},
|
||||
required: ['email'],
|
||||
},
|
||||
Attachment: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', example: 'clx1y2z3a0000b4d2' },
|
||||
filename: { type: 'string', example: 'invoice.pdf' },
|
||||
mimeType: { type: 'string', nullable: true, example: 'application/pdf' },
|
||||
sizeBytes: { type: 'integer', example: 204800 },
|
||||
storagePath: {
|
||||
type: 'string',
|
||||
example: 'open-archiver/attachments/abc123.pdf',
|
||||
},
|
||||
},
|
||||
required: ['id', 'filename', 'sizeBytes', 'storagePath'],
|
||||
},
|
||||
// Minimal representation of an email within a thread (returned alongside ArchivedEmail)
|
||||
ThreadEmail: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
description: 'ArchivedEmail ID.',
|
||||
example: 'clx1y2z3a0000b4d2',
|
||||
},
|
||||
subject: { type: 'string', nullable: true, example: 'Re: Q4 Invoice' },
|
||||
sentAt: { type: 'string', format: 'date-time' },
|
||||
senderEmail: {
|
||||
type: 'string',
|
||||
format: 'email',
|
||||
example: 'finance@vendor.com',
|
||||
},
|
||||
},
|
||||
required: ['id', 'sentAt', 'senderEmail'],
|
||||
},
|
||||
ArchivedEmail: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', example: 'clx1y2z3a0000b4d2' },
|
||||
ingestionSourceId: { type: 'string', example: 'clx1y2z3a0000b4d2' },
|
||||
userEmail: {
|
||||
type: 'string',
|
||||
format: 'email',
|
||||
example: 'user@company.com',
|
||||
},
|
||||
messageIdHeader: { type: 'string', nullable: true },
|
||||
sentAt: { type: 'string', format: 'date-time' },
|
||||
subject: { type: 'string', nullable: true, example: 'Q4 Invoice' },
|
||||
senderName: { type: 'string', nullable: true, example: 'Finance Dept' },
|
||||
senderEmail: {
|
||||
type: 'string',
|
||||
format: 'email',
|
||||
example: 'finance@vendor.com',
|
||||
},
|
||||
recipients: {
|
||||
type: 'array',
|
||||
items: { $ref: '#/components/schemas/Recipient' },
|
||||
},
|
||||
storagePath: { type: 'string' },
|
||||
storageHashSha256: {
|
||||
type: 'string',
|
||||
description:
|
||||
'SHA-256 hash of the raw email file, stored at archival time.',
|
||||
},
|
||||
sizeBytes: { type: 'integer' },
|
||||
isIndexed: { type: 'boolean' },
|
||||
hasAttachments: { type: 'boolean' },
|
||||
isOnLegalHold: { type: 'boolean' },
|
||||
archivedAt: { type: 'string', format: 'date-time' },
|
||||
attachments: {
|
||||
type: 'array',
|
||||
items: { $ref: '#/components/schemas/Attachment' },
|
||||
},
|
||||
thread: {
|
||||
type: 'array',
|
||||
description:
|
||||
'Other emails in the same thread, ordered by sentAt. Only present on single-email GET responses.',
|
||||
items: { $ref: '#/components/schemas/ThreadEmail' },
|
||||
},
|
||||
path: { type: 'string', nullable: true },
|
||||
tags: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
nullable: true,
|
||||
},
|
||||
},
|
||||
required: [
|
||||
'id',
|
||||
'ingestionSourceId',
|
||||
'userEmail',
|
||||
'sentAt',
|
||||
'senderEmail',
|
||||
'recipients',
|
||||
'storagePath',
|
||||
'storageHashSha256',
|
||||
'sizeBytes',
|
||||
'isIndexed',
|
||||
'hasAttachments',
|
||||
'isOnLegalHold',
|
||||
'archivedAt',
|
||||
],
|
||||
},
|
||||
PaginatedArchivedEmails: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
items: {
|
||||
type: 'array',
|
||||
items: { $ref: '#/components/schemas/ArchivedEmail' },
|
||||
},
|
||||
total: { type: 'integer', example: 1234 },
|
||||
page: { type: 'integer', example: 1 },
|
||||
limit: { type: 'integer', example: 10 },
|
||||
},
|
||||
required: ['items', 'total', 'page', 'limit'],
|
||||
},
|
||||
// --- Search ---
|
||||
SearchResults: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
hits: {
|
||||
type: 'array',
|
||||
description:
|
||||
'Array of matching archived email objects, potentially with highlighted fields.',
|
||||
items: { type: 'object' },
|
||||
},
|
||||
total: { type: 'integer', example: 42 },
|
||||
page: { type: 'integer', example: 1 },
|
||||
limit: { type: 'integer', example: 10 },
|
||||
totalPages: { type: 'integer', example: 5 },
|
||||
processingTimeMs: {
|
||||
type: 'integer',
|
||||
description: 'Meilisearch query processing time in milliseconds.',
|
||||
example: 12,
|
||||
},
|
||||
},
|
||||
required: ['hits', 'total', 'page', 'limit', 'totalPages', 'processingTimeMs'],
|
||||
},
|
||||
// --- Integrity ---
|
||||
IntegrityCheckResult: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
type: {
|
||||
type: 'string',
|
||||
enum: ['email', 'attachment'],
|
||||
description:
|
||||
'Whether this result is for the email itself or one of its attachments.',
|
||||
},
|
||||
id: { type: 'string', example: 'clx1y2z3a0000b4d2' },
|
||||
filename: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Attachment filename. Only present when `type` is `attachment`.',
|
||||
example: 'invoice.pdf',
|
||||
},
|
||||
isValid: {
|
||||
type: 'boolean',
|
||||
description: 'True if the stored and computed hashes match.',
|
||||
},
|
||||
reason: {
|
||||
type: 'string',
|
||||
description: 'Human-readable explanation if `isValid` is false.',
|
||||
},
|
||||
storedHash: {
|
||||
type: 'string',
|
||||
description: 'SHA-256 hash stored at archival time.',
|
||||
example: 'a3f1b2c4...',
|
||||
},
|
||||
computedHash: {
|
||||
type: 'string',
|
||||
description: 'SHA-256 hash computed during this verification run.',
|
||||
example: 'a3f1b2c4...',
|
||||
},
|
||||
},
|
||||
required: ['type', 'id', 'isValid', 'storedHash', 'computedHash'],
|
||||
},
|
||||
// --- Jobs ---
|
||||
QueueCounts: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
active: { type: 'integer', example: 0 },
|
||||
completed: { type: 'integer', example: 56 },
|
||||
failed: { type: 'integer', example: 4 },
|
||||
delayed: { type: 'integer', example: 0 },
|
||||
waiting: { type: 'integer', example: 0 },
|
||||
paused: { type: 'integer', example: 0 },
|
||||
},
|
||||
required: ['active', 'completed', 'failed', 'delayed', 'waiting', 'paused'],
|
||||
},
|
||||
QueueOverview: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', example: 'ingestion' },
|
||||
counts: { $ref: '#/components/schemas/QueueCounts' },
|
||||
},
|
||||
required: ['name', 'counts'],
|
||||
},
|
||||
Job: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', nullable: true, example: '1' },
|
||||
name: { type: 'string', example: 'initial-import' },
|
||||
data: {
|
||||
type: 'object',
|
||||
description: 'Job payload data.',
|
||||
example: { ingestionSourceId: 'clx1y2z3a0000b4d2' },
|
||||
},
|
||||
state: {
|
||||
type: 'string',
|
||||
enum: ['active', 'completed', 'failed', 'delayed', 'waiting', 'paused'],
|
||||
example: 'failed',
|
||||
},
|
||||
failedReason: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
example: 'Error: Connection timed out',
|
||||
},
|
||||
timestamp: { type: 'integer', example: 1678886400000 },
|
||||
processedOn: { type: 'integer', nullable: true, example: 1678886401000 },
|
||||
finishedOn: { type: 'integer', nullable: true, example: 1678886402000 },
|
||||
attemptsMade: { type: 'integer', example: 5 },
|
||||
stacktrace: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
},
|
||||
returnValue: { nullable: true },
|
||||
ingestionSourceId: { type: 'string', nullable: true },
|
||||
error: {
|
||||
description: 'Shorthand copy of `failedReason` for easier access.',
|
||||
nullable: true,
|
||||
},
|
||||
},
|
||||
required: [
|
||||
'id',
|
||||
'name',
|
||||
'data',
|
||||
'state',
|
||||
'timestamp',
|
||||
'attemptsMade',
|
||||
'stacktrace',
|
||||
],
|
||||
},
|
||||
QueueDetails: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', example: 'ingestion' },
|
||||
counts: { $ref: '#/components/schemas/QueueCounts' },
|
||||
jobs: {
|
||||
type: 'array',
|
||||
items: { $ref: '#/components/schemas/Job' },
|
||||
},
|
||||
pagination: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
currentPage: { type: 'integer', example: 1 },
|
||||
totalPages: { type: 'integer', example: 3 },
|
||||
totalJobs: { type: 'integer', example: 25 },
|
||||
limit: { type: 'integer', example: 10 },
|
||||
},
|
||||
required: ['currentPage', 'totalPages', 'totalJobs', 'limit'],
|
||||
},
|
||||
},
|
||||
required: ['name', 'counts', 'jobs', 'pagination'],
|
||||
},
|
||||
// --- Dashboard ---
|
||||
DashboardStats: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
totalEmailsArchived: { type: 'integer', example: 125000 },
|
||||
totalStorageUsed: {
|
||||
type: 'integer',
|
||||
description: 'Total storage used by all archived emails in bytes.',
|
||||
example: 5368709120,
|
||||
},
|
||||
failedIngestionsLast7Days: {
|
||||
type: 'integer',
|
||||
description:
|
||||
'Number of ingestion sources in error state updated in the last 7 days.',
|
||||
example: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
IngestionSourceStats: {
|
||||
type: 'object',
|
||||
description: 'Summary of an ingestion source including its storage usage.',
|
||||
properties: {
|
||||
id: { type: 'string', example: 'clx1y2z3a0000b4d2' },
|
||||
name: { type: 'string', example: 'Company Google Workspace' },
|
||||
provider: { type: 'string', example: 'google_workspace' },
|
||||
status: { type: 'string', example: 'active' },
|
||||
storageUsed: {
|
||||
type: 'integer',
|
||||
description:
|
||||
'Total bytes stored for emails from this ingestion source.',
|
||||
example: 1073741824,
|
||||
},
|
||||
},
|
||||
required: ['id', 'name', 'provider', 'status', 'storageUsed'],
|
||||
},
|
||||
RecentSync: {
|
||||
type: 'object',
|
||||
description: 'Summary of a recent sync session.',
|
||||
properties: {
|
||||
id: { type: 'string', example: 'clx1y2z3a0000b4d2' },
|
||||
sourceName: { type: 'string', example: 'Company Google Workspace' },
|
||||
startTime: { type: 'string', format: 'date-time' },
|
||||
duration: {
|
||||
type: 'integer',
|
||||
description: 'Duration in milliseconds.',
|
||||
example: 4500,
|
||||
},
|
||||
emailsProcessed: { type: 'integer', example: 120 },
|
||||
status: { type: 'string', example: 'completed' },
|
||||
},
|
||||
required: [
|
||||
'id',
|
||||
'sourceName',
|
||||
'startTime',
|
||||
'duration',
|
||||
'emailsProcessed',
|
||||
'status',
|
||||
],
|
||||
},
|
||||
IndexedInsights: {
|
||||
type: 'object',
|
||||
description: 'Insights derived from the search index.',
|
||||
properties: {
|
||||
topSenders: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
sender: { type: 'string', example: 'finance@vendor.com' },
|
||||
count: { type: 'integer', example: 342 },
|
||||
},
|
||||
required: ['sender', 'count'],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['topSenders'],
|
||||
},
|
||||
// --- Settings ---
|
||||
SystemSettings: {
|
||||
type: 'object',
|
||||
description: 'Non-sensitive system configuration values.',
|
||||
properties: {
|
||||
language: {
|
||||
type: 'string',
|
||||
enum: ['en', 'es', 'fr', 'de', 'it', 'pt', 'nl', 'ja', 'et', 'el'],
|
||||
example: 'en',
|
||||
description: 'Default UI language code.',
|
||||
},
|
||||
theme: {
|
||||
type: 'string',
|
||||
enum: ['light', 'dark', 'system'],
|
||||
example: 'system',
|
||||
description: 'Default color theme.',
|
||||
},
|
||||
supportEmail: {
|
||||
type: 'string',
|
||||
format: 'email',
|
||||
nullable: true,
|
||||
example: 'support@example.com',
|
||||
description: 'Public-facing support email address.',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
// Scan all route files for @openapi annotations
|
||||
apis: [resolve(__dirname, '../src/api/routes/*.ts')],
|
||||
};
|
||||
|
||||
const spec = swaggerJsdoc(options);
|
||||
|
||||
// Output to docs/ directory so VitePress can consume it
|
||||
const outputPath = resolve(__dirname, '../../../docs/api/openapi.json');
|
||||
mkdirSync(dirname(outputPath), { recursive: true });
|
||||
writeFileSync(outputPath, JSON.stringify(spec, null, 2));
|
||||
|
||||
console.log(`✅ OpenAPI spec generated: ${outputPath}`);
|
||||
console.log(` Paths: ${Object.keys(spec.paths ?? {}).length}, Tags: ${(spec.tags ?? []).length}`);
|
||||
@@ -59,17 +59,26 @@ export class ArchivedEmailController {
|
||||
};
|
||||
|
||||
public deleteArchivedEmail = async (req: Request, res: Response): Promise<Response> => {
|
||||
// Guard: return 400 if deletion is disabled in system settings before touching anything else
|
||||
try {
|
||||
checkDeletionEnabled();
|
||||
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') });
|
||||
}
|
||||
} catch (error) {
|
||||
return res.status(400).json({
|
||||
message: error instanceof Error ? error.message : req.t('errors.deletionDisabled'),
|
||||
});
|
||||
}
|
||||
|
||||
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') });
|
||||
}
|
||||
|
||||
try {
|
||||
await ArchivedEmailService.deleteArchivedEmail(id, actor, req.ip || 'unknown');
|
||||
return res.status(204).send();
|
||||
} catch (error) {
|
||||
@@ -78,6 +87,10 @@ export class ArchivedEmailController {
|
||||
if (error.message === 'Archived email not found') {
|
||||
return res.status(404).json({ message: req.t('archivedEmail.notFound') });
|
||||
}
|
||||
// Retention policy / legal hold blocks are user-facing 400 errors
|
||||
if (error.message.startsWith('Deletion blocked by retention policy')) {
|
||||
return res.status(400).json({ message: error.message });
|
||||
}
|
||||
return res.status(500).json({ message: error.message });
|
||||
}
|
||||
return res.status(500).json({ message: req.t('errors.internalServerError') });
|
||||
|
||||
@@ -3,24 +3,96 @@ import { StorageService } from '../../services/StorageService';
|
||||
import { randomUUID } from 'crypto';
|
||||
import busboy from 'busboy';
|
||||
import { config } from '../../config/index';
|
||||
import { logger } from '../../config/logger';
|
||||
import i18next from 'i18next';
|
||||
|
||||
export const uploadFile = async (req: Request, res: Response) => {
|
||||
const storage = new StorageService();
|
||||
const bb = busboy({ headers: req.headers });
|
||||
const uploads: Promise<void>[] = [];
|
||||
let filePath = '';
|
||||
let originalFilename = '';
|
||||
let headersSent = false;
|
||||
const contentLength = req.headers['content-length'];
|
||||
|
||||
bb.on('file', (fieldname, file, filename) => {
|
||||
originalFilename = filename.filename;
|
||||
logger.info({ contentLength, contentType: req.headers['content-type'] }, 'File upload started');
|
||||
|
||||
const sendErrorResponse = (statusCode: number, message: string) => {
|
||||
if (!headersSent) {
|
||||
headersSent = true;
|
||||
res.status(statusCode).json({
|
||||
status: 'error',
|
||||
statusCode,
|
||||
message,
|
||||
errors: null,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let bb: busboy.Busboy;
|
||||
try {
|
||||
bb = busboy({ headers: req.headers });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : i18next.t('upload.invalid_request');
|
||||
logger.error({ error: message }, 'Failed to initialize file upload parser');
|
||||
sendErrorResponse(400, i18next.t('upload.invalid_request'));
|
||||
return;
|
||||
}
|
||||
|
||||
bb.on('file', (fieldname, file, info) => {
|
||||
originalFilename = info.filename;
|
||||
const uuid = randomUUID();
|
||||
filePath = `${config.storage.openArchiverFolderName}/tmp/${uuid}-${originalFilename}`;
|
||||
|
||||
logger.info({ filename: originalFilename, fieldname }, 'Receiving file stream');
|
||||
|
||||
file.on('error', (err) => {
|
||||
logger.error(
|
||||
{ error: err.message, filename: originalFilename },
|
||||
'File stream error during upload'
|
||||
);
|
||||
sendErrorResponse(500, i18next.t('upload.stream_error'));
|
||||
});
|
||||
|
||||
uploads.push(storage.put(filePath, file));
|
||||
});
|
||||
|
||||
bb.on('error', (err: Error) => {
|
||||
logger.error({ error: err.message }, 'Upload parsing error');
|
||||
sendErrorResponse(500, i18next.t('upload.parse_error'));
|
||||
});
|
||||
|
||||
bb.on('finish', async () => {
|
||||
await Promise.all(uploads);
|
||||
res.json({ filePath });
|
||||
try {
|
||||
await Promise.all(uploads);
|
||||
if (!headersSent) {
|
||||
headersSent = true;
|
||||
logger.info(
|
||||
{ filePath, filename: originalFilename },
|
||||
'File upload completed successfully'
|
||||
);
|
||||
res.json({ filePath });
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown storage error';
|
||||
logger.error(
|
||||
{ error: message, filename: originalFilename, filePath },
|
||||
'Failed to write uploaded file to storage'
|
||||
);
|
||||
sendErrorResponse(500, i18next.t('upload.storage_error'));
|
||||
}
|
||||
});
|
||||
|
||||
// Handle client disconnection mid-upload
|
||||
req.on('error', (err) => {
|
||||
logger.warn(
|
||||
{ error: err.message, filename: originalFilename },
|
||||
'Client connection error during upload'
|
||||
);
|
||||
sendErrorResponse(499, i18next.t('upload.connection_error'));
|
||||
});
|
||||
|
||||
req.on('aborted', () => {
|
||||
logger.warn({ filename: originalFilename }, 'Client aborted upload');
|
||||
});
|
||||
|
||||
req.pipe(bb);
|
||||
|
||||
@@ -7,8 +7,127 @@ export const apiKeyRoutes = (authService: AuthService): Router => {
|
||||
const router = Router();
|
||||
const controller = new ApiKeyController();
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /v1/api-keys:
|
||||
* post:
|
||||
* summary: Generate an API key
|
||||
* description: >
|
||||
* Generates a new API key for the authenticated user. The raw key value is only returned once at creation time.
|
||||
* The key name must be between 1–255 characters. Expiry is required and must be within 730 days (2 years).
|
||||
* Disabled in demo mode.
|
||||
* operationId: generateApiKey
|
||||
* tags:
|
||||
* - API Keys
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* - apiKeyAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - name
|
||||
* - expiresInDays
|
||||
* properties:
|
||||
* name:
|
||||
* type: string
|
||||
* minLength: 1
|
||||
* maxLength: 255
|
||||
* example: "CI/CD Pipeline Key"
|
||||
* expiresInDays:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* maximum: 730
|
||||
* example: 90
|
||||
* responses:
|
||||
* '201':
|
||||
* description: API key created. The raw `key` value is only shown once.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* key:
|
||||
* type: string
|
||||
* description: The raw API key. Store this securely — it will not be shown again.
|
||||
* example: "oa_live_abc123..."
|
||||
* '400':
|
||||
* description: Validation error (name too short/long, expiry out of range).
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ValidationError'
|
||||
* '401':
|
||||
* $ref: '#/components/responses/Unauthorized'
|
||||
* '403':
|
||||
* description: Disabled in demo mode.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorMessage'
|
||||
* '500':
|
||||
* $ref: '#/components/responses/InternalServerError'
|
||||
* get:
|
||||
* summary: List API keys
|
||||
* description: Returns all API keys belonging to the currently authenticated user. The raw key value is not included.
|
||||
* operationId: getApiKeys
|
||||
* tags:
|
||||
* - API Keys
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* - apiKeyAuth: []
|
||||
* responses:
|
||||
* '200':
|
||||
* description: List of API keys (without raw key values).
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/ApiKey'
|
||||
* '401':
|
||||
* $ref: '#/components/responses/Unauthorized'
|
||||
*/
|
||||
router.post('/', requireAuth(authService), controller.generateApiKey);
|
||||
router.get('/', requireAuth(authService), controller.getApiKeys);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /v1/api-keys/{id}:
|
||||
* delete:
|
||||
* summary: Delete an API key
|
||||
* description: Permanently revokes and deletes an API key by ID. Only the owning user can delete their own keys. Disabled in demo mode.
|
||||
* operationId: deleteApiKey
|
||||
* tags:
|
||||
* - API Keys
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* - apiKeyAuth: []
|
||||
* parameters:
|
||||
* - name: id
|
||||
* in: path
|
||||
* required: true
|
||||
* description: The ID of the API key to delete.
|
||||
* schema:
|
||||
* type: string
|
||||
* example: "clx1y2z3a0000b4d2"
|
||||
* responses:
|
||||
* '204':
|
||||
* description: API key deleted. No content returned.
|
||||
* '401':
|
||||
* $ref: '#/components/responses/Unauthorized'
|
||||
* '403':
|
||||
* description: Disabled in demo mode.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorMessage'
|
||||
* '500':
|
||||
* $ref: '#/components/responses/InternalServerError'
|
||||
*/
|
||||
router.delete('/:id', requireAuth(authService), controller.deleteApiKey);
|
||||
|
||||
return router;
|
||||
|
||||
@@ -13,12 +13,126 @@ export const createArchivedEmailRouter = (
|
||||
// Secure all routes in this module
|
||||
router.use(requireAuth(authService));
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /v1/archived-emails/ingestion-source/{ingestionSourceId}:
|
||||
* get:
|
||||
* summary: List archived emails for an ingestion source
|
||||
* description: Returns a paginated list of archived emails belonging to the specified ingestion source. Requires `read:archive` permission.
|
||||
* operationId: getArchivedEmails
|
||||
* tags:
|
||||
* - Archived Emails
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* - apiKeyAuth: []
|
||||
* parameters:
|
||||
* - name: ingestionSourceId
|
||||
* in: path
|
||||
* required: true
|
||||
* description: The ID of the ingestion source to retrieve emails for.
|
||||
* schema:
|
||||
* type: string
|
||||
* example: "clx1y2z3a0000b4d2"
|
||||
* - name: page
|
||||
* in: query
|
||||
* required: false
|
||||
* description: Page number for pagination.
|
||||
* schema:
|
||||
* type: integer
|
||||
* default: 1
|
||||
* example: 1
|
||||
* - name: limit
|
||||
* in: query
|
||||
* required: false
|
||||
* description: Number of items per page.
|
||||
* schema:
|
||||
* type: integer
|
||||
* default: 10
|
||||
* example: 10
|
||||
* responses:
|
||||
* '200':
|
||||
* description: Paginated list of archived emails.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/PaginatedArchivedEmails'
|
||||
* '401':
|
||||
* $ref: '#/components/responses/Unauthorized'
|
||||
* '500':
|
||||
* $ref: '#/components/responses/InternalServerError'
|
||||
*/
|
||||
router.get(
|
||||
'/ingestion-source/:ingestionSourceId',
|
||||
requirePermission('read', 'archive'),
|
||||
archivedEmailController.getArchivedEmails
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /v1/archived-emails/{id}:
|
||||
* get:
|
||||
* summary: Get a single archived email
|
||||
* description: Retrieves the full details of a single archived email by ID, including attachments and thread. Requires `read:archive` permission.
|
||||
* operationId: getArchivedEmailById
|
||||
* tags:
|
||||
* - Archived Emails
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* - apiKeyAuth: []
|
||||
* parameters:
|
||||
* - name: id
|
||||
* in: path
|
||||
* required: true
|
||||
* description: The ID of the archived email.
|
||||
* schema:
|
||||
* type: string
|
||||
* example: "clx1y2z3a0000b4d2"
|
||||
* responses:
|
||||
* '200':
|
||||
* description: Archived email details.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ArchivedEmail'
|
||||
* '401':
|
||||
* $ref: '#/components/responses/Unauthorized'
|
||||
* '404':
|
||||
* $ref: '#/components/responses/NotFound'
|
||||
* '500':
|
||||
* $ref: '#/components/responses/InternalServerError'
|
||||
* delete:
|
||||
* summary: Delete an archived email
|
||||
* description: Permanently deletes an archived email by ID. Deletion must be enabled in system settings and the email must not be on legal hold. Requires `delete:archive` permission.
|
||||
* operationId: deleteArchivedEmail
|
||||
* tags:
|
||||
* - Archived Emails
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* - apiKeyAuth: []
|
||||
* parameters:
|
||||
* - name: id
|
||||
* in: path
|
||||
* required: true
|
||||
* description: The ID of the archived email to delete.
|
||||
* schema:
|
||||
* type: string
|
||||
* example: "clx1y2z3a0000b4d2"
|
||||
* responses:
|
||||
* '204':
|
||||
* description: Email deleted successfully. No content returned.
|
||||
* '400':
|
||||
* description: Deletion is disabled in system settings, or the email is blocked by a retention policy / legal hold.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorMessage'
|
||||
* '401':
|
||||
* $ref: '#/components/responses/Unauthorized'
|
||||
* '404':
|
||||
* $ref: '#/components/responses/NotFound'
|
||||
* '500':
|
||||
* $ref: '#/components/responses/InternalServerError'
|
||||
*/
|
||||
router.get(
|
||||
'/:id',
|
||||
requirePermission('read', 'archive'),
|
||||
|
||||
@@ -5,23 +5,141 @@ export const createAuthRouter = (authController: AuthController): Router => {
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* @route POST /api/v1/auth/setup
|
||||
* @description Creates the initial administrator user.
|
||||
* @access Public
|
||||
* @openapi
|
||||
* /v1/auth/setup:
|
||||
* post:
|
||||
* summary: Initial setup
|
||||
* description: Creates the initial administrator user. Can only be called once when no users exist.
|
||||
* operationId: authSetup
|
||||
* tags:
|
||||
* - Auth
|
||||
* security: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - email
|
||||
* - password
|
||||
* - first_name
|
||||
* - last_name
|
||||
* properties:
|
||||
* email:
|
||||
* type: string
|
||||
* format: email
|
||||
* example: admin@example.com
|
||||
* password:
|
||||
* type: string
|
||||
* format: password
|
||||
* example: "securepassword123"
|
||||
* first_name:
|
||||
* type: string
|
||||
* example: Admin
|
||||
* last_name:
|
||||
* type: string
|
||||
* example: User
|
||||
* responses:
|
||||
* '201':
|
||||
* description: Admin user created and logged in successfully.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/LoginResponse'
|
||||
* '400':
|
||||
* description: All fields are required.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorMessage'
|
||||
* '403':
|
||||
* description: Setup has already been completed (users already exist).
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorMessage'
|
||||
* '500':
|
||||
* $ref: '#/components/responses/InternalServerError'
|
||||
*/
|
||||
router.post('/setup', authController.setup);
|
||||
|
||||
/**
|
||||
* @route POST /api/v1/auth/login
|
||||
* @description Authenticates a user and returns a JWT.
|
||||
* @access Public
|
||||
* @openapi
|
||||
* /v1/auth/login:
|
||||
* post:
|
||||
* summary: Login
|
||||
* description: Authenticates a user with email and password and returns a JWT access token.
|
||||
* operationId: authLogin
|
||||
* tags:
|
||||
* - Auth
|
||||
* security: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - email
|
||||
* - password
|
||||
* properties:
|
||||
* email:
|
||||
* type: string
|
||||
* format: email
|
||||
* example: user@example.com
|
||||
* password:
|
||||
* type: string
|
||||
* format: password
|
||||
* example: "securepassword123"
|
||||
* responses:
|
||||
* '200':
|
||||
* description: Authentication successful.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/LoginResponse'
|
||||
* '400':
|
||||
* description: Email and password are required.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorMessage'
|
||||
* '401':
|
||||
* description: Invalid credentials.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorMessage'
|
||||
* '500':
|
||||
* $ref: '#/components/responses/InternalServerError'
|
||||
*/
|
||||
router.post('/login', authController.login);
|
||||
|
||||
/**
|
||||
* @route GET /api/v1/auth/status
|
||||
* @description Checks if the application has been set up.
|
||||
* @access Public
|
||||
* @openapi
|
||||
* /v1/auth/status:
|
||||
* get:
|
||||
* summary: Check setup status
|
||||
* description: Returns whether the application has been set up (i.e., whether an admin user exists).
|
||||
* operationId: authStatus
|
||||
* tags:
|
||||
* - Auth
|
||||
* security: []
|
||||
* responses:
|
||||
* '200':
|
||||
* description: Setup status returned.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* needsSetup:
|
||||
* type: boolean
|
||||
* description: True if no admin user exists and setup is required.
|
||||
* example: false
|
||||
* '500':
|
||||
* $ref: '#/components/responses/InternalServerError'
|
||||
*/
|
||||
router.get('/status', authController.status);
|
||||
|
||||
|
||||
@@ -9,26 +9,168 @@ export const createDashboardRouter = (authService: AuthService): Router => {
|
||||
|
||||
router.use(requireAuth(authService));
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /v1/dashboard/stats:
|
||||
* get:
|
||||
* summary: Get dashboard stats
|
||||
* description: Returns high-level statistics including total archived emails, total storage used, and failed ingestions in the last 7 days. Requires `read:dashboard` permission.
|
||||
* operationId: getDashboardStats
|
||||
* tags:
|
||||
* - Dashboard
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* - apiKeyAuth: []
|
||||
* responses:
|
||||
* '200':
|
||||
* description: Dashboard statistics.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/DashboardStats'
|
||||
* '401':
|
||||
* $ref: '#/components/responses/Unauthorized'
|
||||
* '403':
|
||||
* $ref: '#/components/responses/Forbidden'
|
||||
*/
|
||||
router.get(
|
||||
'/stats',
|
||||
requirePermission('read', 'dashboard', 'dashboard.permissionRequired'),
|
||||
dashboardController.getStats
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /v1/dashboard/ingestion-history:
|
||||
* get:
|
||||
* summary: Get ingestion history
|
||||
* description: Returns time-series data of email ingestion counts for the last 30 days. Requires `read:dashboard` permission.
|
||||
* operationId: getIngestionHistory
|
||||
* tags:
|
||||
* - Dashboard
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* - apiKeyAuth: []
|
||||
* responses:
|
||||
* '200':
|
||||
* description: Ingestion history wrapped in a `history` array.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* history:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* date:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* description: Truncated to day precision (UTC).
|
||||
* count:
|
||||
* type: integer
|
||||
* required:
|
||||
* - history
|
||||
* '401':
|
||||
* $ref: '#/components/responses/Unauthorized'
|
||||
* '403':
|
||||
* $ref: '#/components/responses/Forbidden'
|
||||
*/
|
||||
router.get(
|
||||
'/ingestion-history',
|
||||
requirePermission('read', 'dashboard', 'dashboard.permissionRequired'),
|
||||
dashboardController.getIngestionHistory
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /v1/dashboard/ingestion-sources:
|
||||
* get:
|
||||
* summary: Get ingestion source summaries
|
||||
* description: Returns a summary list of ingestion sources with their storage usage. Requires `read:dashboard` permission.
|
||||
* operationId: getDashboardIngestionSources
|
||||
* tags:
|
||||
* - Dashboard
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* - apiKeyAuth: []
|
||||
* responses:
|
||||
* '200':
|
||||
* description: List of ingestion source summaries.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/IngestionSourceStats'
|
||||
* '401':
|
||||
* $ref: '#/components/responses/Unauthorized'
|
||||
* '403':
|
||||
* $ref: '#/components/responses/Forbidden'
|
||||
*/
|
||||
router.get(
|
||||
'/ingestion-sources',
|
||||
requirePermission('read', 'dashboard', 'dashboard.permissionRequired'),
|
||||
dashboardController.getIngestionSources
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /v1/dashboard/recent-syncs:
|
||||
* get:
|
||||
* summary: Get recent sync activity
|
||||
* description: Returns the most recent sync sessions across all ingestion sources. Requires `read:dashboard` permission.
|
||||
* operationId: getRecentSyncs
|
||||
* tags:
|
||||
* - Dashboard
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* - apiKeyAuth: []
|
||||
* responses:
|
||||
* '200':
|
||||
* description: List of recent sync sessions.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/RecentSync'
|
||||
* '401':
|
||||
* $ref: '#/components/responses/Unauthorized'
|
||||
* '403':
|
||||
* $ref: '#/components/responses/Forbidden'
|
||||
*/
|
||||
router.get(
|
||||
'/recent-syncs',
|
||||
requirePermission('read', 'dashboard', 'dashboard.permissionRequired'),
|
||||
dashboardController.getRecentSyncs
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /v1/dashboard/indexed-insights:
|
||||
* get:
|
||||
* summary: Get indexed email insights
|
||||
* description: Returns top-sender statistics from the search index. Requires `read:dashboard` permission.
|
||||
* operationId: getIndexedInsights
|
||||
* tags:
|
||||
* - Dashboard
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* - apiKeyAuth: []
|
||||
* responses:
|
||||
* '200':
|
||||
* description: Indexed email insights.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/IndexedInsights'
|
||||
* '401':
|
||||
* $ref: '#/components/responses/Unauthorized'
|
||||
* '403':
|
||||
* $ref: '#/components/responses/Forbidden'
|
||||
*/
|
||||
router.get(
|
||||
'/indexed-insights',
|
||||
requirePermission('read', 'dashboard', 'dashboard.permissionRequired'),
|
||||
|
||||
@@ -10,16 +10,116 @@ export const createIamRouter = (iamController: IamController, authService: AuthS
|
||||
router.use(requireAuth(authService));
|
||||
|
||||
/**
|
||||
* @route GET /api/v1/iam/roles
|
||||
* @description Gets all roles.
|
||||
* @access Private
|
||||
* @openapi
|
||||
* /v1/iam/roles:
|
||||
* get:
|
||||
* summary: List all roles
|
||||
* description: Returns all IAM roles. If predefined roles do not yet exist, they are created automatically. Requires `read:roles` permission.
|
||||
* operationId: getRoles
|
||||
* tags:
|
||||
* - IAM
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* - apiKeyAuth: []
|
||||
* responses:
|
||||
* '200':
|
||||
* description: List of roles.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/Role'
|
||||
* '401':
|
||||
* $ref: '#/components/responses/Unauthorized'
|
||||
* '500':
|
||||
* $ref: '#/components/responses/InternalServerError'
|
||||
*/
|
||||
router.get('/roles', requirePermission('read', 'roles'), iamController.getRoles);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /v1/iam/roles/{id}:
|
||||
* get:
|
||||
* summary: Get a role
|
||||
* description: Returns a single IAM role by ID. Requires `read:roles` permission.
|
||||
* operationId: getRoleById
|
||||
* tags:
|
||||
* - IAM
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* - apiKeyAuth: []
|
||||
* parameters:
|
||||
* - name: id
|
||||
* in: path
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* example: "clx1y2z3a0000b4d2"
|
||||
* responses:
|
||||
* '200':
|
||||
* description: Role details.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Role'
|
||||
* '401':
|
||||
* $ref: '#/components/responses/Unauthorized'
|
||||
* '404':
|
||||
* $ref: '#/components/responses/NotFound'
|
||||
* '500':
|
||||
* $ref: '#/components/responses/InternalServerError'
|
||||
*/
|
||||
router.get('/roles/:id', requirePermission('read', 'roles'), iamController.getRoleById);
|
||||
|
||||
/**
|
||||
* Only super admin has the ability to modify existing roles or create new roles.
|
||||
* @openapi
|
||||
* /v1/iam/roles:
|
||||
* post:
|
||||
* summary: Create a role
|
||||
* description: Creates a new IAM role with the given name and CASL policies. Requires `manage:all` (Super Admin) permission.
|
||||
* operationId: createRole
|
||||
* tags:
|
||||
* - IAM
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* - apiKeyAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - name
|
||||
* - policies
|
||||
* properties:
|
||||
* name:
|
||||
* type: string
|
||||
* example: "Compliance Officer"
|
||||
* policies:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/CaslPolicy'
|
||||
* responses:
|
||||
* '201':
|
||||
* description: Role created.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Role'
|
||||
* '400':
|
||||
* description: Missing fields or invalid policy.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorMessage'
|
||||
* '401':
|
||||
* $ref: '#/components/responses/Unauthorized'
|
||||
* '403':
|
||||
* $ref: '#/components/responses/Forbidden'
|
||||
* '500':
|
||||
* $ref: '#/components/responses/InternalServerError'
|
||||
*/
|
||||
router.post(
|
||||
'/roles',
|
||||
@@ -27,12 +127,94 @@ export const createIamRouter = (iamController: IamController, authService: AuthS
|
||||
iamController.createRole
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /v1/iam/roles/{id}:
|
||||
* delete:
|
||||
* summary: Delete a role
|
||||
* description: Permanently deletes an IAM role. Requires `manage:all` (Super Admin) permission.
|
||||
* operationId: deleteRole
|
||||
* tags:
|
||||
* - IAM
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* - apiKeyAuth: []
|
||||
* parameters:
|
||||
* - name: id
|
||||
* in: path
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* example: "clx1y2z3a0000b4d2"
|
||||
* responses:
|
||||
* '204':
|
||||
* description: Role deleted. No content returned.
|
||||
* '401':
|
||||
* $ref: '#/components/responses/Unauthorized'
|
||||
* '403':
|
||||
* $ref: '#/components/responses/Forbidden'
|
||||
* '500':
|
||||
* $ref: '#/components/responses/InternalServerError'
|
||||
*/
|
||||
router.delete(
|
||||
'/roles/:id',
|
||||
requirePermission('manage', 'all', 'iam.requiresSuperAdminRole'),
|
||||
iamController.deleteRole
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /v1/iam/roles/{id}:
|
||||
* put:
|
||||
* summary: Update a role
|
||||
* description: Updates the name or policies of an IAM role. Requires `manage:all` (Super Admin) permission.
|
||||
* operationId: updateRole
|
||||
* tags:
|
||||
* - IAM
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* - apiKeyAuth: []
|
||||
* parameters:
|
||||
* - name: id
|
||||
* in: path
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* example: "clx1y2z3a0000b4d2"
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* name:
|
||||
* type: string
|
||||
* example: "Senior Compliance Officer"
|
||||
* policies:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/CaslPolicy'
|
||||
* responses:
|
||||
* '200':
|
||||
* description: Updated role.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Role'
|
||||
* '400':
|
||||
* description: No update fields provided or invalid policy.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorMessage'
|
||||
* '401':
|
||||
* $ref: '#/components/responses/Unauthorized'
|
||||
* '403':
|
||||
* $ref: '#/components/responses/Forbidden'
|
||||
* '500':
|
||||
* $ref: '#/components/responses/InternalServerError'
|
||||
*/
|
||||
router.put(
|
||||
'/roles/:id',
|
||||
requirePermission('manage', 'all', 'iam.requiresSuperAdminRole'),
|
||||
|
||||
@@ -13,24 +13,278 @@ export const createIngestionRouter = (
|
||||
// Secure all routes in this module
|
||||
router.use(requireAuth(authService));
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /v1/ingestion-sources:
|
||||
* post:
|
||||
* summary: Create an ingestion source
|
||||
* description: Creates a new ingestion source and validates the connection. Returns the created source without credentials. Requires `create:ingestion` permission.
|
||||
* operationId: createIngestionSource
|
||||
* tags:
|
||||
* - Ingestion
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* - apiKeyAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/CreateIngestionSourceDto'
|
||||
* responses:
|
||||
* '201':
|
||||
* description: Ingestion source created successfully.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/SafeIngestionSource'
|
||||
* '400':
|
||||
* description: Invalid input or connection test failed.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorMessage'
|
||||
* '401':
|
||||
* $ref: '#/components/responses/Unauthorized'
|
||||
* get:
|
||||
* summary: List ingestion sources
|
||||
* description: Returns all ingestion sources accessible to the authenticated user. Credentials are excluded from the response. Requires `read:ingestion` permission.
|
||||
* operationId: listIngestionSources
|
||||
* tags:
|
||||
* - Ingestion
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* - apiKeyAuth: []
|
||||
* responses:
|
||||
* '200':
|
||||
* description: Array of ingestion sources.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/SafeIngestionSource'
|
||||
* '401':
|
||||
* $ref: '#/components/responses/Unauthorized'
|
||||
* '500':
|
||||
* $ref: '#/components/responses/InternalServerError'
|
||||
*/
|
||||
router.post('/', requirePermission('create', 'ingestion'), ingestionController.create);
|
||||
|
||||
router.get('/', requirePermission('read', 'ingestion'), ingestionController.findAll);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /v1/ingestion-sources/{id}:
|
||||
* get:
|
||||
* summary: Get an ingestion source
|
||||
* description: Returns a single ingestion source by ID. Credentials are excluded. Requires `read:ingestion` permission.
|
||||
* operationId: getIngestionSourceById
|
||||
* tags:
|
||||
* - Ingestion
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* - apiKeyAuth: []
|
||||
* parameters:
|
||||
* - name: id
|
||||
* in: path
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* example: "clx1y2z3a0000b4d2"
|
||||
* responses:
|
||||
* '200':
|
||||
* description: Ingestion source details.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/SafeIngestionSource'
|
||||
* '401':
|
||||
* $ref: '#/components/responses/Unauthorized'
|
||||
* '404':
|
||||
* $ref: '#/components/responses/NotFound'
|
||||
* '500':
|
||||
* $ref: '#/components/responses/InternalServerError'
|
||||
* put:
|
||||
* summary: Update an ingestion source
|
||||
* description: Updates configuration for an existing ingestion source. Requires `update:ingestion` permission.
|
||||
* operationId: updateIngestionSource
|
||||
* tags:
|
||||
* - Ingestion
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* - apiKeyAuth: []
|
||||
* parameters:
|
||||
* - name: id
|
||||
* in: path
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* example: "clx1y2z3a0000b4d2"
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/UpdateIngestionSourceDto'
|
||||
* responses:
|
||||
* '200':
|
||||
* description: Updated ingestion source.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/SafeIngestionSource'
|
||||
* '401':
|
||||
* $ref: '#/components/responses/Unauthorized'
|
||||
* '404':
|
||||
* $ref: '#/components/responses/NotFound'
|
||||
* '500':
|
||||
* $ref: '#/components/responses/InternalServerError'
|
||||
* delete:
|
||||
* summary: Delete an ingestion source
|
||||
* description: Permanently deletes an ingestion source. Deletion must be enabled in system settings. Requires `delete:ingestion` permission.
|
||||
* operationId: deleteIngestionSource
|
||||
* tags:
|
||||
* - Ingestion
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* - apiKeyAuth: []
|
||||
* parameters:
|
||||
* - name: id
|
||||
* in: path
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* example: "clx1y2z3a0000b4d2"
|
||||
* responses:
|
||||
* '204':
|
||||
* description: Ingestion source deleted. No content returned.
|
||||
* '400':
|
||||
* description: Deletion disabled or constraint error.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorMessage'
|
||||
* '401':
|
||||
* $ref: '#/components/responses/Unauthorized'
|
||||
* '404':
|
||||
* $ref: '#/components/responses/NotFound'
|
||||
* '500':
|
||||
* $ref: '#/components/responses/InternalServerError'
|
||||
*/
|
||||
router.get('/:id', requirePermission('read', 'ingestion'), ingestionController.findById);
|
||||
|
||||
router.put('/:id', requirePermission('update', 'ingestion'), ingestionController.update);
|
||||
|
||||
router.delete('/:id', requirePermission('delete', 'ingestion'), ingestionController.delete);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /v1/ingestion-sources/{id}/import:
|
||||
* post:
|
||||
* summary: Trigger initial import
|
||||
* description: Enqueues an initial import job for the ingestion source. This imports all historical emails. Requires `create:ingestion` permission.
|
||||
* operationId: triggerInitialImport
|
||||
* tags:
|
||||
* - Ingestion
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* - apiKeyAuth: []
|
||||
* parameters:
|
||||
* - name: id
|
||||
* in: path
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* example: "clx1y2z3a0000b4d2"
|
||||
* responses:
|
||||
* '202':
|
||||
* description: Initial import job accepted and queued.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/MessageResponse'
|
||||
* '401':
|
||||
* $ref: '#/components/responses/Unauthorized'
|
||||
* '404':
|
||||
* $ref: '#/components/responses/NotFound'
|
||||
* '500':
|
||||
* $ref: '#/components/responses/InternalServerError'
|
||||
*/
|
||||
router.post(
|
||||
'/:id/import',
|
||||
requirePermission('create', 'ingestion'),
|
||||
ingestionController.triggerInitialImport
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /v1/ingestion-sources/{id}/pause:
|
||||
* post:
|
||||
* summary: Pause an ingestion source
|
||||
* description: Sets the ingestion source status to `paused`, stopping continuous sync. Requires `update:ingestion` permission.
|
||||
* operationId: pauseIngestionSource
|
||||
* tags:
|
||||
* - Ingestion
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* - apiKeyAuth: []
|
||||
* parameters:
|
||||
* - name: id
|
||||
* in: path
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* example: "clx1y2z3a0000b4d2"
|
||||
* responses:
|
||||
* '200':
|
||||
* description: Ingestion source paused. Returns the updated source.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/SafeIngestionSource'
|
||||
* '401':
|
||||
* $ref: '#/components/responses/Unauthorized'
|
||||
* '404':
|
||||
* $ref: '#/components/responses/NotFound'
|
||||
* '500':
|
||||
* $ref: '#/components/responses/InternalServerError'
|
||||
*/
|
||||
router.post('/:id/pause', requirePermission('update', 'ingestion'), ingestionController.pause);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /v1/ingestion-sources/{id}/sync:
|
||||
* post:
|
||||
* summary: Force sync
|
||||
* description: Triggers an out-of-schedule continuous sync for the ingestion source. Requires `sync:ingestion` permission.
|
||||
* operationId: triggerForceSync
|
||||
* tags:
|
||||
* - Ingestion
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* - apiKeyAuth: []
|
||||
* parameters:
|
||||
* - name: id
|
||||
* in: path
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* example: "clx1y2z3a0000b4d2"
|
||||
* responses:
|
||||
* '202':
|
||||
* description: Force sync job accepted and queued.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/MessageResponse'
|
||||
* '401':
|
||||
* $ref: '#/components/responses/Unauthorized'
|
||||
* '404':
|
||||
* $ref: '#/components/responses/NotFound'
|
||||
* '500':
|
||||
* $ref: '#/components/responses/InternalServerError'
|
||||
*/
|
||||
router.post(
|
||||
'/:id/sync',
|
||||
requirePermission('sync', 'ingestion'),
|
||||
|
||||
@@ -10,6 +10,49 @@ export const integrityRoutes = (authService: AuthService): Router => {
|
||||
|
||||
router.use(requireAuth(authService));
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /v1/integrity/{id}:
|
||||
* get:
|
||||
* summary: Check email integrity
|
||||
* description: Verifies the SHA-256 hash of an archived email and all its attachments against the hashes stored at archival time. Returns per-item integrity results. Requires `read:archive` permission.
|
||||
* operationId: checkIntegrity
|
||||
* tags:
|
||||
* - Integrity
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* - apiKeyAuth: []
|
||||
* parameters:
|
||||
* - name: id
|
||||
* in: path
|
||||
* required: true
|
||||
* description: UUID of the archived email to verify.
|
||||
* schema:
|
||||
* type: string
|
||||
* format: uuid
|
||||
* example: "550e8400-e29b-41d4-a716-446655440000"
|
||||
* responses:
|
||||
* '200':
|
||||
* description: Integrity check results for the email and its attachments.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/IntegrityCheckResult'
|
||||
* '400':
|
||||
* description: Invalid UUID format.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ValidationError'
|
||||
* '401':
|
||||
* $ref: '#/components/responses/Unauthorized'
|
||||
* '404':
|
||||
* $ref: '#/components/responses/NotFound'
|
||||
* '500':
|
||||
* $ref: '#/components/responses/InternalServerError'
|
||||
*/
|
||||
router.get('/:id', requirePermission('read', 'archive'), controller.checkIntegrity);
|
||||
|
||||
return router;
|
||||
|
||||
@@ -10,11 +10,121 @@ export const createJobsRouter = (authService: AuthService): Router => {
|
||||
|
||||
router.use(requireAuth(authService));
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /v1/jobs/queues:
|
||||
* get:
|
||||
* summary: List all queues
|
||||
* description: Returns all BullMQ job queues and their current job counts broken down by status. Requires `manage:all` (Super Admin) permission.
|
||||
* operationId: getQueues
|
||||
* tags:
|
||||
* - Jobs
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* - apiKeyAuth: []
|
||||
* responses:
|
||||
* '200':
|
||||
* description: List of queue overviews.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* queues:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/QueueOverview'
|
||||
* example:
|
||||
* queues:
|
||||
* - name: ingestion
|
||||
* counts:
|
||||
* active: 0
|
||||
* completed: 56
|
||||
* failed: 4
|
||||
* delayed: 3
|
||||
* waiting: 0
|
||||
* paused: 0
|
||||
* - name: indexing
|
||||
* counts:
|
||||
* active: 0
|
||||
* completed: 0
|
||||
* failed: 0
|
||||
* delayed: 0
|
||||
* waiting: 0
|
||||
* paused: 0
|
||||
* '401':
|
||||
* $ref: '#/components/responses/Unauthorized'
|
||||
* '403':
|
||||
* $ref: '#/components/responses/Forbidden'
|
||||
* '500':
|
||||
* $ref: '#/components/responses/InternalServerError'
|
||||
*/
|
||||
router.get(
|
||||
'/queues',
|
||||
requirePermission('manage', 'all', 'user.requiresSuperAdminRole'),
|
||||
jobsController.getQueues
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /v1/jobs/queues/{queueName}:
|
||||
* get:
|
||||
* summary: Get jobs in a queue
|
||||
* description: Returns a paginated list of jobs within a specific queue, filtered by status. Requires `manage:all` (Super Admin) permission.
|
||||
* operationId: getQueueJobs
|
||||
* tags:
|
||||
* - Jobs
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* - apiKeyAuth: []
|
||||
* parameters:
|
||||
* - name: queueName
|
||||
* in: path
|
||||
* required: true
|
||||
* description: The name of the queue (e.g. `ingestion` or `indexing`).
|
||||
* schema:
|
||||
* type: string
|
||||
* example: ingestion
|
||||
* - name: status
|
||||
* in: query
|
||||
* required: false
|
||||
* description: Filter jobs by status.
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [active, completed, failed, delayed, waiting, paused]
|
||||
* default: failed
|
||||
* - name: page
|
||||
* in: query
|
||||
* required: false
|
||||
* schema:
|
||||
* type: integer
|
||||
* default: 1
|
||||
* - name: limit
|
||||
* in: query
|
||||
* required: false
|
||||
* schema:
|
||||
* type: integer
|
||||
* default: 10
|
||||
* responses:
|
||||
* '200':
|
||||
* description: Detailed view of the queue including paginated jobs.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/QueueDetails'
|
||||
* '401':
|
||||
* $ref: '#/components/responses/Unauthorized'
|
||||
* '403':
|
||||
* $ref: '#/components/responses/Forbidden'
|
||||
* '404':
|
||||
* description: Queue not found.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorMessage'
|
||||
* '500':
|
||||
* $ref: '#/components/responses/InternalServerError'
|
||||
*/
|
||||
router.get(
|
||||
'/queues/:queueName',
|
||||
requirePermission('manage', 'all', 'user.requiresSuperAdminRole'),
|
||||
|
||||
@@ -12,6 +12,68 @@ export const createSearchRouter = (
|
||||
|
||||
router.use(requireAuth(authService));
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /v1/search:
|
||||
* get:
|
||||
* summary: Search archived emails
|
||||
* description: Performs a full-text search across indexed archived emails using Meilisearch. Requires `search:archive` permission.
|
||||
* operationId: searchEmails
|
||||
* tags:
|
||||
* - Search
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* - apiKeyAuth: []
|
||||
* parameters:
|
||||
* - name: keywords
|
||||
* in: query
|
||||
* required: true
|
||||
* description: The search query string.
|
||||
* schema:
|
||||
* type: string
|
||||
* example: "invoice Q4"
|
||||
* - name: page
|
||||
* in: query
|
||||
* required: false
|
||||
* description: Page number for pagination.
|
||||
* schema:
|
||||
* type: integer
|
||||
* default: 1
|
||||
* example: 1
|
||||
* - name: limit
|
||||
* in: query
|
||||
* required: false
|
||||
* description: Number of results per page.
|
||||
* schema:
|
||||
* type: integer
|
||||
* default: 10
|
||||
* example: 10
|
||||
* - name: matchingStrategy
|
||||
* in: query
|
||||
* required: false
|
||||
* description: Meilisearch matching strategy. `last` returns results containing at least one keyword; `all` requires all keywords; `frequency` sorts by keyword frequency.
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [last, all, frequency]
|
||||
* default: last
|
||||
* responses:
|
||||
* '200':
|
||||
* description: Search results.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/SearchResults'
|
||||
* '400':
|
||||
* description: Keywords parameter is required.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorMessage'
|
||||
* '401':
|
||||
* $ref: '#/components/responses/Unauthorized'
|
||||
* '500':
|
||||
* $ref: '#/components/responses/InternalServerError'
|
||||
*/
|
||||
router.get('/', requirePermission('search', 'archive'), searchController.search);
|
||||
|
||||
return router;
|
||||
|
||||
@@ -7,10 +7,56 @@ import { AuthService } from '../../services/AuthService';
|
||||
export const createSettingsRouter = (authService: AuthService): Router => {
|
||||
const router = Router();
|
||||
|
||||
// Public route to get non-sensitive settings. settings read should not be scoped with a permission because all end users need the settings data in the frontend. However, for sensitive settings data, we need to add a new permission subject to limit access. So this route should only expose non-sensitive settings data.
|
||||
/**
|
||||
* @returns SystemSettings
|
||||
* @openapi
|
||||
* /v1/settings/system:
|
||||
* get:
|
||||
* summary: Get system settings
|
||||
* description: >
|
||||
* Returns non-sensitive system settings such as language, timezone, and feature flags.
|
||||
* This endpoint is public — no authentication required. Sensitive settings are never exposed.
|
||||
* operationId: getSystemSettings
|
||||
* tags:
|
||||
* - Settings
|
||||
* responses:
|
||||
* '200':
|
||||
* description: Current system settings.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/SystemSettings'
|
||||
* '500':
|
||||
* $ref: '#/components/responses/InternalServerError'
|
||||
* put:
|
||||
* summary: Update system settings
|
||||
* description: Updates system settings. Requires `manage:settings` permission.
|
||||
* operationId: updateSystemSettings
|
||||
* tags:
|
||||
* - Settings
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* - apiKeyAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/SystemSettings'
|
||||
* responses:
|
||||
* '200':
|
||||
* description: Updated system settings.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/SystemSettings'
|
||||
* '401':
|
||||
* $ref: '#/components/responses/Unauthorized'
|
||||
* '403':
|
||||
* $ref: '#/components/responses/Forbidden'
|
||||
* '500':
|
||||
* $ref: '#/components/responses/InternalServerError'
|
||||
*/
|
||||
// Public route to get non-sensitive settings. All end users need the settings data in the frontend.
|
||||
router.get('/system', settingsController.getSystemSettings);
|
||||
|
||||
// Protected route to update settings
|
||||
|
||||
@@ -13,6 +13,60 @@ export const createStorageRouter = (
|
||||
// Secure all routes in this module
|
||||
router.use(requireAuth(authService));
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /v1/storage/download:
|
||||
* get:
|
||||
* summary: Download a stored file
|
||||
* description: >
|
||||
* Downloads a file from the configured storage backend (local filesystem or S3-compatible).
|
||||
* The path is sanitized to prevent directory traversal attacks.
|
||||
* Requires `read:archive` permission.
|
||||
* operationId: downloadFile
|
||||
* tags:
|
||||
* - Storage
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* - apiKeyAuth: []
|
||||
* parameters:
|
||||
* - name: path
|
||||
* in: query
|
||||
* required: true
|
||||
* description: The relative storage path of the file to download.
|
||||
* schema:
|
||||
* type: string
|
||||
* example: "open-archiver/emails/abc123.eml"
|
||||
* responses:
|
||||
* '200':
|
||||
* description: The file content as a binary stream. The `Content-Disposition` header is set to trigger a browser download.
|
||||
* headers:
|
||||
* Content-Disposition:
|
||||
* description: Attachment filename.
|
||||
* schema:
|
||||
* type: string
|
||||
* example: 'attachment; filename="abc123.eml"'
|
||||
* content:
|
||||
* application/octet-stream:
|
||||
* schema:
|
||||
* type: string
|
||||
* format: binary
|
||||
* '400':
|
||||
* description: File path is required or invalid.
|
||||
* content:
|
||||
* text/plain:
|
||||
* schema:
|
||||
* type: string
|
||||
* '401':
|
||||
* $ref: '#/components/responses/Unauthorized'
|
||||
* '404':
|
||||
* description: File not found in storage.
|
||||
* content:
|
||||
* text/plain:
|
||||
* schema:
|
||||
* type: string
|
||||
* '500':
|
||||
* $ref: '#/components/responses/InternalServerError'
|
||||
*/
|
||||
router.get('/download', requirePermission('read', 'archive'), storageController.downloadFile);
|
||||
|
||||
return router;
|
||||
|
||||
@@ -9,6 +9,55 @@ export const createUploadRouter = (authService: AuthService): Router => {
|
||||
|
||||
router.use(requireAuth(authService));
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /v1/upload:
|
||||
* post:
|
||||
* summary: Upload a file
|
||||
* description: >
|
||||
* Uploads a file (PST, EML, MBOX, or other) to temporary storage for subsequent use in an ingestion source.
|
||||
* Returns the storage path, which should be passed as `uploadedFilePath` when creating a file-based ingestion source.
|
||||
* Requires `create:ingestion` permission.
|
||||
* operationId: uploadFile
|
||||
* tags:
|
||||
* - Upload
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* - apiKeyAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* multipart/form-data:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* file:
|
||||
* type: string
|
||||
* format: binary
|
||||
* description: The file to upload.
|
||||
* responses:
|
||||
* '200':
|
||||
* description: File uploaded successfully. Returns the storage path.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* filePath:
|
||||
* type: string
|
||||
* description: The storage path of the uploaded file. Use this as `uploadedFilePath` when creating a file-based ingestion source.
|
||||
* example: "open-archiver/tmp/uuid-filename.pst"
|
||||
* '400':
|
||||
* description: Invalid multipart request.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorMessage'
|
||||
* '401':
|
||||
* $ref: '#/components/responses/Unauthorized'
|
||||
* '500':
|
||||
* $ref: '#/components/responses/InternalServerError'
|
||||
*/
|
||||
router.post('/', requirePermission('create', 'ingestion'), uploadFile);
|
||||
|
||||
return router;
|
||||
|
||||
@@ -9,16 +9,235 @@ export const createUserRouter = (authService: AuthService): Router => {
|
||||
|
||||
router.use(requireAuth(authService));
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /v1/users:
|
||||
* get:
|
||||
* summary: List all users
|
||||
* description: Returns all user accounts in the system. Requires `read:users` permission.
|
||||
* operationId: getUsers
|
||||
* tags:
|
||||
* - Users
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* - apiKeyAuth: []
|
||||
* responses:
|
||||
* '200':
|
||||
* description: List of users.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/User'
|
||||
* '401':
|
||||
* $ref: '#/components/responses/Unauthorized'
|
||||
*/
|
||||
router.get('/', requirePermission('read', 'users'), userController.getUsers);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /v1/users/profile:
|
||||
* get:
|
||||
* summary: Get current user profile
|
||||
* description: Returns the profile of the currently authenticated user.
|
||||
* operationId: getProfile
|
||||
* tags:
|
||||
* - Users
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* - apiKeyAuth: []
|
||||
* responses:
|
||||
* '200':
|
||||
* description: Current user's profile.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/User'
|
||||
* '401':
|
||||
* $ref: '#/components/responses/Unauthorized'
|
||||
* '404':
|
||||
* $ref: '#/components/responses/NotFound'
|
||||
* patch:
|
||||
* summary: Update current user profile
|
||||
* description: Updates the email, first name, or last name of the currently authenticated user. Disabled in demo mode.
|
||||
* operationId: updateProfile
|
||||
* tags:
|
||||
* - Users
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* - apiKeyAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* email:
|
||||
* type: string
|
||||
* format: email
|
||||
* first_name:
|
||||
* type: string
|
||||
* last_name:
|
||||
* type: string
|
||||
* responses:
|
||||
* '200':
|
||||
* description: Updated user profile.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/User'
|
||||
* '401':
|
||||
* $ref: '#/components/responses/Unauthorized'
|
||||
* '403':
|
||||
* description: Disabled in demo mode.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorMessage'
|
||||
*/
|
||||
router.get('/profile', userController.getProfile);
|
||||
router.patch('/profile', userController.updateProfile);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /v1/users/profile/password:
|
||||
* post:
|
||||
* summary: Update password
|
||||
* description: Updates the password of the currently authenticated user. The current password must be provided for verification. Disabled in demo mode.
|
||||
* operationId: updatePassword
|
||||
* tags:
|
||||
* - Users
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* - apiKeyAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - currentPassword
|
||||
* - newPassword
|
||||
* properties:
|
||||
* currentPassword:
|
||||
* type: string
|
||||
* format: password
|
||||
* newPassword:
|
||||
* type: string
|
||||
* format: password
|
||||
* responses:
|
||||
* '200':
|
||||
* description: Password updated successfully.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/MessageResponse'
|
||||
* '400':
|
||||
* description: Current password is incorrect.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorMessage'
|
||||
* '401':
|
||||
* $ref: '#/components/responses/Unauthorized'
|
||||
* '403':
|
||||
* description: Disabled in demo mode.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorMessage'
|
||||
*/
|
||||
router.post('/profile/password', userController.updatePassword);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /v1/users/{id}:
|
||||
* get:
|
||||
* summary: Get a user
|
||||
* description: Returns a single user by ID. Requires `read:users` permission.
|
||||
* operationId: getUser
|
||||
* tags:
|
||||
* - Users
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* - apiKeyAuth: []
|
||||
* parameters:
|
||||
* - name: id
|
||||
* in: path
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* example: "clx1y2z3a0000b4d2"
|
||||
* responses:
|
||||
* '200':
|
||||
* description: User details.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/User'
|
||||
* '401':
|
||||
* $ref: '#/components/responses/Unauthorized'
|
||||
* '404':
|
||||
* $ref: '#/components/responses/NotFound'
|
||||
*/
|
||||
router.get('/:id', requirePermission('read', 'users'), userController.getUser);
|
||||
|
||||
/**
|
||||
* Only super admin has the ability to modify existing users or create new users.
|
||||
* @openapi
|
||||
* /v1/users:
|
||||
* post:
|
||||
* summary: Create a user
|
||||
* description: Creates a new user account and optionally assigns a role. Requires `manage:all` (Super Admin) permission.
|
||||
* operationId: createUser
|
||||
* tags:
|
||||
* - Users
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* - apiKeyAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - email
|
||||
* - first_name
|
||||
* - last_name
|
||||
* - password
|
||||
* properties:
|
||||
* email:
|
||||
* type: string
|
||||
* format: email
|
||||
* example: jane.doe@example.com
|
||||
* first_name:
|
||||
* type: string
|
||||
* example: Jane
|
||||
* last_name:
|
||||
* type: string
|
||||
* example: Doe
|
||||
* password:
|
||||
* type: string
|
||||
* format: password
|
||||
* example: "securepassword123"
|
||||
* roleId:
|
||||
* type: string
|
||||
* description: Optional role ID to assign to the user.
|
||||
* example: "clx1y2z3a0000b4d2"
|
||||
* responses:
|
||||
* '201':
|
||||
* description: User created.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/User'
|
||||
* '401':
|
||||
* $ref: '#/components/responses/Unauthorized'
|
||||
* '403':
|
||||
* $ref: '#/components/responses/Forbidden'
|
||||
*/
|
||||
router.post(
|
||||
'/',
|
||||
@@ -26,12 +245,94 @@ export const createUserRouter = (authService: AuthService): Router => {
|
||||
userController.createUser
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /v1/users/{id}:
|
||||
* put:
|
||||
* summary: Update a user
|
||||
* description: Updates a user's email, name, or role assignment. Requires `manage:all` (Super Admin) permission.
|
||||
* operationId: updateUser
|
||||
* tags:
|
||||
* - Users
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* - apiKeyAuth: []
|
||||
* parameters:
|
||||
* - name: id
|
||||
* in: path
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* example: "clx1y2z3a0000b4d2"
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* email:
|
||||
* type: string
|
||||
* format: email
|
||||
* first_name:
|
||||
* type: string
|
||||
* last_name:
|
||||
* type: string
|
||||
* roleId:
|
||||
* type: string
|
||||
* responses:
|
||||
* '200':
|
||||
* description: Updated user.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/User'
|
||||
* '401':
|
||||
* $ref: '#/components/responses/Unauthorized'
|
||||
* '403':
|
||||
* $ref: '#/components/responses/Forbidden'
|
||||
* '404':
|
||||
* $ref: '#/components/responses/NotFound'
|
||||
*/
|
||||
router.put(
|
||||
'/:id',
|
||||
requirePermission('manage', 'all', 'user.requiresSuperAdminRole'),
|
||||
userController.updateUser
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /v1/users/{id}:
|
||||
* delete:
|
||||
* summary: Delete a user
|
||||
* description: Permanently deletes a user. Cannot delete the last remaining user. Requires `manage:all` (Super Admin) permission.
|
||||
* operationId: deleteUser
|
||||
* tags:
|
||||
* - Users
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* - apiKeyAuth: []
|
||||
* parameters:
|
||||
* - name: id
|
||||
* in: path
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* example: "clx1y2z3a0000b4d2"
|
||||
* responses:
|
||||
* '204':
|
||||
* description: User deleted. No content returned.
|
||||
* '400':
|
||||
* description: Cannot delete the only remaining user.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorMessage'
|
||||
* '401':
|
||||
* $ref: '#/components/responses/Unauthorized'
|
||||
* '403':
|
||||
* $ref: '#/components/responses/Forbidden'
|
||||
*/
|
||||
router.delete(
|
||||
'/:id',
|
||||
requirePermission('manage', 'all', 'user.requiresSuperAdminRole'),
|
||||
|
||||
@@ -158,13 +158,12 @@ export async function createServer(modules: ArchiverModule[] = []): Promise<Expr
|
||||
// Load all provided extension modules
|
||||
for (const module of modules) {
|
||||
await module.initialize(app, authService);
|
||||
console.log(`🏢 Enterprise module loaded: ${module.name}`);
|
||||
logger.info(`🏢 Enterprise module loaded: ${module.name}`);
|
||||
}
|
||||
app.get('/', (req, res) => {
|
||||
res.send('Backend is running!!');
|
||||
});
|
||||
|
||||
console.log('✅ Core OSS modules loaded.');
|
||||
logger.info('✅ Core OSS modules loaded.');
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
CREATE TABLE "sync_sessions" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"ingestion_source_id" uuid NOT NULL,
|
||||
"is_initial_import" boolean DEFAULT false NOT NULL,
|
||||
"total_mailboxes" integer DEFAULT 0 NOT NULL,
|
||||
"completed_mailboxes" integer DEFAULT 0 NOT NULL,
|
||||
"failed_mailboxes" integer DEFAULT 0 NOT NULL,
|
||||
"error_messages" text[] DEFAULT '{}' NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "sync_sessions" ADD CONSTRAINT "sync_sessions_ingestion_source_id_ingestion_sources_id_fk" FOREIGN KEY ("ingestion_source_id") REFERENCES "public"."ingestion_sources"("id") ON DELETE cascade ON UPDATE no action;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "sync_sessions" ADD COLUMN "last_activity_at" timestamp with time zone DEFAULT now() NOT NULL;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "archived_emails" ADD COLUMN "provider_message_id" text;--> statement-breakpoint
|
||||
CREATE INDEX "provider_msg_source_idx" ON "archived_emails" USING btree ("provider_message_id","ingestion_source_id");
|
||||
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
1578
packages/backend/src/database/migrations/meta/0027_snapshot.json
Normal file
1578
packages/backend/src/database/migrations/meta/0027_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1585
packages/backend/src/database/migrations/meta/0028_snapshot.json
Normal file
1585
packages/backend/src/database/migrations/meta/0028_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1612
packages/backend/src/database/migrations/meta/0029_snapshot.json
Normal file
1612
packages/backend/src/database/migrations/meta/0029_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,195 +1,216 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1752225352591,
|
||||
"tag": "0000_amusing_namora",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1752326803882,
|
||||
"tag": "0001_odd_night_thrasher",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1752332648392,
|
||||
"tag": "0002_lethal_quentin_quire",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "7",
|
||||
"when": 1752332967084,
|
||||
"tag": "0003_petite_wrecker",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "7",
|
||||
"when": 1752606108876,
|
||||
"tag": "0004_sleepy_paper_doll",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "7",
|
||||
"when": 1752606327253,
|
||||
"tag": "0005_chunky_sue_storm",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "7",
|
||||
"when": 1753112018514,
|
||||
"tag": "0006_majestic_caretaker",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "7",
|
||||
"when": 1753190159356,
|
||||
"tag": "0007_handy_archangel",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "7",
|
||||
"when": 1753370737317,
|
||||
"tag": "0008_eminent_the_spike",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "7",
|
||||
"when": 1754337938241,
|
||||
"tag": "0009_late_lenny_balinger",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "7",
|
||||
"when": 1754420780849,
|
||||
"tag": "0010_perpetual_lightspeed",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "7",
|
||||
"when": 1754422064158,
|
||||
"tag": "0011_tan_blackheart",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "7",
|
||||
"when": 1754476962901,
|
||||
"tag": "0012_warm_the_stranger",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "7",
|
||||
"when": 1754659373517,
|
||||
"tag": "0013_classy_talkback",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "7",
|
||||
"when": 1754831765718,
|
||||
"tag": "0014_foamy_vapor",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 15,
|
||||
"version": "7",
|
||||
"when": 1755443936046,
|
||||
"tag": "0015_wakeful_norman_osborn",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 16,
|
||||
"version": "7",
|
||||
"when": 1755780572342,
|
||||
"tag": "0016_lonely_mariko_yashida",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 17,
|
||||
"version": "7",
|
||||
"when": 1755961566627,
|
||||
"tag": "0017_tranquil_shooting_star",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 18,
|
||||
"version": "7",
|
||||
"when": 1756911118035,
|
||||
"tag": "0018_flawless_owl",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 19,
|
||||
"version": "7",
|
||||
"when": 1756937533843,
|
||||
"tag": "0019_confused_scream",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 20,
|
||||
"version": "7",
|
||||
"when": 1757860242528,
|
||||
"tag": "0020_panoramic_wolverine",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 21,
|
||||
"version": "7",
|
||||
"when": 1759412986134,
|
||||
"tag": "0021_nosy_veda",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 22,
|
||||
"version": "7",
|
||||
"when": 1759701622932,
|
||||
"tag": "0022_complete_triton",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 23,
|
||||
"version": "7",
|
||||
"when": 1760354094610,
|
||||
"tag": "0023_swift_swordsman",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 24,
|
||||
"version": "7",
|
||||
"when": 1772842674479,
|
||||
"tag": "0024_careful_black_panther",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 25,
|
||||
"version": "7",
|
||||
"when": 1773013461190,
|
||||
"tag": "0025_peaceful_grim_reaper",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 26,
|
||||
"version": "7",
|
||||
"when": 1773326266420,
|
||||
"tag": "0026_pink_fantastic_four",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1752225352591,
|
||||
"tag": "0000_amusing_namora",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1752326803882,
|
||||
"tag": "0001_odd_night_thrasher",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1752332648392,
|
||||
"tag": "0002_lethal_quentin_quire",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "7",
|
||||
"when": 1752332967084,
|
||||
"tag": "0003_petite_wrecker",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "7",
|
||||
"when": 1752606108876,
|
||||
"tag": "0004_sleepy_paper_doll",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "7",
|
||||
"when": 1752606327253,
|
||||
"tag": "0005_chunky_sue_storm",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "7",
|
||||
"when": 1753112018514,
|
||||
"tag": "0006_majestic_caretaker",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "7",
|
||||
"when": 1753190159356,
|
||||
"tag": "0007_handy_archangel",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "7",
|
||||
"when": 1753370737317,
|
||||
"tag": "0008_eminent_the_spike",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "7",
|
||||
"when": 1754337938241,
|
||||
"tag": "0009_late_lenny_balinger",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "7",
|
||||
"when": 1754420780849,
|
||||
"tag": "0010_perpetual_lightspeed",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "7",
|
||||
"when": 1754422064158,
|
||||
"tag": "0011_tan_blackheart",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "7",
|
||||
"when": 1754476962901,
|
||||
"tag": "0012_warm_the_stranger",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "7",
|
||||
"when": 1754659373517,
|
||||
"tag": "0013_classy_talkback",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "7",
|
||||
"when": 1754831765718,
|
||||
"tag": "0014_foamy_vapor",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 15,
|
||||
"version": "7",
|
||||
"when": 1755443936046,
|
||||
"tag": "0015_wakeful_norman_osborn",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 16,
|
||||
"version": "7",
|
||||
"when": 1755780572342,
|
||||
"tag": "0016_lonely_mariko_yashida",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 17,
|
||||
"version": "7",
|
||||
"when": 1755961566627,
|
||||
"tag": "0017_tranquil_shooting_star",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 18,
|
||||
"version": "7",
|
||||
"when": 1756911118035,
|
||||
"tag": "0018_flawless_owl",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 19,
|
||||
"version": "7",
|
||||
"when": 1756937533843,
|
||||
"tag": "0019_confused_scream",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 20,
|
||||
"version": "7",
|
||||
"when": 1757860242528,
|
||||
"tag": "0020_panoramic_wolverine",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 21,
|
||||
"version": "7",
|
||||
"when": 1759412986134,
|
||||
"tag": "0021_nosy_veda",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 22,
|
||||
"version": "7",
|
||||
"when": 1759701622932,
|
||||
"tag": "0022_complete_triton",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 23,
|
||||
"version": "7",
|
||||
"when": 1760354094610,
|
||||
"tag": "0023_swift_swordsman",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 24,
|
||||
"version": "7",
|
||||
"when": 1772842674479,
|
||||
"tag": "0024_careful_black_panther",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 25,
|
||||
"version": "7",
|
||||
"when": 1773013461190,
|
||||
"tag": "0025_peaceful_grim_reaper",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 26,
|
||||
"version": "7",
|
||||
"when": 1773326266420,
|
||||
"tag": "0026_pink_fantastic_four",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 27,
|
||||
"version": "7",
|
||||
"when": 1773768709477,
|
||||
"tag": "0027_black_morph",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 28,
|
||||
"version": "7",
|
||||
"when": 1773770326402,
|
||||
"tag": "0028_youthful_kitty_pryde",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 29,
|
||||
"version": "7",
|
||||
"when": 1773927678269,
|
||||
"tag": "0029_lethal_brood",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -9,3 +9,4 @@ export * from './schema/system-settings';
|
||||
export * from './schema/api-keys';
|
||||
export * from './schema/audit-logs';
|
||||
export * from './schema/enums';
|
||||
export * from './schema/sync-sessions';
|
||||
|
||||
@@ -12,6 +12,9 @@ export const archivedEmails = pgTable(
|
||||
.references(() => ingestionSources.id, { onDelete: 'cascade' }),
|
||||
userEmail: text('user_email').notNull(),
|
||||
messageIdHeader: text('message_id_header'),
|
||||
/** The provider-specific message ID (e.g., Gmail API ID, Graph API ID).
|
||||
* Used by the pre-fetch duplicate check to avoid unnecessary API calls during retries. */
|
||||
providerMessageId: text('provider_message_id'),
|
||||
sentAt: timestamp('sent_at', { withTimezone: true }).notNull(),
|
||||
subject: text('subject'),
|
||||
senderName: text('sender_name'),
|
||||
@@ -27,7 +30,10 @@ export const archivedEmails = pgTable(
|
||||
path: text('path'),
|
||||
tags: jsonb('tags'),
|
||||
},
|
||||
(table) => [index('thread_id_idx').on(table.threadId)]
|
||||
(table) => [
|
||||
index('thread_id_idx').on(table.threadId),
|
||||
index('provider_msg_source_idx').on(table.providerMessageId, table.ingestionSourceId),
|
||||
]
|
||||
);
|
||||
|
||||
export const archivedEmailsRelations = relations(archivedEmails, ({ one }) => ({
|
||||
|
||||
@@ -50,18 +50,20 @@ export const retentionLabels = pgTable('retention_labels', {
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const emailRetentionLabels = pgTable('email_retention_labels', {
|
||||
emailId: uuid('email_id')
|
||||
.references(() => archivedEmails.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
labelId: uuid('label_id')
|
||||
.references(() => retentionLabels.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
appliedAt: timestamp('applied_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
appliedByUserId: uuid('applied_by_user_id').references(() => users.id),
|
||||
}, (t) => [
|
||||
primaryKey({ columns: [t.emailId, t.labelId] }),
|
||||
]);
|
||||
export const emailRetentionLabels = pgTable(
|
||||
'email_retention_labels',
|
||||
{
|
||||
emailId: uuid('email_id')
|
||||
.references(() => archivedEmails.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
labelId: uuid('label_id')
|
||||
.references(() => retentionLabels.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
appliedAt: timestamp('applied_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
appliedByUserId: uuid('applied_by_user_id').references(() => users.id),
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.emailId, t.labelId] })]
|
||||
);
|
||||
|
||||
export const retentionEvents = pgTable('retention_events', {
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
@@ -105,9 +107,7 @@ export const emailLegalHolds = pgTable(
|
||||
appliedAt: timestamp('applied_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
appliedByUserId: uuid('applied_by_user_id').references(() => users.id),
|
||||
},
|
||||
(t) => [
|
||||
primaryKey({ columns: [t.emailId, t.legalHoldId] }),
|
||||
],
|
||||
(t) => [primaryKey({ columns: [t.emailId, t.legalHoldId] })]
|
||||
);
|
||||
|
||||
export const exportJobs = pgTable('export_jobs', {
|
||||
|
||||
36
packages/backend/src/database/schema/sync-sessions.ts
Normal file
36
packages/backend/src/database/schema/sync-sessions.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { boolean, integer, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
|
||||
import { ingestionSources } from './ingestion-sources';
|
||||
import { relations } from 'drizzle-orm';
|
||||
|
||||
/**
|
||||
* Tracks the progress of a single sync cycle (initial import or continuous sync).
|
||||
* Used as the coordination layer to replace BullMQ FlowProducer parent/child tracking.
|
||||
* Each process-mailbox job atomically increments completed/failed counters here,
|
||||
* and the last job to finish dispatches the sync-cycle-finished job.
|
||||
*/
|
||||
export const syncSessions = pgTable('sync_sessions', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
ingestionSourceId: uuid('ingestion_source_id')
|
||||
.notNull()
|
||||
.references(() => ingestionSources.id, { onDelete: 'cascade' }),
|
||||
isInitialImport: boolean('is_initial_import').notNull().default(false),
|
||||
totalMailboxes: integer('total_mailboxes').notNull().default(0),
|
||||
completedMailboxes: integer('completed_mailboxes').notNull().default(0),
|
||||
failedMailboxes: integer('failed_mailboxes').notNull().default(0),
|
||||
/** Aggregated error messages from all failed process-mailbox jobs */
|
||||
errorMessages: text('error_messages').array().notNull().default([]),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
/**
|
||||
* Updated each time a process-mailbox job reports its result.
|
||||
* Used to detect genuinely stuck sessions (no activity for N minutes) vs.
|
||||
* large imports that are still actively running.
|
||||
*/
|
||||
lastActivityAt: timestamp('last_activity_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const syncSessionsRelations = relations(syncSessions, ({ one }) => ({
|
||||
ingestionSource: one(ingestionSources, {
|
||||
fields: [syncSessions.ingestionSourceId],
|
||||
references: [ingestionSources.id],
|
||||
}),
|
||||
}));
|
||||
218
packages/backend/src/helpers/emlUtils.ts
Normal file
218
packages/backend/src/helpers/emlUtils.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import { simpleParser, type Attachment } from 'mailparser';
|
||||
import MailComposer from 'nodemailer/lib/mail-composer';
|
||||
import type Mail from 'nodemailer/lib/mailer';
|
||||
import { logger } from '../config/logger';
|
||||
|
||||
/**
|
||||
* Set of headers that are either handled natively by nodemailer's MailComposer
|
||||
* via dedicated options, or are structural MIME headers that will be regenerated
|
||||
* when the MIME tree is rebuilt.
|
||||
*/
|
||||
const HEADERS_HANDLED_BY_COMPOSER = new Set([
|
||||
'content-type',
|
||||
'content-transfer-encoding',
|
||||
'mime-version',
|
||||
'from',
|
||||
'to',
|
||||
'cc',
|
||||
'bcc',
|
||||
'subject',
|
||||
'message-id',
|
||||
'date',
|
||||
'in-reply-to',
|
||||
'references',
|
||||
'reply-to',
|
||||
'sender',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Determines whether a parsed attachment should be preserved in the stored .eml.
|
||||
*
|
||||
* An attachment is considered inline if:
|
||||
* 1. mailparser explicitly marked it as related (embedded in multipart/related)
|
||||
* 2. It has Content-Disposition: inline AND a Content-ID
|
||||
* 3. Its Content-ID is referenced as a cid: URL in the HTML body
|
||||
*
|
||||
* All three checks are evaluated with OR logic (conservative: keep if any match).
|
||||
*/
|
||||
function isInlineAttachment(attachment: Attachment, referencedCids: Set<string>): boolean {
|
||||
// Signal 1: mailparser marks embedded multipart/related resources
|
||||
if (attachment.related === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (attachment.cid) {
|
||||
const normalizedCid = attachment.cid.toLowerCase();
|
||||
|
||||
// Signal 2: explicitly marked inline with a CID
|
||||
if (attachment.contentDisposition === 'inline') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Signal 3: CID is actively referenced in the HTML body
|
||||
if (referencedCids.has(normalizedCid)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts cid: references from an HTML string.
|
||||
* Matches patterns like src="cid:abc123" in img tags or CSS backgrounds.
|
||||
*
|
||||
* @returns A Set of normalized (lowercased) CID values without the "cid:" prefix.
|
||||
*/
|
||||
function extractCidReferences(html: string): Set<string> {
|
||||
const cidPattern = /\bcid:([^\s"'>]+)/gi;
|
||||
const cids = new Set<string>();
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = cidPattern.exec(html)) !== null) {
|
||||
cids.add(match[1].toLowerCase());
|
||||
}
|
||||
return cids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts additional headers from the parsed email's header map that are NOT
|
||||
* handled natively by nodemailer's MailComposer dedicated options.
|
||||
* These are passed through as custom headers to preserve the original email metadata.
|
||||
*/
|
||||
function extractAdditionalHeaders(
|
||||
headers: Map<string, unknown>
|
||||
): Array<{ key: string; value: string }> {
|
||||
const result: Array<{ key: string; value: string }> = [];
|
||||
|
||||
for (const [key, value] of headers) {
|
||||
if (HEADERS_HANDLED_BY_COMPOSER.has(key.toLowerCase())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
result.push({ key, value });
|
||||
} else if (Array.isArray(value)) {
|
||||
// Headers like 'received' can appear multiple times
|
||||
for (const item of value) {
|
||||
if (typeof item === 'string') {
|
||||
result.push({ key, value: item });
|
||||
} else if (item && typeof item === 'object' && 'value' in item) {
|
||||
result.push({ key, value: String(item.value) });
|
||||
}
|
||||
}
|
||||
} else if (value && typeof value === 'object' && 'value' in value) {
|
||||
// Structured headers like { value: '...', params: {...} }
|
||||
result.push({ key, value: String((value as { value: string }).value) });
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a mailparser AddressObject or AddressObject[] to a comma-separated string
|
||||
* suitable for nodemailer's MailComposer options.
|
||||
*/
|
||||
function addressToString(
|
||||
addresses: import('mailparser').AddressObject | import('mailparser').AddressObject[] | undefined
|
||||
): string | undefined {
|
||||
if (!addresses) return undefined;
|
||||
const arr = Array.isArray(addresses) ? addresses : [addresses];
|
||||
return arr.map((a) => a.text).join(', ') || undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips non-inline attachments from a raw .eml buffer to avoid double-storing
|
||||
* attachment data (since attachments are already stored separately).
|
||||
*
|
||||
* Inline images referenced via cid: in the HTML body are preserved so that
|
||||
* the email renders correctly when viewed.
|
||||
*
|
||||
* If the email has no strippable attachments, the original buffer is returned
|
||||
* unchanged (zero overhead).
|
||||
*
|
||||
* If re-serialization fails for any reason, the original buffer is returned
|
||||
* and a warning is logged — email ingestion is never blocked by this function.
|
||||
*
|
||||
* @param emlBuffer The raw .eml file as a Buffer.
|
||||
* @returns A new Buffer with non-inline attachments removed, or the original if nothing was stripped.
|
||||
*/
|
||||
export async function stripAttachmentsFromEml(emlBuffer: Buffer): Promise<Buffer> {
|
||||
try {
|
||||
const parsed = await simpleParser(emlBuffer);
|
||||
|
||||
// If there are no attachments at all, return early
|
||||
if (!parsed.attachments || parsed.attachments.length === 0) {
|
||||
return emlBuffer;
|
||||
}
|
||||
|
||||
// Build the set of cid values referenced in the HTML body
|
||||
const htmlBody = parsed.html || '';
|
||||
const referencedCids = extractCidReferences(htmlBody);
|
||||
|
||||
// Check if there's anything to strip
|
||||
const hasStrippableAttachments = parsed.attachments.some(
|
||||
(a) => !isInlineAttachment(a, referencedCids)
|
||||
);
|
||||
|
||||
if (!hasStrippableAttachments) {
|
||||
return emlBuffer;
|
||||
}
|
||||
|
||||
// Build the list of inline attachments to preserve in the .eml
|
||||
const inlineAttachments: Mail.Attachment[] = [];
|
||||
for (const attachment of parsed.attachments) {
|
||||
if (isInlineAttachment(attachment, referencedCids)) {
|
||||
inlineAttachments.push({
|
||||
content: attachment.content,
|
||||
contentType: attachment.contentType,
|
||||
contentDisposition: 'inline' as const,
|
||||
filename: attachment.filename || undefined,
|
||||
cid: attachment.cid || undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Collect additional headers not handled by MailComposer's dedicated fields
|
||||
const additionalHeaders = extractAdditionalHeaders(parsed.headers);
|
||||
|
||||
// Build the mail options for MailComposer
|
||||
const mailOptions: Mail.Options = {
|
||||
from: addressToString(parsed.from),
|
||||
to: addressToString(parsed.to),
|
||||
cc: addressToString(parsed.cc),
|
||||
bcc: addressToString(parsed.bcc),
|
||||
replyTo: addressToString(parsed.replyTo),
|
||||
subject: parsed.subject,
|
||||
messageId: parsed.messageId,
|
||||
date: parsed.date,
|
||||
inReplyTo: parsed.inReplyTo,
|
||||
references: Array.isArray(parsed.references)
|
||||
? parsed.references.join(' ')
|
||||
: parsed.references,
|
||||
text: parsed.text || undefined,
|
||||
html: parsed.html || undefined,
|
||||
attachments: inlineAttachments,
|
||||
headers: additionalHeaders,
|
||||
};
|
||||
|
||||
const composer = new MailComposer(mailOptions);
|
||||
const builtMessage = composer.compile();
|
||||
const stream = builtMessage.createReadStream();
|
||||
|
||||
return await new Promise<Buffer>((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
stream.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
stream.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
stream.on('error', reject);
|
||||
});
|
||||
} catch (error) {
|
||||
// If stripping fails, return the original buffer unchanged.
|
||||
// Email ingestion should never be blocked by an attachment-stripping failure.
|
||||
logger.warn(
|
||||
{ error },
|
||||
'Failed to strip non-inline attachments from .eml — storing original.'
|
||||
);
|
||||
return emlBuffer;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ export * from './api/middleware/requirePermission';
|
||||
export { db } from './database';
|
||||
export * from './database/schema';
|
||||
export { AuditService } from './services/AuditService';
|
||||
export * from './config'
|
||||
export * from './jobs/queues'
|
||||
export * from './config';
|
||||
export * from './jobs/queues';
|
||||
export { RetentionHook } from './hooks/RetentionHook';
|
||||
export { IntegrityService } from './services/IntegrityService';
|
||||
|
||||
@@ -2,7 +2,8 @@ import { Job } from 'bullmq';
|
||||
import { IngestionService } from '../../services/IngestionService';
|
||||
import { IContinuousSyncJob } from '@open-archiver/types';
|
||||
import { EmailProviderFactory } from '../../services/EmailProviderFactory';
|
||||
import { flowProducer } from '../queues';
|
||||
import { ingestionQueue } from '../queues';
|
||||
import { SyncSessionService } from '../../services/SyncSessionService';
|
||||
import { logger } from '../../config/logger';
|
||||
|
||||
export default async (job: Job<IContinuousSyncJob>) => {
|
||||
@@ -26,50 +27,54 @@ export default async (job: Job<IContinuousSyncJob>) => {
|
||||
const connector = EmailProviderFactory.createConnector(source);
|
||||
|
||||
try {
|
||||
const jobs = [];
|
||||
// Phase 1: Collect user emails (async generator — no full buffering of job descriptors).
|
||||
// We need the total count before creating the session so the counter is correct.
|
||||
const userEmails: string[] = [];
|
||||
for await (const user of connector.listAllUsers()) {
|
||||
if (user.primaryEmail) {
|
||||
jobs.push({
|
||||
name: 'process-mailbox',
|
||||
queueName: 'ingestion',
|
||||
data: {
|
||||
ingestionSourceId: source.id,
|
||||
userEmail: user.primaryEmail,
|
||||
},
|
||||
opts: {
|
||||
removeOnComplete: {
|
||||
age: 60 * 10, // 10 minutes
|
||||
},
|
||||
removeOnFail: {
|
||||
age: 60 * 30, // 30 minutes
|
||||
},
|
||||
timeout: 1000 * 60 * 30, // 30 minutes
|
||||
},
|
||||
});
|
||||
userEmails.push(user.primaryEmail);
|
||||
}
|
||||
}
|
||||
// }
|
||||
|
||||
if (jobs.length > 0) {
|
||||
await flowProducer.add({
|
||||
name: 'sync-cycle-finished',
|
||||
queueName: 'ingestion',
|
||||
data: {
|
||||
ingestionSourceId,
|
||||
isInitialImport: false,
|
||||
},
|
||||
children: jobs,
|
||||
opts: {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
},
|
||||
if (userEmails.length === 0) {
|
||||
logger.info(
|
||||
{ ingestionSourceId },
|
||||
'No users found during continuous sync, marking active.'
|
||||
);
|
||||
await IngestionService.update(ingestionSourceId, {
|
||||
status: 'active',
|
||||
lastSyncFinishedAt: new Date(),
|
||||
lastSyncStatusMessage: 'Continuous sync complete. No users found.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Phase 2: Create a session BEFORE dispatching any jobs.
|
||||
const sessionId = await SyncSessionService.create(
|
||||
ingestionSourceId,
|
||||
userEmails.length,
|
||||
false
|
||||
);
|
||||
|
||||
logger.info(
|
||||
{ ingestionSourceId, userCount: userEmails.length, sessionId },
|
||||
'Dispatching process-mailbox jobs for continuous sync'
|
||||
);
|
||||
|
||||
// Phase 3: Enqueue individual process-mailbox jobs one at a time.
|
||||
// No FlowProducer — each job carries the sessionId for DB-based coordination.
|
||||
for (const userEmail of userEmails) {
|
||||
await ingestionQueue.add('process-mailbox', {
|
||||
ingestionSourceId: source.id,
|
||||
userEmail,
|
||||
sessionId,
|
||||
});
|
||||
}
|
||||
|
||||
// The status will be set back to 'active' by the 'sync-cycle-finished' job
|
||||
// once all the mailboxes have been processed.
|
||||
logger.info(
|
||||
{ ingestionSourceId },
|
||||
{ ingestionSourceId, sessionId },
|
||||
'Continuous sync job finished dispatching mailbox jobs.'
|
||||
);
|
||||
} catch (error) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { SearchService } from '../../services/SearchService';
|
||||
import { StorageService } from '../../services/StorageService';
|
||||
import { DatabaseService } from '../../services/DatabaseService';
|
||||
import { PendingEmail } from '@open-archiver/types';
|
||||
import { logger } from '@open-archiver/backend/config/logger';
|
||||
|
||||
const searchService = new SearchService();
|
||||
const storageService = new StorageService();
|
||||
@@ -12,6 +13,6 @@ const indexingService = new IndexingService(databaseService, searchService, stor
|
||||
|
||||
export default async function (job: Job<{ emails: PendingEmail[] }>) {
|
||||
const { emails } = job.data;
|
||||
console.log(`Indexing email batch with ${emails.length} emails`);
|
||||
logger.info(`Indexing email batch with ${emails.length} emails`);
|
||||
await indexingService.indexEmailBatch(emails);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Job, FlowChildJob } from 'bullmq';
|
||||
import { Job } from 'bullmq';
|
||||
import { IngestionService } from '../../services/IngestionService';
|
||||
import { IInitialImportJob, IngestionProvider } from '@open-archiver/types';
|
||||
import { IInitialImportJob, IngestionStatus } from '@open-archiver/types';
|
||||
import { EmailProviderFactory } from '../../services/EmailProviderFactory';
|
||||
import { flowProducer } from '../queues';
|
||||
import { ingestionQueue } from '../queues';
|
||||
import { SyncSessionService } from '../../services/SyncSessionService';
|
||||
import { logger } from '../../config/logger';
|
||||
|
||||
export default async (job: Job<IInitialImportJob>) => {
|
||||
@@ -22,66 +23,55 @@ export default async (job: Job<IInitialImportJob>) => {
|
||||
|
||||
const connector = EmailProviderFactory.createConnector(source);
|
||||
|
||||
// if (connector instanceof GoogleWorkspaceConnector || connector instanceof MicrosoftConnector) {
|
||||
const jobs: FlowChildJob[] = [];
|
||||
let userCount = 0;
|
||||
// Phase 1: Collect user emails from the provider (async generator — no full buffering
|
||||
// of FlowChildJob objects). Email strings are tiny (~30 bytes each) compared to the
|
||||
// old FlowChildJob descriptors (~500 bytes each), and we need the count before we can
|
||||
// create the session.
|
||||
const userEmails: string[] = [];
|
||||
for await (const user of connector.listAllUsers()) {
|
||||
if (user.primaryEmail) {
|
||||
jobs.push({
|
||||
name: 'process-mailbox',
|
||||
queueName: 'ingestion',
|
||||
data: {
|
||||
ingestionSourceId,
|
||||
userEmail: user.primaryEmail,
|
||||
},
|
||||
opts: {
|
||||
removeOnComplete: {
|
||||
age: 60 * 10, // 10 minutes
|
||||
},
|
||||
removeOnFail: {
|
||||
age: 60 * 30, // 30 minutes
|
||||
},
|
||||
attempts: 1,
|
||||
// failParentOnFailure: true
|
||||
},
|
||||
});
|
||||
userCount++;
|
||||
userEmails.push(user.primaryEmail);
|
||||
}
|
||||
}
|
||||
|
||||
if (jobs.length > 0) {
|
||||
logger.info(
|
||||
{ ingestionSourceId, userCount },
|
||||
'Adding sync-cycle-finished job to the queue'
|
||||
);
|
||||
await flowProducer.add({
|
||||
name: 'sync-cycle-finished',
|
||||
queueName: 'ingestion',
|
||||
data: {
|
||||
ingestionSourceId,
|
||||
userCount,
|
||||
isInitialImport: true,
|
||||
},
|
||||
children: jobs,
|
||||
opts: {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
if (userEmails.length === 0) {
|
||||
const fileBasedIngestions = IngestionService.returnFileBasedIngestions();
|
||||
const finalStatus = fileBasedIngestions.includes(source.provider)
|
||||
const finalStatus: IngestionStatus = fileBasedIngestions.includes(source.provider)
|
||||
? 'imported'
|
||||
: 'active';
|
||||
// If there are no users, we can consider the import finished and set to active
|
||||
await IngestionService.update(ingestionSourceId, {
|
||||
status: finalStatus,
|
||||
lastSyncFinishedAt: new Date(),
|
||||
lastSyncStatusMessage: 'Initial import complete. No users found.',
|
||||
});
|
||||
logger.info({ ingestionSourceId }, 'No users found, initial import complete');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info({ ingestionSourceId }, 'Finished initial import master job');
|
||||
// Phase 2: Create a session BEFORE dispatching any jobs to avoid a race condition
|
||||
// where a process-mailbox job finishes before the session's totalMailboxes is set.
|
||||
const sessionId = await SyncSessionService.create(
|
||||
ingestionSourceId,
|
||||
userEmails.length,
|
||||
true
|
||||
);
|
||||
|
||||
logger.info(
|
||||
{ ingestionSourceId, userCount: userEmails.length, sessionId },
|
||||
'Dispatching process-mailbox jobs for initial import'
|
||||
);
|
||||
|
||||
// Phase 3: Enqueue individual process-mailbox jobs one at a time.
|
||||
// No FlowProducer, no large atomic Redis write — jobs are enqueued in a loop.
|
||||
for (const userEmail of userEmails) {
|
||||
await ingestionQueue.add('process-mailbox', {
|
||||
ingestionSourceId,
|
||||
userEmail,
|
||||
sessionId,
|
||||
});
|
||||
}
|
||||
|
||||
logger.info({ ingestionSourceId, sessionId }, 'Finished dispatching initial import jobs');
|
||||
} catch (error) {
|
||||
logger.error({ err: error, ingestionSourceId }, 'Error in initial import master job');
|
||||
await IngestionService.update(ingestionSourceId, {
|
||||
|
||||
@@ -1,38 +1,29 @@
|
||||
import { Job } from 'bullmq';
|
||||
import {
|
||||
IProcessMailboxJob,
|
||||
SyncState,
|
||||
ProcessMailboxError,
|
||||
PendingEmail,
|
||||
} from '@open-archiver/types';
|
||||
import { IProcessMailboxJob, ProcessMailboxError, PendingEmail } from '@open-archiver/types';
|
||||
import { IngestionService } from '../../services/IngestionService';
|
||||
import { logger } from '../../config/logger';
|
||||
import { EmailProviderFactory } from '../../services/EmailProviderFactory';
|
||||
import { StorageService } from '../../services/StorageService';
|
||||
import { IndexingService } from '../../services/IndexingService';
|
||||
import { SearchService } from '../../services/SearchService';
|
||||
import { DatabaseService } from '../../services/DatabaseService';
|
||||
import { config } from '../../config';
|
||||
import { indexingQueue } from '../queues';
|
||||
import { indexingQueue, ingestionQueue } from '../queues';
|
||||
import { SyncSessionService } from '../../services/SyncSessionService';
|
||||
|
||||
/**
|
||||
* This processor handles the ingestion of emails for a single user's mailbox.
|
||||
* If an error occurs during processing (e.g., an API failure),
|
||||
* it catches the exception and returns a structured error object instead of throwing.
|
||||
* This prevents a single failed mailbox from halting the entire sync cycle for all users.
|
||||
* The parent 'sync-cycle-finished' job is responsible for inspecting the results of all
|
||||
* 'process-mailbox' jobs, aggregating successes, and reporting detailed failures.
|
||||
* Handles ingestion of emails for a single user's mailbox.
|
||||
*
|
||||
* On completion, it reports its result to SyncSessionService using an atomic DB counter.
|
||||
* If this is the last mailbox job in the session, it dispatches the 'sync-cycle-finished' job.
|
||||
* This replaces the BullMQ FlowProducer parent/child pattern, avoiding the memory and Redis
|
||||
* overhead of loading all children's return values at once.
|
||||
*/
|
||||
export const processMailboxProcessor = async (job: Job<IProcessMailboxJob, SyncState, string>) => {
|
||||
const { ingestionSourceId, userEmail } = job.data;
|
||||
export const processMailboxProcessor = async (job: Job<IProcessMailboxJob>) => {
|
||||
const { ingestionSourceId, userEmail, sessionId } = job.data;
|
||||
const BATCH_SIZE: number = config.meili.indexingBatchSize;
|
||||
let emailBatch: PendingEmail[] = [];
|
||||
|
||||
logger.info({ ingestionSourceId, userEmail }, `Processing mailbox for user`);
|
||||
logger.info({ ingestionSourceId, userEmail, sessionId }, `Processing mailbox for user`);
|
||||
|
||||
const searchService = new SearchService();
|
||||
const storageService = new StorageService();
|
||||
const databaseService = new DatabaseService();
|
||||
|
||||
try {
|
||||
const source = await IngestionService.findById(ingestionSourceId);
|
||||
@@ -43,7 +34,7 @@ export const processMailboxProcessor = async (job: Job<IProcessMailboxJob, SyncS
|
||||
const connector = EmailProviderFactory.createConnector(source);
|
||||
const ingestionService = new IngestionService();
|
||||
|
||||
// Create a callback to check for duplicates without fetching full email content
|
||||
// Pre-check for duplicates without fetching full email content
|
||||
const checkDuplicate = async (messageId: string) => {
|
||||
return await IngestionService.doesEmailExist(messageId, ingestionSourceId);
|
||||
};
|
||||
@@ -65,6 +56,12 @@ export const processMailboxProcessor = async (job: Job<IProcessMailboxJob, SyncS
|
||||
if (emailBatch.length >= BATCH_SIZE) {
|
||||
await indexingQueue.add('index-email-batch', { emails: emailBatch });
|
||||
emailBatch = [];
|
||||
// Heartbeat: a single large mailbox can take hours to process.
|
||||
// Without this, cleanStaleSessions() would see no activity on the
|
||||
// session and incorrectly mark it as stale after 30 minutes.
|
||||
// We piggyback on the existing batch flush cadence — no extra DB
|
||||
// writes beyond what we'd do anyway.
|
||||
await SyncSessionService.heartbeat(sessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -77,8 +74,26 @@ export const processMailboxProcessor = async (job: Job<IProcessMailboxJob, SyncS
|
||||
|
||||
const newSyncState = connector.getUpdatedSyncState(userEmail);
|
||||
logger.info({ ingestionSourceId, userEmail }, `Finished processing mailbox for user`);
|
||||
return newSyncState;
|
||||
|
||||
// Report success to the session and check if this is the last job
|
||||
const { isLast, totalFailed } = await SyncSessionService.recordMailboxResult(
|
||||
sessionId,
|
||||
newSyncState
|
||||
);
|
||||
|
||||
if (isLast) {
|
||||
logger.info(
|
||||
{ ingestionSourceId, sessionId },
|
||||
'Last mailbox job completed, dispatching sync-cycle-finished'
|
||||
);
|
||||
await ingestionQueue.add('sync-cycle-finished', {
|
||||
ingestionSourceId,
|
||||
sessionId,
|
||||
isInitialImport: false,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// Flush any buffered emails before reporting failure
|
||||
if (emailBatch.length > 0) {
|
||||
await indexingQueue.add('index-email-batch', { emails: emailBatch });
|
||||
emailBatch = [];
|
||||
@@ -90,6 +105,33 @@ export const processMailboxProcessor = async (job: Job<IProcessMailboxJob, SyncS
|
||||
error: true,
|
||||
message: `Failed to process mailbox for ${userEmail}: ${errorMessage}`,
|
||||
};
|
||||
return processMailboxError;
|
||||
|
||||
// Report failure to the session — this still counts towards the total
|
||||
try {
|
||||
const { isLast } = await SyncSessionService.recordMailboxResult(
|
||||
sessionId,
|
||||
processMailboxError
|
||||
);
|
||||
|
||||
if (isLast) {
|
||||
logger.info(
|
||||
{ ingestionSourceId, sessionId },
|
||||
'Last mailbox job (with error) completed, dispatching sync-cycle-finished'
|
||||
);
|
||||
await ingestionQueue.add('sync-cycle-finished', {
|
||||
ingestionSourceId,
|
||||
sessionId,
|
||||
isInitialImport: false,
|
||||
});
|
||||
}
|
||||
} catch (sessionError) {
|
||||
logger.error(
|
||||
{ err: sessionError, sessionId },
|
||||
'Failed to record mailbox error in sync session'
|
||||
);
|
||||
}
|
||||
|
||||
// Do not re-throw — a single failed mailbox should not mark the BullMQ job as failed
|
||||
// and trigger retries that would double-count against the session counter.
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3,17 +3,36 @@ import { db } from '../../database';
|
||||
import { ingestionSources } from '../../database/schema';
|
||||
import { or, eq } from 'drizzle-orm';
|
||||
import { ingestionQueue } from '../queues';
|
||||
import { SyncSessionService } from '../../services/SyncSessionService';
|
||||
import { logger } from '../../config/logger';
|
||||
|
||||
export default async (job: Job) => {
|
||||
console.log('Scheduler running: Looking for active or error ingestion sources to sync.');
|
||||
// find all sources that have the status of active or error for continuous syncing.
|
||||
logger.info({}, 'Scheduler running: checking for stale sessions and active sources to sync.');
|
||||
|
||||
// Step 1: Clean up any stale sync sessions from previous crashed runs.
|
||||
// A session is stale when lastActivityAt hasn't been updated in 30 minutes —
|
||||
// meaning no process-mailbox job has reported back, indicating the worker crashed
|
||||
// after creating the session but before all jobs were enqueued.
|
||||
// This sets the associated ingestion source to 'error' so Step 2 picks it up.
|
||||
try {
|
||||
await SyncSessionService.cleanStaleSessions();
|
||||
} catch (error) {
|
||||
// Log but don't abort — stale session cleanup is best-effort
|
||||
logger.error({ err: error }, 'Error during stale session cleanup in scheduler');
|
||||
}
|
||||
|
||||
// Step 2: Find all sources with status 'active' or 'error' for continuous syncing.
|
||||
// Sources previously stuck in 'importing'/'syncing' due to a crash will now appear
|
||||
// as 'error' (set by cleanStaleSessions above) and will be picked up here for retry.
|
||||
const sourcesToSync = await db
|
||||
.select({ id: ingestionSources.id })
|
||||
.from(ingestionSources)
|
||||
.where(or(eq(ingestionSources.status, 'active'), eq(ingestionSources.status, 'error')));
|
||||
|
||||
logger.info({ count: sourcesToSync.length }, 'Dispatching continuous-sync jobs for sources');
|
||||
|
||||
for (const source of sourcesToSync) {
|
||||
// The status field on the ingestion source is used to prevent duplicate syncs.
|
||||
// The status field on the ingestion source prevents duplicate concurrent syncs.
|
||||
await ingestionQueue.add('continuous-sync', { ingestionSourceId: source.id });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,103 +1,74 @@
|
||||
import { Job } from 'bullmq';
|
||||
import { IngestionService } from '../../services/IngestionService';
|
||||
import { SyncSessionService } from '../../services/SyncSessionService';
|
||||
import { logger } from '../../config/logger';
|
||||
import {
|
||||
SyncState,
|
||||
ProcessMailboxError,
|
||||
IngestionStatus,
|
||||
IngestionProvider,
|
||||
} from '@open-archiver/types';
|
||||
import { db } from '../../database';
|
||||
import { ingestionSources } from '../../database/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { deepmerge } from 'deepmerge-ts';
|
||||
import { IngestionStatus } from '@open-archiver/types';
|
||||
|
||||
interface ISyncCycleFinishedJob {
|
||||
ingestionSourceId: string;
|
||||
userCount?: number; // Optional, as it's only relevant for the initial import
|
||||
sessionId: string;
|
||||
isInitialImport: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* This processor runs after all 'process-mailbox' jobs for a sync cycle have completed.
|
||||
* It is responsible for aggregating the results and finalizing the sync status.
|
||||
* It inspects the return values of all child jobs to identify successes and failures.
|
||||
*
|
||||
* If any child jobs returned an error object, this processor will:
|
||||
* 1. Mark the overall ingestion status as 'error'.
|
||||
* 2. Aggregate the detailed error messages from all failed jobs.
|
||||
* 3. Save the sync state from any jobs that *did* succeed, preserving partial progress.
|
||||
*
|
||||
* If all child jobs succeeded, it marks the ingestion as 'active' and saves the final
|
||||
* aggregated sync state from all children.
|
||||
* Finalizes a sync cycle after all process-mailbox jobs have completed.
|
||||
*
|
||||
* This processor no longer uses BullMQ's job.getChildrenValues() or deepmerge.
|
||||
* Instead, it reads the aggregated results from the sync_sessions table in PostgreSQL,
|
||||
* where each process-mailbox job has already atomically recorded its outcome and
|
||||
* incrementally merged its SyncState into ingestion_sources.sync_state.
|
||||
*/
|
||||
export default async (job: Job<ISyncCycleFinishedJob, any, string>) => {
|
||||
const { ingestionSourceId, userCount, isInitialImport } = job.data;
|
||||
export default async (job: Job<ISyncCycleFinishedJob>) => {
|
||||
const { ingestionSourceId, sessionId, isInitialImport } = job.data;
|
||||
|
||||
logger.info(
|
||||
{ ingestionSourceId, userCount, isInitialImport },
|
||||
{ ingestionSourceId, sessionId, isInitialImport },
|
||||
'Sync cycle finished job started'
|
||||
);
|
||||
|
||||
try {
|
||||
const childrenValues = await job.getChildrenValues<SyncState | ProcessMailboxError>();
|
||||
const allChildJobs = Object.values(childrenValues);
|
||||
// if data has error property, it is a failed job
|
||||
const failedJobs = allChildJobs.filter(
|
||||
(v) => v && (v as any).error
|
||||
) as ProcessMailboxError[];
|
||||
// if data doesn't have error property, it is a successful job with SyncState
|
||||
const successfulJobs = allChildJobs.filter((v) => !v || !(v as any).error) as SyncState[];
|
||||
const session = await SyncSessionService.findById(sessionId);
|
||||
|
||||
const finalSyncState = deepmerge(
|
||||
...successfulJobs.filter((s) => s && Object.keys(s).length > 0)
|
||||
) as SyncState;
|
||||
|
||||
const source = await IngestionService.findById(ingestionSourceId);
|
||||
let status: IngestionStatus = 'active';
|
||||
let message: string;
|
||||
|
||||
const fileBasedIngestions = IngestionService.returnFileBasedIngestions();
|
||||
const source = await IngestionService.findById(ingestionSourceId);
|
||||
|
||||
if (fileBasedIngestions.includes(source.provider)) {
|
||||
status = 'imported';
|
||||
}
|
||||
let message: string;
|
||||
|
||||
// Check for a specific rate-limit message from the successful jobs
|
||||
const rateLimitMessage = successfulJobs.find(
|
||||
(j) => j.statusMessage && j.statusMessage.includes('rate limit')
|
||||
)?.statusMessage;
|
||||
|
||||
if (failedJobs.length > 0) {
|
||||
if (session.failedMailboxes > 0) {
|
||||
status = 'error';
|
||||
const errorMessages = failedJobs.map((j) => j.message).join('\n');
|
||||
message = `Sync cycle completed with ${failedJobs.length} error(s):\n${errorMessages}`;
|
||||
const errorMessages = session.errorMessages.join('\n');
|
||||
message = `Sync cycle completed with ${session.failedMailboxes} error(s):\n${errorMessages}`;
|
||||
logger.error(
|
||||
{ ingestionSourceId, errors: errorMessages },
|
||||
{ ingestionSourceId, sessionId, errors: errorMessages },
|
||||
'Sync cycle finished with errors.'
|
||||
);
|
||||
} else if (rateLimitMessage) {
|
||||
message = rateLimitMessage;
|
||||
logger.warn({ ingestionSourceId, message }, 'Sync cycle paused due to rate limiting.');
|
||||
} else {
|
||||
message = 'Continuous sync cycle finished successfully.';
|
||||
if (isInitialImport) {
|
||||
message = `Initial import finished for ${userCount} mailboxes.`;
|
||||
}
|
||||
logger.info({ ingestionSourceId }, 'Successfully updated status and final sync state.');
|
||||
message = isInitialImport
|
||||
? `Initial import finished for ${session.completedMailboxes} mailboxes.`
|
||||
: 'Continuous sync cycle finished successfully.';
|
||||
logger.info({ ingestionSourceId, sessionId }, 'Sync cycle finished successfully.');
|
||||
}
|
||||
|
||||
await db
|
||||
.update(ingestionSources)
|
||||
.set({
|
||||
status,
|
||||
lastSyncFinishedAt: new Date(),
|
||||
lastSyncStatusMessage: message,
|
||||
syncState: finalSyncState,
|
||||
})
|
||||
.where(eq(ingestionSources.id, ingestionSourceId));
|
||||
// syncState was already merged incrementally by each process-mailbox job via
|
||||
// SyncSessionService.recordMailboxResult() — no deepmerge needed here.
|
||||
await IngestionService.update(ingestionSourceId, {
|
||||
status,
|
||||
lastSyncFinishedAt: new Date(),
|
||||
lastSyncStatusMessage: message,
|
||||
});
|
||||
|
||||
// Clean up the session row
|
||||
await SyncSessionService.finalize(sessionId);
|
||||
|
||||
logger.info({ ingestionSourceId, sessionId, status }, 'Sync cycle finalized');
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ err: error, ingestionSourceId },
|
||||
{ err: error, ingestionSourceId, sessionId },
|
||||
'An unexpected error occurred while finalizing the sync cycle.'
|
||||
);
|
||||
await IngestionService.update(ingestionSourceId, {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { Queue, FlowProducer } from 'bullmq';
|
||||
import { Queue } from 'bullmq';
|
||||
import { connection } from '../config/redis';
|
||||
|
||||
export const flowProducer = new FlowProducer({ connection });
|
||||
|
||||
// Default job options
|
||||
const defaultJobOptions = {
|
||||
attempts: 5,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ingestionQueue } from '../queues';
|
||||
|
||||
import { config } from '../../config';
|
||||
import { logger } from '@open-archiver/backend/config/logger';
|
||||
|
||||
const scheduleContinuousSync = async () => {
|
||||
// This job will run every 15 minutes
|
||||
@@ -17,5 +18,5 @@ const scheduleContinuousSync = async () => {
|
||||
};
|
||||
|
||||
scheduleContinuousSync().then(() => {
|
||||
console.log('Continuous sync scheduler started.');
|
||||
logger.info('Continuous sync scheduler started.');
|
||||
});
|
||||
|
||||
@@ -66,5 +66,12 @@
|
||||
},
|
||||
"api": {
|
||||
"requestBodyInvalid": "Invalid request body."
|
||||
},
|
||||
"upload": {
|
||||
"invalid_request": "The upload request is invalid or malformed.",
|
||||
"stream_error": "An error occurred while receiving the file. Please try again.",
|
||||
"parse_error": "Failed to parse the uploaded file data.",
|
||||
"storage_error": "Failed to save the uploaded file to storage. Please try again.",
|
||||
"connection_error": "The connection was lost during the upload."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,16 +31,16 @@ export class ApiKeyService {
|
||||
|
||||
await this.auditService.createAuditLog({
|
||||
actorIdentifier: actor.id,
|
||||
actionType: 'GENERATE',
|
||||
targetType: 'ApiKey',
|
||||
targetId: name,
|
||||
actorIp,
|
||||
details: {
|
||||
keyName: name,
|
||||
},
|
||||
});
|
||||
actionType: 'GENERATE',
|
||||
targetType: 'ApiKey',
|
||||
targetId: name,
|
||||
actorIp,
|
||||
details: {
|
||||
keyName: name,
|
||||
},
|
||||
});
|
||||
|
||||
return key;
|
||||
return key;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -8,13 +8,14 @@ import type {
|
||||
IngestionProvider,
|
||||
PendingEmail,
|
||||
} from '@open-archiver/types';
|
||||
import { and, desc, eq } from 'drizzle-orm';
|
||||
import { and, desc, eq, or } from 'drizzle-orm';
|
||||
import { CryptoService } from './CryptoService';
|
||||
import { EmailProviderFactory } from './EmailProviderFactory';
|
||||
import { ingestionQueue } from '../jobs/queues';
|
||||
import type { JobType } from 'bullmq';
|
||||
import { StorageService } from './StorageService';
|
||||
import type { IInitialImportJob, EmailObject } from '@open-archiver/types';
|
||||
import { stripAttachmentsFromEml } from '../helpers/emlUtils';
|
||||
import {
|
||||
archivedEmails,
|
||||
attachments as attachmentsSchema,
|
||||
@@ -391,8 +392,9 @@ export class IngestionService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Quickly checks if an email exists in the database by its Message-ID header.
|
||||
* This is used to skip downloading duplicate emails during ingestion.
|
||||
* Pre-fetch duplicate check to avoid unnecessary API calls during ingestion.
|
||||
* Checks both providerMessageId (for Google/Microsoft API IDs) and
|
||||
* messageIdHeader (for IMAP/PST/EML/Mbox RFC Message-IDs and pre-migration rows).
|
||||
*/
|
||||
public static async doesEmailExist(
|
||||
messageId: string,
|
||||
@@ -400,12 +402,14 @@ export class IngestionService {
|
||||
): Promise<boolean> {
|
||||
const existingEmail = await db.query.archivedEmails.findFirst({
|
||||
where: and(
|
||||
eq(archivedEmails.messageIdHeader, messageId),
|
||||
eq(archivedEmails.ingestionSourceId, ingestionSourceId)
|
||||
eq(archivedEmails.ingestionSourceId, ingestionSourceId),
|
||||
or(
|
||||
eq(archivedEmails.providerMessageId, messageId),
|
||||
eq(archivedEmails.messageIdHeader, messageId)
|
||||
)
|
||||
),
|
||||
columns: { id: true },
|
||||
});
|
||||
|
||||
return !!existingEmail;
|
||||
}
|
||||
|
||||
@@ -446,7 +450,10 @@ export class IngestionService {
|
||||
return null;
|
||||
}
|
||||
|
||||
const emlBuffer = email.eml ?? Buffer.from(email.body, 'utf-8');
|
||||
const rawEmlBuffer = email.eml ?? Buffer.from(email.body, 'utf-8');
|
||||
// Strip non-inline attachments from the .eml to avoid double-storing
|
||||
// attachment data (attachments are stored separately).
|
||||
const emlBuffer = await stripAttachmentsFromEml(rawEmlBuffer);
|
||||
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`;
|
||||
@@ -459,6 +466,7 @@ export class IngestionService {
|
||||
userEmail,
|
||||
threadId: email.threadId,
|
||||
messageIdHeader: messageId,
|
||||
providerMessageId: email.id,
|
||||
sentAt: email.receivedAt,
|
||||
subject: email.subject,
|
||||
senderName: email.from[0]?.name,
|
||||
|
||||
@@ -28,13 +28,21 @@ export class IntegrityService {
|
||||
const currentEmailHash = createHash('sha256').update(emailBuffer).digest('hex');
|
||||
|
||||
if (currentEmailHash === email.storageHashSha256) {
|
||||
results.push({ type: 'email', id: email.id, isValid: true });
|
||||
results.push({
|
||||
type: 'email',
|
||||
id: email.id,
|
||||
isValid: true,
|
||||
storedHash: email.storageHashSha256,
|
||||
computedHash: currentEmailHash,
|
||||
});
|
||||
} else {
|
||||
results.push({
|
||||
type: 'email',
|
||||
id: email.id,
|
||||
isValid: false,
|
||||
reason: 'Stored hash does not match current hash.',
|
||||
storedHash: email.storageHashSha256,
|
||||
computedHash: currentEmailHash,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -62,6 +70,8 @@ export class IntegrityService {
|
||||
id: attachment.id,
|
||||
filename: attachment.filename,
|
||||
isValid: true,
|
||||
storedHash: attachment.contentHashSha256,
|
||||
computedHash: currentAttachmentHash,
|
||||
});
|
||||
} else {
|
||||
results.push({
|
||||
@@ -70,6 +80,8 @@ export class IntegrityService {
|
||||
filename: attachment.filename,
|
||||
isValid: false,
|
||||
reason: 'Stored hash does not match current hash.',
|
||||
storedHash: attachment.contentHashSha256,
|
||||
computedHash: currentAttachmentHash,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -83,6 +95,8 @@ export class IntegrityService {
|
||||
filename: attachment.filename,
|
||||
isValid: false,
|
||||
reason: 'Could not read attachment file from storage.',
|
||||
storedHash: attachment.contentHashSha256,
|
||||
computedHash: '',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
260
packages/backend/src/services/SyncSessionService.ts
Normal file
260
packages/backend/src/services/SyncSessionService.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
import { db } from '../database';
|
||||
import { syncSessions, ingestionSources } from '../database/schema';
|
||||
import { eq, lt, sql } from 'drizzle-orm';
|
||||
import type { SyncState, ProcessMailboxError } from '@open-archiver/types';
|
||||
import { logger } from '../config/logger';
|
||||
|
||||
export interface SyncSessionRecord {
|
||||
id: string;
|
||||
ingestionSourceId: string;
|
||||
isInitialImport: boolean;
|
||||
totalMailboxes: number;
|
||||
completedMailboxes: number;
|
||||
failedMailboxes: number;
|
||||
errorMessages: string[];
|
||||
createdAt: Date;
|
||||
lastActivityAt: Date;
|
||||
}
|
||||
|
||||
export interface MailboxResultOutcome {
|
||||
/** True if this was the last mailbox job in the session (should trigger finalization) */
|
||||
isLast: boolean;
|
||||
totalCompleted: number;
|
||||
totalFailed: number;
|
||||
errorMessages: string[];
|
||||
}
|
||||
|
||||
export class SyncSessionService {
|
||||
/**
|
||||
* Creates a new sync session for a given ingestion source and returns its ID.
|
||||
* Must be called before any process-mailbox jobs are dispatched.
|
||||
*/
|
||||
public static async create(
|
||||
ingestionSourceId: string,
|
||||
totalMailboxes: number,
|
||||
isInitialImport: boolean
|
||||
): Promise<string> {
|
||||
const [session] = await db
|
||||
.insert(syncSessions)
|
||||
.values({
|
||||
ingestionSourceId,
|
||||
totalMailboxes,
|
||||
isInitialImport,
|
||||
completedMailboxes: 0,
|
||||
failedMailboxes: 0,
|
||||
errorMessages: [],
|
||||
})
|
||||
.returning({ id: syncSessions.id });
|
||||
|
||||
logger.info(
|
||||
{ sessionId: session.id, ingestionSourceId, totalMailboxes, isInitialImport },
|
||||
'Sync session created'
|
||||
);
|
||||
|
||||
return session.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically records the result of a single process-mailbox job.
|
||||
* Increments either completedMailboxes or failedMailboxes depending on the result.
|
||||
* If the result is a successful SyncState, it is merged into the ingestion source's
|
||||
* syncState column using PostgreSQL's jsonb merge operator.
|
||||
*
|
||||
* Returns whether this was the last mailbox job in the session.
|
||||
*/
|
||||
public static async recordMailboxResult(
|
||||
sessionId: string,
|
||||
result: SyncState | ProcessMailboxError
|
||||
): Promise<MailboxResultOutcome> {
|
||||
const isError = (result as ProcessMailboxError).error === true;
|
||||
|
||||
// Atomically increment the appropriate counter and append error message if needed.
|
||||
// The RETURNING clause ensures we get the post-update values to check if this is the last job.
|
||||
const [updated] = await db
|
||||
.update(syncSessions)
|
||||
.set({
|
||||
completedMailboxes: isError
|
||||
? syncSessions.completedMailboxes
|
||||
: sql`${syncSessions.completedMailboxes} + 1`,
|
||||
failedMailboxes: isError
|
||||
? sql`${syncSessions.failedMailboxes} + 1`
|
||||
: syncSessions.failedMailboxes,
|
||||
errorMessages: isError
|
||||
? sql`array_append(${syncSessions.errorMessages}, ${(result as ProcessMailboxError).message})`
|
||||
: syncSessions.errorMessages,
|
||||
// Touch lastActivityAt on every result so the stale-session detector
|
||||
// knows this session is still alive, regardless of how long it has been running.
|
||||
lastActivityAt: new Date(),
|
||||
})
|
||||
.where(eq(syncSessions.id, sessionId))
|
||||
.returning({
|
||||
completedMailboxes: syncSessions.completedMailboxes,
|
||||
failedMailboxes: syncSessions.failedMailboxes,
|
||||
totalMailboxes: syncSessions.totalMailboxes,
|
||||
errorMessages: syncSessions.errorMessages,
|
||||
ingestionSourceId: syncSessions.ingestionSourceId,
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
throw new Error(`Sync session ${sessionId} not found when recording mailbox result.`);
|
||||
}
|
||||
|
||||
// If the result is a successful SyncState with actual content, merge it into the
|
||||
// ingestion source's syncState column using PostgreSQL's || jsonb merge operator.
|
||||
// This is done incrementally per mailbox to avoid the large deepmerge at the end.
|
||||
if (!isError) {
|
||||
const syncState = result as SyncState;
|
||||
if (Object.keys(syncState).length > 0) {
|
||||
await db
|
||||
.update(ingestionSources)
|
||||
.set({
|
||||
syncState: sql`COALESCE(${ingestionSources.syncState}, '{}'::jsonb) || ${JSON.stringify(syncState)}::jsonb`,
|
||||
})
|
||||
.where(eq(ingestionSources.id, updated.ingestionSourceId));
|
||||
}
|
||||
}
|
||||
|
||||
const totalProcessed = updated.completedMailboxes + updated.failedMailboxes;
|
||||
const isLast = totalProcessed >= updated.totalMailboxes;
|
||||
|
||||
logger.info(
|
||||
{
|
||||
sessionId,
|
||||
completed: updated.completedMailboxes,
|
||||
failed: updated.failedMailboxes,
|
||||
total: updated.totalMailboxes,
|
||||
isLast,
|
||||
},
|
||||
'Mailbox result recorded'
|
||||
);
|
||||
|
||||
return {
|
||||
isLast,
|
||||
totalCompleted: updated.completedMailboxes,
|
||||
totalFailed: updated.failedMailboxes,
|
||||
errorMessages: updated.errorMessages,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a sync session by its ID.
|
||||
*/
|
||||
public static async findById(sessionId: string): Promise<SyncSessionRecord> {
|
||||
const [session] = await db
|
||||
.select()
|
||||
.from(syncSessions)
|
||||
.where(eq(syncSessions.id, sessionId));
|
||||
|
||||
if (!session) {
|
||||
throw new Error(`Sync session ${sessionId} not found.`);
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates lastActivityAt for the session without changing any counters.
|
||||
* Should be called periodically during a long-running process-mailbox job
|
||||
* to prevent cleanStaleSessions() from incorrectly treating an actively
|
||||
* processing mailbox as stale.
|
||||
*
|
||||
*/
|
||||
public static async heartbeat(sessionId: string): Promise<void> {
|
||||
try {
|
||||
logger.info('heatbeat, ', sessionId);
|
||||
await db
|
||||
.update(syncSessions)
|
||||
.set({ lastActivityAt: new Date() })
|
||||
.where(eq(syncSessions.id, sessionId));
|
||||
} catch (error) {
|
||||
logger.warn({ err: error, sessionId }, 'Failed to update session heartbeat');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a sync session after finalization to keep the table clean.
|
||||
*/
|
||||
public static async finalize(sessionId: string): Promise<void> {
|
||||
await db.delete(syncSessions).where(eq(syncSessions.id, sessionId));
|
||||
logger.info({ sessionId }, 'Sync session finalized and deleted');
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds all sync sessions that are stale and marks the associated ingestion source
|
||||
* as 'error', then deletes the orphaned session row.
|
||||
*
|
||||
* Staleness is determined by lastActivityAt — the timestamp updated every time a
|
||||
* process-mailbox job reports a result. This correctly handles large imports that run
|
||||
* for many hours: as long as mailboxes are actively completing, lastActivityAt stays
|
||||
* fresh and the session is never considered stale.
|
||||
*
|
||||
* A session is stale when:
|
||||
* completedMailboxes + failedMailboxes < totalMailboxes
|
||||
* AND lastActivityAt < (now - thresholdMs)
|
||||
*
|
||||
* Default threshold: 30 minutes of inactivity. This covers the crash scenario where
|
||||
* the processor died after creating the session but before all process-mailbox jobs
|
||||
* were enqueued — those jobs will never report back, causing permanent inactivity.
|
||||
*
|
||||
* Once cleaned up, the source is set to 'error' so the next scheduler tick will
|
||||
* re-queue a continuous-sync job.
|
||||
*/
|
||||
public static async cleanStaleSessions(
|
||||
thresholdMs: number = 30 * 60 * 1000 // 30 minutes of inactivity
|
||||
): Promise<void> {
|
||||
const cutoffTime = new Date(Date.now() - thresholdMs);
|
||||
|
||||
// Find sessions with no recent activity (regardless of how old they are)
|
||||
const staleSessions = await db
|
||||
.select()
|
||||
.from(syncSessions)
|
||||
.where(lt(syncSessions.lastActivityAt, cutoffTime));
|
||||
|
||||
for (const session of staleSessions) {
|
||||
const totalProcessed = session.completedMailboxes + session.failedMailboxes;
|
||||
if (totalProcessed >= session.totalMailboxes) {
|
||||
// Session finished but was never finalized (e.g., sync-cycle-finished job
|
||||
// was lost) — clean it up silently without touching the source status.
|
||||
await db.delete(syncSessions).where(eq(syncSessions.id, session.id));
|
||||
logger.warn(
|
||||
{ sessionId: session.id, ingestionSourceId: session.ingestionSourceId },
|
||||
'Cleaned up completed-but-unfinalized stale sync session'
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Session is genuinely stuck — no mailbox activity for the threshold period.
|
||||
const inactiveMinutes = Math.round(
|
||||
(Date.now() - session.lastActivityAt.getTime()) / 60000
|
||||
);
|
||||
|
||||
logger.warn(
|
||||
{
|
||||
sessionId: session.id,
|
||||
ingestionSourceId: session.ingestionSourceId,
|
||||
totalMailboxes: session.totalMailboxes,
|
||||
completedMailboxes: session.completedMailboxes,
|
||||
failedMailboxes: session.failedMailboxes,
|
||||
inactiveMinutes,
|
||||
},
|
||||
'Stale sync session detected — marking source as error and cleaning up'
|
||||
);
|
||||
|
||||
await db
|
||||
.update(ingestionSources)
|
||||
.set({
|
||||
status: 'error',
|
||||
lastSyncFinishedAt: new Date(),
|
||||
lastSyncStatusMessage: `Sync interrupted: no activity for ${inactiveMinutes} minutes. ${session.completedMailboxes} of ${session.totalMailboxes} mailboxes completed. Will retry on next sync cycle.`,
|
||||
})
|
||||
.where(eq(ingestionSources.id, session.ingestionSourceId));
|
||||
|
||||
await db.delete(syncSessions).where(eq(syncSessions.id, session.id));
|
||||
|
||||
logger.info(
|
||||
{ sessionId: session.id, ingestionSourceId: session.ingestionSourceId },
|
||||
'Stale sync session cleaned up, source set to error for retry'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,7 +78,9 @@ export class EMLConnector implements IEmailConnector {
|
||||
|
||||
if (!fileExist) {
|
||||
if (this.credentials.localFilePath) {
|
||||
throw Error(`EML Zip file not found at path: ${this.credentials.localFilePath}`);
|
||||
throw Error(
|
||||
`EML Zip file not found at path: ${this.credentials.localFilePath}`
|
||||
);
|
||||
} else {
|
||||
throw Error(
|
||||
'Uploaded EML Zip file not found. The upload may not have finished yet, or it failed.'
|
||||
@@ -256,10 +258,7 @@ export class EMLConnector implements IEmailConnector {
|
||||
}
|
||||
}
|
||||
|
||||
private async parseMessage(
|
||||
input: Buffer | Readable,
|
||||
path: string
|
||||
): Promise<EmailObject> {
|
||||
private async parseMessage(input: Buffer | Readable, path: string): Promise<EmailObject> {
|
||||
let emlBuffer: Buffer;
|
||||
if (Buffer.isBuffer(input)) {
|
||||
emlBuffer = input;
|
||||
|
||||
@@ -225,7 +225,6 @@ export class GoogleWorkspaceConnector implements IEmailConnector {
|
||||
);
|
||||
};
|
||||
const threadId = getThreadId(parsedEmail.headers);
|
||||
console.log('threadId', threadId);
|
||||
yield {
|
||||
id: msgResponse.data.id!,
|
||||
threadId,
|
||||
@@ -348,7 +347,6 @@ export class GoogleWorkspaceConnector implements IEmailConnector {
|
||||
);
|
||||
};
|
||||
const threadId = getThreadId(parsedEmail.headers);
|
||||
console.log('threadId', threadId);
|
||||
yield {
|
||||
id: msgResponse.data.id!,
|
||||
threadId,
|
||||
|
||||
@@ -197,7 +197,7 @@ export class ImapConnector implements IEmailConnector {
|
||||
|
||||
// Only fetch if the mailbox has messages, to avoid errors on empty mailboxes with some IMAP servers.
|
||||
if (mailbox.exists > 0) {
|
||||
const BATCH_SIZE = 250; // A configurable batch size
|
||||
const BATCH_SIZE = 250;
|
||||
let startUid = (lastUid || 0) + 1;
|
||||
const maxUidToFetch = currentMaxUid;
|
||||
|
||||
@@ -205,10 +205,11 @@ export class ImapConnector implements IEmailConnector {
|
||||
const endUid = Math.min(startUid + BATCH_SIZE - 1, maxUidToFetch);
|
||||
const searchCriteria = { uid: `${startUid}:${endUid}` };
|
||||
|
||||
// --- Pass 1: fetch only envelope + uid (no source) for the entire batch.
|
||||
const uidsToFetch: number[] = [];
|
||||
|
||||
for await (const msg of this.client.fetch(searchCriteria, {
|
||||
envelope: true,
|
||||
source: true,
|
||||
bodyStructure: true,
|
||||
uid: true,
|
||||
})) {
|
||||
if (lastUid && msg.uid <= lastUid) {
|
||||
@@ -219,9 +220,13 @@ export class ImapConnector implements IEmailConnector {
|
||||
this.newMaxUids[mailboxPath] = msg.uid;
|
||||
}
|
||||
|
||||
// Optimization: Verify existence using Message-ID from envelope before fetching full body
|
||||
// Duplicate check against the Message-ID from the envelope.
|
||||
// If a duplicate is found we skip fetching the full source entirely,
|
||||
// avoiding loading attachment binary data into memory for known emails.
|
||||
if (checkDuplicate && msg.envelope?.messageId) {
|
||||
const isDuplicate = await checkDuplicate(msg.envelope.messageId);
|
||||
const isDuplicate = await checkDuplicate(
|
||||
msg.envelope.messageId
|
||||
);
|
||||
if (isDuplicate) {
|
||||
logger.debug(
|
||||
{
|
||||
@@ -235,18 +240,42 @@ export class ImapConnector implements IEmailConnector {
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug({ mailboxPath, uid: msg.uid }, 'Processing message');
|
||||
if (msg.envelope) {
|
||||
uidsToFetch.push(msg.uid);
|
||||
}
|
||||
}
|
||||
|
||||
if (msg.envelope && msg.source) {
|
||||
try {
|
||||
yield await this.parseMessage(msg, mailboxPath);
|
||||
} catch (err: any) {
|
||||
logger.error(
|
||||
{ err, mailboxPath, uid: msg.uid },
|
||||
'Failed to parse message'
|
||||
);
|
||||
throw err;
|
||||
// --- Pass 2: fetch full source one message at a time for non-duplicate UIDs.
|
||||
for (const uid of uidsToFetch) {
|
||||
logger.debug(
|
||||
{ mailboxPath, uid },
|
||||
'Fetching full source for message'
|
||||
);
|
||||
|
||||
try {
|
||||
const fullMsg = await this.withRetry(
|
||||
async () =>
|
||||
await this.client.fetchOne(
|
||||
String(uid),
|
||||
{
|
||||
envelope: true,
|
||||
source: true,
|
||||
bodyStructure: true,
|
||||
uid: true,
|
||||
},
|
||||
{ uid: true }
|
||||
)
|
||||
);
|
||||
|
||||
if (fullMsg && fullMsg.envelope && fullMsg.source) {
|
||||
yield await this.parseMessage(fullMsg, mailboxPath);
|
||||
}
|
||||
} catch (err: any) {
|
||||
logger.error(
|
||||
{ err, mailboxPath, uid },
|
||||
'Failed to fetch or parse message'
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -93,10 +93,7 @@ export class MboxConnector implements IEmailConnector {
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ error, credentials: this.credentials },
|
||||
'Mbox file validation failed.'
|
||||
);
|
||||
logger.error({ error, credentials: this.credentials }, 'Mbox file validation failed.');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,7 +136,8 @@ export class MicrosoftConnector implements IEmailConnector {
|
||||
*/
|
||||
public async *fetchEmails(
|
||||
userEmail: string,
|
||||
syncState?: SyncState | null
|
||||
syncState?: SyncState | null,
|
||||
checkDuplicate?: (messageId: string) => Promise<boolean>
|
||||
): AsyncGenerator<EmailObject> {
|
||||
this.newDeltaTokens = syncState?.microsoft?.[userEmail]?.deltaTokens || {};
|
||||
|
||||
@@ -152,7 +153,8 @@ export class MicrosoftConnector implements IEmailConnector {
|
||||
userEmail,
|
||||
folder.id,
|
||||
folder.path,
|
||||
this.newDeltaTokens[folder.id]
|
||||
this.newDeltaTokens[folder.id],
|
||||
checkDuplicate
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -214,7 +216,8 @@ export class MicrosoftConnector implements IEmailConnector {
|
||||
userEmail: string,
|
||||
folderId: string,
|
||||
path: string,
|
||||
deltaToken?: string
|
||||
deltaToken?: string,
|
||||
checkDuplicate?: (messageId: string) => Promise<boolean>
|
||||
): AsyncGenerator<EmailObject> {
|
||||
let requestUrl: string | undefined;
|
||||
|
||||
@@ -235,6 +238,15 @@ export class MicrosoftConnector implements IEmailConnector {
|
||||
|
||||
for (const message of response.value) {
|
||||
if (message.id && !message['@removed']) {
|
||||
// Skip fetching raw content for already-imported messages
|
||||
if (checkDuplicate && (await checkDuplicate(message.id))) {
|
||||
logger.debug(
|
||||
{ messageId: message.id, userEmail },
|
||||
'Skipping duplicate email (pre-check)'
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const rawEmail = await this.getRawEmail(userEmail, message.id);
|
||||
if (rawEmail) {
|
||||
const emailObject = await this.parseEmail(
|
||||
@@ -243,7 +255,7 @@ export class MicrosoftConnector implements IEmailConnector {
|
||||
userEmail,
|
||||
path
|
||||
);
|
||||
emailObject.threadId = message.conversationId; // Add conversationId as threadId
|
||||
emailObject.threadId = message.conversationId;
|
||||
yield emailObject;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,10 +171,7 @@ export class PSTConnector implements IEmailConnector {
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ error, credentials: this.credentials },
|
||||
'PST file validation failed.'
|
||||
);
|
||||
logger.error({ error, credentials: this.credentials }, 'PST file validation failed.');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Worker } from 'bullmq';
|
||||
import { connection } from '../config/redis';
|
||||
import indexEmailBatchProcessor from '../jobs/processors/index-email-batch.processor';
|
||||
import { logger } from '../config/logger';
|
||||
|
||||
const processor = async (job: any) => {
|
||||
switch (job.name) {
|
||||
@@ -21,7 +22,7 @@ const worker = new Worker('indexing', processor, {
|
||||
},
|
||||
});
|
||||
|
||||
console.log('Indexing worker started');
|
||||
logger.info('Indexing worker started');
|
||||
|
||||
process.on('SIGINT', () => worker.close());
|
||||
process.on('SIGTERM', () => worker.close());
|
||||
|
||||
@@ -5,6 +5,7 @@ import continuousSyncProcessor from '../jobs/processors/continuous-sync.processo
|
||||
import scheduleContinuousSyncProcessor from '../jobs/processors/schedule-continuous-sync.processor';
|
||||
import { processMailboxProcessor } from '../jobs/processors/process-mailbox.processor';
|
||||
import syncCycleFinishedProcessor from '../jobs/processors/sync-cycle-finished.processor';
|
||||
import { logger } from '../config/logger';
|
||||
|
||||
const processor = async (job: any) => {
|
||||
switch (job.name) {
|
||||
@@ -25,6 +26,10 @@ const processor = async (job: any) => {
|
||||
|
||||
const worker = new Worker('ingestion', processor, {
|
||||
connection,
|
||||
// Configurable via INGESTION_WORKER_CONCURRENCY env var. Tune based on available RAM.
|
||||
concurrency: process.env.INGESTION_WORKER_CONCURRENCY
|
||||
? parseInt(process.env.INGESTION_WORKER_CONCURRENCY, 10)
|
||||
: 5,
|
||||
removeOnComplete: {
|
||||
count: 100, // keep last 100 jobs
|
||||
},
|
||||
@@ -33,7 +38,7 @@ const worker = new Worker('ingestion', processor, {
|
||||
},
|
||||
});
|
||||
|
||||
console.log('Ingestion worker started');
|
||||
logger.info('Ingestion worker started');
|
||||
|
||||
process.on('SIGINT', () => worker.close());
|
||||
process.on('SIGTERM', () => worker.close());
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import PostalMime, { type Email } from 'postal-mime';
|
||||
import PostalMime, { type Attachment, type Email } from 'postal-mime';
|
||||
import type { Buffer } from 'buffer';
|
||||
import { t } from '$lib/translations';
|
||||
import { encode } from 'html-entities';
|
||||
@@ -13,11 +13,57 @@
|
||||
let parsedEmail: Email | null = $state(null);
|
||||
let isLoading = $state(true);
|
||||
|
||||
/** Converts an ArrayBuffer to a base64-encoded string. */
|
||||
function arrayBufferToBase64(buffer: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.byteLength; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces `cid:` references in HTML with inline base64 data URIs
|
||||
* sourced from the parsed email attachments. This ensures that images
|
||||
* embedded as MIME parts (disposition: inline) render correctly in the
|
||||
* iframe preview.
|
||||
*/
|
||||
function resolveContentIdReferences(html: string, attachments: Attachment[]): string {
|
||||
if (!attachments || attachments.length === 0) return html;
|
||||
|
||||
const cidMap = new Map<string, string>();
|
||||
for (const attachment of attachments) {
|
||||
if (!attachment.contentId) continue;
|
||||
|
||||
const cid = attachment.contentId.replace(/^<|>$/g, '');
|
||||
|
||||
let base64Content: string;
|
||||
if (typeof attachment.content === 'string') {
|
||||
base64Content = attachment.content;
|
||||
} else {
|
||||
base64Content = arrayBufferToBase64(attachment.content);
|
||||
}
|
||||
|
||||
cidMap.set(cid, `data:${attachment.mimeType};base64,${base64Content}`);
|
||||
}
|
||||
|
||||
if (cidMap.size === 0) return html;
|
||||
|
||||
return html.replace(/cid:([^\s"']+)/gi, (match, cid) => {
|
||||
return cidMap.get(cid) ?? match;
|
||||
});
|
||||
}
|
||||
|
||||
// By adding a <base> tag, all relative and absolute links in the HTML document
|
||||
// will open in a new tab by default.
|
||||
let emailHtml = $derived(() => {
|
||||
if (parsedEmail && parsedEmail.html) {
|
||||
return `<base target="_blank" />${parsedEmail.html}`;
|
||||
const resolvedHtml = resolveContentIdReferences(
|
||||
parsedEmail.html,
|
||||
parsedEmail.attachments
|
||||
);
|
||||
return `<base target="_blank" />${resolvedHtml}`;
|
||||
} else if (parsedEmail && parsedEmail.text) {
|
||||
// display raw text email body in html
|
||||
const safeHtmlContent: string = encode(parsedEmail.text);
|
||||
|
||||
@@ -72,7 +72,9 @@
|
||||
let fileUploading = $state(false);
|
||||
|
||||
let importMethod = $state<'upload' | 'local'>(
|
||||
source?.credentials && 'localFilePath' in source.credentials && source.credentials.localFilePath
|
||||
source?.credentials &&
|
||||
'localFilePath' in source.credentials &&
|
||||
source.credentials.localFilePath
|
||||
? 'local'
|
||||
: 'upload'
|
||||
);
|
||||
@@ -119,16 +121,25 @@
|
||||
method: 'POST',
|
||||
body: uploadFormData,
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
// Safely parse the response body — it may not be valid JSON
|
||||
// (e.g. if the proxy rejected the request with an HTML error page)
|
||||
let result: Record<string, string>;
|
||||
try {
|
||||
result = await response.json();
|
||||
} catch {
|
||||
throw new Error($t('app.components.ingestion_source_form.upload_network_error'));
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.message || 'File upload failed');
|
||||
throw new Error(
|
||||
result.message || $t('app.components.ingestion_source_form.upload_failed')
|
||||
);
|
||||
}
|
||||
|
||||
formData.providerConfig.uploadedFilePath = result.filePath;
|
||||
formData.providerConfig.uploadedFileName = file.name;
|
||||
fileUploading = false;
|
||||
} catch (error) {
|
||||
fileUploading = false;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setAlert({
|
||||
type: 'error',
|
||||
@@ -137,6 +148,10 @@
|
||||
duration: 5000,
|
||||
show: true,
|
||||
});
|
||||
// Reset file input so the user can retry with the same file
|
||||
target.value = '';
|
||||
} finally {
|
||||
fileUploading = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -259,15 +274,21 @@
|
||||
</div>
|
||||
{:else if formData.provider === 'pst_import'}
|
||||
<div class="grid grid-cols-4 items-start gap-4">
|
||||
<Label class="text-left pt-2">{$t('app.components.ingestion_source_form.import_method')}</Label>
|
||||
<Label class="pt-2 text-left"
|
||||
>{$t('app.components.ingestion_source_form.import_method')}</Label
|
||||
>
|
||||
<RadioGroup.Root bind:value={importMethod} class="col-span-3 flex flex-col space-y-1">
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="upload" id="pst-upload" />
|
||||
<Label for="pst-upload">{$t('app.components.ingestion_source_form.upload_file')}</Label>
|
||||
<Label for="pst-upload"
|
||||
>{$t('app.components.ingestion_source_form.upload_file')}</Label
|
||||
>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="local" id="pst-local" />
|
||||
<Label for="pst-local">{$t('app.components.ingestion_source_form.local_path')}</Label>
|
||||
<Label for="pst-local"
|
||||
>{$t('app.components.ingestion_source_form.local_path')}</Label
|
||||
>
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
</div>
|
||||
@@ -305,15 +326,21 @@
|
||||
{/if}
|
||||
{:else if formData.provider === 'eml_import'}
|
||||
<div class="grid grid-cols-4 items-start gap-4">
|
||||
<Label class="text-left pt-2">{$t('app.components.ingestion_source_form.import_method')}</Label>
|
||||
<Label class="pt-2 text-left"
|
||||
>{$t('app.components.ingestion_source_form.import_method')}</Label
|
||||
>
|
||||
<RadioGroup.Root bind:value={importMethod} class="col-span-3 flex flex-col space-y-1">
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="upload" id="eml-upload" />
|
||||
<Label for="eml-upload">{$t('app.components.ingestion_source_form.upload_file')}</Label>
|
||||
<Label for="eml-upload"
|
||||
>{$t('app.components.ingestion_source_form.upload_file')}</Label
|
||||
>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="local" id="eml-local" />
|
||||
<Label for="eml-local">{$t('app.components.ingestion_source_form.local_path')}</Label>
|
||||
<Label for="eml-local"
|
||||
>{$t('app.components.ingestion_source_form.local_path')}</Label
|
||||
>
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
</div>
|
||||
@@ -351,15 +378,21 @@
|
||||
{/if}
|
||||
{:else if formData.provider === 'mbox_import'}
|
||||
<div class="grid grid-cols-4 items-start gap-4">
|
||||
<Label class="text-left pt-2">{$t('app.components.ingestion_source_form.import_method')}</Label>
|
||||
<Label class="pt-2 text-left"
|
||||
>{$t('app.components.ingestion_source_form.import_method')}</Label
|
||||
>
|
||||
<RadioGroup.Root bind:value={importMethod} class="col-span-3 flex flex-col space-y-1">
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="upload" id="mbox-upload" />
|
||||
<Label for="mbox-upload">{$t('app.components.ingestion_source_form.upload_file')}</Label>
|
||||
<Label for="mbox-upload"
|
||||
>{$t('app.components.ingestion_source_form.upload_file')}</Label
|
||||
>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="local" id="mbox-local" />
|
||||
<Label for="mbox-local">{$t('app.components.ingestion_source_form.local_path')}</Label>
|
||||
<Label for="mbox-local"
|
||||
>{$t('app.components.ingestion_source_form.local_path')}</Label
|
||||
>
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
</div>
|
||||
|
||||
@@ -47,18 +47,14 @@
|
||||
let isEnabled = $state(policy?.isActive ?? true);
|
||||
|
||||
// Conditions state
|
||||
let logicalOperator = $state<LogicalOperator>(
|
||||
policy?.conditions?.logicalOperator ?? 'AND'
|
||||
);
|
||||
let logicalOperator = $state<LogicalOperator>(policy?.conditions?.logicalOperator ?? 'AND');
|
||||
let rules = $state<RetentionRule[]>(
|
||||
policy?.conditions?.rules ? [...policy.conditions.rules] : []
|
||||
);
|
||||
|
||||
// Ingestion scope: set of selected ingestion source IDs
|
||||
// Empty set = null scope = applies to all
|
||||
let selectedIngestionIds = $state<Set<string>>(
|
||||
new Set(policy?.ingestionScope ?? [])
|
||||
);
|
||||
let selectedIngestionIds = $state<Set<string>>(new Set(policy?.ingestionScope ?? []));
|
||||
|
||||
// The conditions JSON that gets sent as a hidden form field
|
||||
const conditionsJson = $derived(JSON.stringify({ logicalOperator, rules }));
|
||||
@@ -202,11 +198,7 @@
|
||||
|
||||
<!-- Enabled toggle — value written to hidden input above -->
|
||||
<div class="flex items-center gap-3">
|
||||
<Switch
|
||||
id="rp-enabled"
|
||||
checked={isEnabled}
|
||||
onCheckedChange={(v) => (isEnabled = v)}
|
||||
/>
|
||||
<Switch id="rp-enabled" checked={isEnabled} onCheckedChange={(v) => (isEnabled = v)} />
|
||||
<Label for="rp-enabled">{$t('app.retention_policies.active')}</Label>
|
||||
</div>
|
||||
|
||||
@@ -310,7 +302,8 @@
|
||||
onValueChange={(v) => updateRuleOperator(i, v as ConditionOperator)}
|
||||
>
|
||||
<Select.Trigger class="h-8 flex-1 text-xs">
|
||||
{operatorOptions.find((o) => o.value === rule.operator)?.label ?? rule.operator}
|
||||
{operatorOptions.find((o) => o.value === rule.operator)?.label ??
|
||||
rule.operator}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each operatorOptions as opt}
|
||||
|
||||
@@ -35,6 +35,9 @@
|
||||
"cancel": "Cancel",
|
||||
"not_found": "Email not found.",
|
||||
"integrity_report": "Integrity Report",
|
||||
"download_integrity_report_pdf": "Download Integrity Report (PDF)",
|
||||
"downloading_integrity_report": "Generating...",
|
||||
"integrity_report_download_error": "Failed to generate the integrity report.",
|
||||
"email_eml": "Email (.eml)",
|
||||
"valid": "Valid",
|
||||
"invalid": "Invalid",
|
||||
@@ -229,7 +232,8 @@
|
||||
"mbox_file": "Mbox File",
|
||||
"heads_up": "Heads up!",
|
||||
"org_wide_warning": "Please note that this is an organization-wide operation. This kind of ingestions will import and index <b>all</b> email inboxes in your organization. If you want to import only specific email inboxes, use the IMAP connector.",
|
||||
"upload_failed": "Upload Failed, please try again"
|
||||
"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."
|
||||
},
|
||||
"role_form": {
|
||||
"policies_json": "Policies (JSON)",
|
||||
|
||||
@@ -12,7 +12,7 @@ import nl from './nl.json';
|
||||
import ja from './ja.json';
|
||||
import et from './et.json';
|
||||
import el from './el.json';
|
||||
import bg from './bg.json'
|
||||
import bg from './bg.json';
|
||||
// This is your config object.
|
||||
// It defines the languages and how to load them.
|
||||
const config: Config = {
|
||||
|
||||
@@ -1,400 +1,400 @@
|
||||
{
|
||||
"app": {
|
||||
"auth": {
|
||||
"login": "Accedi",
|
||||
"login_tip": "Inserisci la tua email qui sotto per accedere al tuo account.",
|
||||
"email": "Email",
|
||||
"password": "Password"
|
||||
},
|
||||
"common": {
|
||||
"working": "In corso",
|
||||
"read_docs": "Leggi la documentazione"
|
||||
},
|
||||
"archive": {
|
||||
"title": "Archivio",
|
||||
"no_subject": "Nessun oggetto",
|
||||
"from": "Da",
|
||||
"sent": "Inviato",
|
||||
"recipients": "Destinatari",
|
||||
"to": "A",
|
||||
"meta_data": "Metadati",
|
||||
"folder": "Cartella",
|
||||
"tags": "Tag",
|
||||
"size": "Dimensione",
|
||||
"email_preview": "Anteprima email",
|
||||
"attachments": "Allegati",
|
||||
"download": "Scarica",
|
||||
"actions": "Azioni",
|
||||
"download_eml": "Scarica Email (.eml)",
|
||||
"delete_email": "Elimina Email",
|
||||
"email_thread": "Thread Email",
|
||||
"delete_confirmation_title": "Sei sicuro di voler eliminare questa email?",
|
||||
"delete_confirmation_description": "Questa azione non può essere annullata e rimuoverà definitivamente l'email e i suoi allegati.",
|
||||
"deleting": "Eliminazione in corso",
|
||||
"confirm": "Conferma",
|
||||
"cancel": "Annulla",
|
||||
"not_found": "Email non trovata.",
|
||||
"integrity_report": "Rapporto di integrità",
|
||||
"email_eml": "Email (.eml)",
|
||||
"valid": "Valido",
|
||||
"invalid": "Non valido",
|
||||
"integrity_check_failed_title": "Controllo di integrità non riuscito",
|
||||
"integrity_check_failed_message": "Impossibile verificare l'integrità dell'email e dei suoi allegati.",
|
||||
"integrity_report_description": "Questo rapporto verifica che il contenuto delle tue email archiviate non sia stato alterato."
|
||||
},
|
||||
"ingestions": {
|
||||
"title": "Fonti di acquisizione",
|
||||
"ingestion_sources": "Fonti di acquisizione",
|
||||
"bulk_actions": "Azioni di massa",
|
||||
"force_sync": "Forza sincronizzazione",
|
||||
"delete": "Elimina",
|
||||
"create_new": "Crea nuovo",
|
||||
"name": "Nome",
|
||||
"provider": "Provider",
|
||||
"status": "Stato",
|
||||
"active": "Attivo",
|
||||
"created_at": "Creato il",
|
||||
"actions": "Azioni",
|
||||
"last_sync_message": "Ultimo messaggio di sincronizzazione",
|
||||
"empty": "Vuoto",
|
||||
"open_menu": "Apri menu",
|
||||
"edit": "Modifica",
|
||||
"create": "Crea",
|
||||
"ingestion_source": "Fonte di acquisizione",
|
||||
"edit_description": "Apporta modifiche alla tua fonte di acquisizione qui.",
|
||||
"create_description": "Aggiungi una nuova fonte di acquisizione per iniziare ad archiviare le email.",
|
||||
"read": "Leggi",
|
||||
"docs_here": "documentazione qui",
|
||||
"delete_confirmation_title": "Sei sicuro di voler eliminare questa acquisizione?",
|
||||
"delete_confirmation_description": "Questo cancellerà tutte le email archiviate, gli allegati, l'indicizzazione e i file associati a questa acquisizione. Se vuoi solo interrompere la sincronizzazione di nuove email, puoi invece mettere in pausa l'acquisizione.",
|
||||
"deleting": "Eliminazione in corso",
|
||||
"confirm": "Conferma",
|
||||
"cancel": "Annulla",
|
||||
"bulk_delete_confirmation_title": "Sei sicuro di voler eliminare {{count}} acquisizioni selezionate?",
|
||||
"bulk_delete_confirmation_description": "Questo cancellerà tutte le email archiviate, gli allegati, l'indicizzazione e i file associati a queste acquisizioni. Se vuoi solo interrompere la sincronizzazione di nuove email, puoi invece mettere in pausa le acquisizioni."
|
||||
},
|
||||
"search": {
|
||||
"title": "Cerca",
|
||||
"description": "Cerca email archiviate.",
|
||||
"email_search": "Ricerca email",
|
||||
"placeholder": "Cerca per parola chiave, mittente, destinatario...",
|
||||
"search_button": "Cerca",
|
||||
"search_options": "Opzioni di ricerca",
|
||||
"strategy_fuzzy": "Approssimativa",
|
||||
"strategy_verbatim": "Testuale",
|
||||
"strategy_frequency": "Frequenza",
|
||||
"select_strategy": "Seleziona una strategia",
|
||||
"error": "Errore",
|
||||
"found_results_in": "Trovati {{total}} risultati in {{seconds}}s",
|
||||
"found_results": "Trovati {{total}} risultati",
|
||||
"from": "Da",
|
||||
"to": "A",
|
||||
"in_email_body": "Nel corpo dell'email",
|
||||
"in_attachment": "Nell'allegato: {{filename}}",
|
||||
"prev": "Prec",
|
||||
"next": "Succ"
|
||||
},
|
||||
"roles": {
|
||||
"title": "Gestione ruoli",
|
||||
"role_management": "Gestione ruoli",
|
||||
"create_new": "Crea nuovo",
|
||||
"name": "Nome",
|
||||
"created_at": "Creato il",
|
||||
"actions": "Azioni",
|
||||
"open_menu": "Apri menu",
|
||||
"view_policy": "Visualizza Policy",
|
||||
"edit": "Modifica",
|
||||
"delete": "Elimina",
|
||||
"no_roles_found": "Nessun ruolo trovato.",
|
||||
"role_policy": "Policy del ruolo",
|
||||
"viewing_policy_for_role": "Visualizzazione della policy per il ruolo: {{name}}",
|
||||
"create": "Crea",
|
||||
"role": "Ruolo",
|
||||
"edit_description": "Apporta modifiche al ruolo qui.",
|
||||
"create_description": "Aggiungi un nuovo ruolo al sistema.",
|
||||
"delete_confirmation_title": "Sei sicuro di voler eliminare questo ruolo?",
|
||||
"delete_confirmation_description": "Questa azione non può essere annullata. Questo cancellerà definitivamente il ruolo.",
|
||||
"deleting": "Eliminazione in corso",
|
||||
"confirm": "Conferma",
|
||||
"cancel": "Annulla"
|
||||
},
|
||||
"account": {
|
||||
"title": "Impostazioni account",
|
||||
"description": "Gestisci il tuo profilo e le impostazioni di sicurezza.",
|
||||
"personal_info": "Informazioni personali",
|
||||
"personal_info_desc": "Aggiorna i tuoi dati personali.",
|
||||
"security": "Sicurezza",
|
||||
"security_desc": "Gestisci la tua password e le preferenze di sicurezza.",
|
||||
"edit_profile": "Modifica profilo",
|
||||
"change_password": "Cambia password",
|
||||
"edit_profile_desc": "Apporta modifiche al tuo profilo qui.",
|
||||
"change_password_desc": "Cambia la tua password. Dovrai inserire la tua password attuale.",
|
||||
"current_password": "Password attuale",
|
||||
"new_password": "Nuova password",
|
||||
"confirm_new_password": "Conferma nuova password",
|
||||
"operation_successful": "Operazione riuscita",
|
||||
"passwords_do_not_match": "Le password non corrispondono"
|
||||
},
|
||||
"system_settings": {
|
||||
"title": "Impostazioni di sistema",
|
||||
"system_settings": "Impostazioni di sistema",
|
||||
"description": "Gestisci le impostazioni globali dell'applicazione.",
|
||||
"language": "Lingua",
|
||||
"default_theme": "Tema predefinito",
|
||||
"light": "Chiaro",
|
||||
"dark": "Scuro",
|
||||
"system": "Sistema",
|
||||
"support_email": "Email di supporto",
|
||||
"saving": "Salvataggio in corso",
|
||||
"save_changes": "Salva modifiche"
|
||||
},
|
||||
"users": {
|
||||
"title": "Gestione utenti",
|
||||
"user_management": "Gestione utenti",
|
||||
"create_new": "Crea nuovo",
|
||||
"name": "Nome",
|
||||
"email": "Email",
|
||||
"role": "Ruolo",
|
||||
"created_at": "Creato il",
|
||||
"actions": "Azioni",
|
||||
"open_menu": "Apri menu",
|
||||
"edit": "Modifica",
|
||||
"delete": "Elimina",
|
||||
"no_users_found": "Nessun utente trovato.",
|
||||
"create": "Crea",
|
||||
"user": "Utente",
|
||||
"edit_description": "Apporta modifiche all'utente qui.",
|
||||
"create_description": "Aggiungi un nuovo utente al sistema.",
|
||||
"delete_confirmation_title": "Sei sicuro di voler eliminare questo utente?",
|
||||
"delete_confirmation_description": "Questa azione non può essere annullata. Questo cancellerà definitivamente l'utente e rimuoverà i suoi dati dai nostri server.",
|
||||
"deleting": "Eliminazione in corso",
|
||||
"confirm": "Conferma",
|
||||
"cancel": "Annulla"
|
||||
},
|
||||
"components": {
|
||||
"charts": {
|
||||
"emails_ingested": "Email acquisite",
|
||||
"storage_used": "Spazio di archiviazione utilizzato",
|
||||
"emails": "Email"
|
||||
},
|
||||
"common": {
|
||||
"submitting": "Invio in corso...",
|
||||
"submit": "Invia",
|
||||
"save": "Salva"
|
||||
},
|
||||
"email_preview": {
|
||||
"loading": "Caricamento anteprima email...",
|
||||
"render_error": "Impossibile visualizzare l'anteprima dell'email.",
|
||||
"not_available": "File .eml grezzo non disponibile per questa email."
|
||||
},
|
||||
"footer": {
|
||||
"all_rights_reserved": "Tutti i diritti riservati.",
|
||||
"new_version_available": "Nuova versione disponibile"
|
||||
},
|
||||
"ingestion_source_form": {
|
||||
"provider_generic_imap": "IMAP generico",
|
||||
"provider_google_workspace": "Google Workspace",
|
||||
"provider_microsoft_365": "Microsoft 365",
|
||||
"provider_pst_import": "Importazione PST",
|
||||
"provider_eml_import": "Importazione EML",
|
||||
"provider_mbox_import": "Importazione Mbox",
|
||||
"select_provider": "Seleziona un provider",
|
||||
"service_account_key": "Chiave dell'account di servizio (JSON)",
|
||||
"service_account_key_placeholder": "Incolla il contenuto JSON della chiave del tuo account di servizio",
|
||||
"impersonated_admin_email": "Email dell'amministratore impersonato",
|
||||
"client_id": "ID applicazione (client)",
|
||||
"client_secret": "Valore del segreto client",
|
||||
"client_secret_placeholder": "Inserisci il valore segreto, non l'ID segreto",
|
||||
"tenant_id": "ID directory (tenant)",
|
||||
"host": "Host",
|
||||
"port": "Porta",
|
||||
"username": "Nome utente",
|
||||
"use_tls": "Usa TLS",
|
||||
"allow_insecure_cert": "Consenti certificato non sicuro",
|
||||
"pst_file": "File PST",
|
||||
"eml_file": "File EML",
|
||||
"mbox_file": "File Mbox",
|
||||
"heads_up": "Attenzione!",
|
||||
"org_wide_warning": "Tieni presente che questa è un'operazione a livello di organizzazione. Questo tipo di acquisizione importerà e indicizzerà <b>tutte</b> le caselle di posta nella tua organizzazione. Se vuoi importare solo caselle di posta specifiche, usa il connettore IMAP.",
|
||||
"upload_failed": "Caricamento non riuscito, riprova"
|
||||
},
|
||||
"role_form": {
|
||||
"policies_json": "Policy (JSON)",
|
||||
"invalid_json": "Formato JSON non valido per le policy."
|
||||
},
|
||||
"theme_switcher": {
|
||||
"toggle_theme": "Attiva/disattiva tema"
|
||||
},
|
||||
"user_form": {
|
||||
"select_role": "Seleziona un ruolo"
|
||||
}
|
||||
},
|
||||
"setup": {
|
||||
"title": "Configurazione",
|
||||
"description": "Configura l'account amministratore iniziale per Open Archiver.",
|
||||
"welcome": "Benvenuto",
|
||||
"create_admin_account": "Crea il primo account amministratore per iniziare.",
|
||||
"first_name": "Nome",
|
||||
"last_name": "Cognome",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"creating_account": "Creazione account",
|
||||
"create_account": "Crea account"
|
||||
},
|
||||
"layout": {
|
||||
"dashboard": "Dashboard",
|
||||
"ingestions": "Acquisizioni",
|
||||
"archived_emails": "Email archiviate",
|
||||
"search": "Cerca",
|
||||
"settings": "Impostazioni",
|
||||
"system": "Sistema",
|
||||
"users": "Utenti",
|
||||
"roles": "Ruoli",
|
||||
"api_keys": "Chiavi API",
|
||||
"account": "Account",
|
||||
"logout": "Disconnetti",
|
||||
"admin": "Amministratore"
|
||||
},
|
||||
"api_keys_page": {
|
||||
"title": "Chiavi API",
|
||||
"header": "Chiavi API",
|
||||
"generate_new_key": "Genera nuova chiave",
|
||||
"name": "Nome",
|
||||
"key": "Chiave",
|
||||
"expires_at": "Scade il",
|
||||
"created_at": "Creato il",
|
||||
"actions": "Azioni",
|
||||
"delete": "Elimina",
|
||||
"no_keys_found": "Nessuna chiave API trovata.",
|
||||
"generate_modal_title": "Genera nuova chiave API",
|
||||
"generate_modal_description": "Fornisci un nome e una scadenza per la tua nuova chiave API.",
|
||||
"expires_in": "Scade tra",
|
||||
"select_expiration": "Seleziona una scadenza",
|
||||
"30_days": "30 giorni",
|
||||
"60_days": "60 giorni",
|
||||
"6_months": "6 mesi",
|
||||
"12_months": "12 mesi",
|
||||
"24_months": "24 mesi",
|
||||
"generate": "Genera",
|
||||
"new_api_key": "Nuova chiave API",
|
||||
"failed_to_delete": "Impossibile eliminare la chiave API",
|
||||
"api_key_deleted": "Chiave API eliminata",
|
||||
"generated_title": "Chiave API generata",
|
||||
"generated_message": "La tua chiave API è stata generata, copiala e salvala in un luogo sicuro. Questa chiave verrà mostrata solo una volta."
|
||||
},
|
||||
"archived_emails_page": {
|
||||
"title": "Email archiviate",
|
||||
"header": "Email archiviate",
|
||||
"select_ingestion_source": "Seleziona una fonte di acquisizione",
|
||||
"date": "Data",
|
||||
"subject": "Oggetto",
|
||||
"sender": "Mittente",
|
||||
"inbox": "Posta in arrivo",
|
||||
"path": "Percorso",
|
||||
"actions": "Azioni",
|
||||
"view": "Visualizza",
|
||||
"no_emails_found": "Nessuna email archiviata trovata.",
|
||||
"prev": "Prec",
|
||||
"next": "Succ"
|
||||
},
|
||||
"dashboard_page": {
|
||||
"title": "Dashboard",
|
||||
"meta_description": "Panoramica del tuo archivio email.",
|
||||
"header": "Dashboard",
|
||||
"create_ingestion": "Crea un'acquisizione",
|
||||
"no_ingestion_header": "Non hai configurato alcuna fonte di acquisizione.",
|
||||
"no_ingestion_text": "Aggiungi una fonte di acquisizione per iniziare ad archiviare le tue caselle di posta.",
|
||||
"total_emails_archived": "Email totali archiviate",
|
||||
"total_storage_used": "Spazio di archiviazione totale utilizzato",
|
||||
"failed_ingestions": "Acquisizioni non riuscite (ultimi 7 giorni)",
|
||||
"ingestion_history": "Cronologia acquisizioni",
|
||||
"no_ingestion_history": "Nessuna cronologia acquisizioni disponibile.",
|
||||
"storage_by_source": "Spazio di archiviazione per fonte di acquisizione",
|
||||
"no_ingestion_sources": "Nessuna fonte di acquisizione disponibile.",
|
||||
"indexed_insights": "Informazioni indicizzate",
|
||||
"top_10_senders": "I 10 mittenti principali",
|
||||
"no_indexed_insights": "Nessuna informazione indicizzata disponibile."
|
||||
},
|
||||
"audit_log": {
|
||||
"title": "Registro di audit",
|
||||
"header": "Registro di audit",
|
||||
"verify_integrity": "Verifica l'integrità del registro",
|
||||
"log_entries": "Voci di registro",
|
||||
"timestamp": "Timestamp",
|
||||
"actor": "Attore",
|
||||
"action": "Azione",
|
||||
"target": "Obiettivo",
|
||||
"details": "Dettagli",
|
||||
"ip_address": "Indirizzo IP",
|
||||
"target_type": "Tipo di obiettivo",
|
||||
"target_id": "ID obiettivo",
|
||||
"no_logs_found": "Nessun registro di audit trovato.",
|
||||
"prev": "Prec",
|
||||
"next": "Succ",
|
||||
"log_entry_details": "Dettagli della voce di registro",
|
||||
"viewing_details_for": "Visualizzazione dei dettagli completi per la voce di registro #",
|
||||
"actor_id": "ID attore",
|
||||
"previous_hash": "Hash precedente",
|
||||
"current_hash": "Hash corrente",
|
||||
"close": "Chiudi",
|
||||
"verification_successful_title": "Verifica riuscita",
|
||||
"verification_successful_message": "Integrità del registro di audit verificata con successo.",
|
||||
"verification_failed_title": "Verifica non riuscita",
|
||||
"verification_failed_message": "Il controllo di integrità del registro di audit non è riuscito. Controlla i registri di sistema per maggiori dettagli.",
|
||||
"verification_error_message": "Si è verificato un errore inatteso durante la verifica. Riprova."
|
||||
},
|
||||
"jobs": {
|
||||
"title": "Code dei lavori",
|
||||
"queues": "Code dei lavori",
|
||||
"active": "Attivo",
|
||||
"completed": "Completato",
|
||||
"failed": "Fallito",
|
||||
"delayed": "Ritardato",
|
||||
"waiting": "In attesa",
|
||||
"paused": "In pausa",
|
||||
"back_to_queues": "Torna alle code",
|
||||
"queue_overview": "Panoramica della coda",
|
||||
"jobs": "Lavori",
|
||||
"id": "ID",
|
||||
"name": "Nome",
|
||||
"state": "Stato",
|
||||
|
||||
"created_at": "Creato il",
|
||||
"processed_at": "Elaborato il",
|
||||
"finished_at": "Terminato il",
|
||||
"showing": "Visualizzazione di",
|
||||
"of": "di",
|
||||
"previous": "Precedente",
|
||||
"next": "Successivo",
|
||||
"ingestion_source": "Fonte di acquisizione"
|
||||
},
|
||||
"license_page": {
|
||||
"title": "Stato della licenza Enterprise",
|
||||
"meta_description": "Visualizza lo stato attuale della tua licenza Open Archiver Enterprise.",
|
||||
"revoked_title": "Licenza revocata",
|
||||
"revoked_message": "La tua licenza è stata revocata dall'amministratore della licenza. Le funzionalità Enterprise verranno disabilitate {{grace_period}}. Contatta il tuo account manager per assistenza.",
|
||||
"revoked_grace_period": "il {{date}}",
|
||||
"revoked_immediately": "immediatamente",
|
||||
"seat_limit_exceeded_title": "Limite di posti superato",
|
||||
"seat_limit_exceeded_message": "La tua licenza è per {{planSeats}} utenti, ma ne stai attualmente utilizzando {{activeSeats}}. Contatta il reparto vendite per modificare il tuo abbonamento.",
|
||||
"customer": "Cliente",
|
||||
"license_details": "Dettagli licenza",
|
||||
"license_status": "Stato licenza",
|
||||
"active": "Attivo",
|
||||
"expired": "Scaduto",
|
||||
"revoked": "Revocato",
|
||||
"unknown": "Sconosciuto",
|
||||
"expires": "Scade",
|
||||
"seat_usage": "Utilizzo posti",
|
||||
"seats_used": "{{activeSeats}} di {{planSeats}} posti utilizzati",
|
||||
"enabled_features": "Funzionalità abilitate",
|
||||
"enabled_features_description": "Le seguenti funzionalità enterprise sono attualmente abilitate.",
|
||||
"feature": "Funzionalità",
|
||||
"status": "Stato",
|
||||
"enabled": "Abilitato",
|
||||
"disabled": "Disabilitato",
|
||||
"could_not_load_title": "Impossibile caricare la licenza",
|
||||
"could_not_load_message": "Si è verificato un errore inatteso."
|
||||
}
|
||||
}
|
||||
}
|
||||
{
|
||||
"app": {
|
||||
"auth": {
|
||||
"login": "Accedi",
|
||||
"login_tip": "Inserisci la tua email qui sotto per accedere al tuo account.",
|
||||
"email": "Email",
|
||||
"password": "Password"
|
||||
},
|
||||
"common": {
|
||||
"working": "In corso",
|
||||
"read_docs": "Leggi la documentazione"
|
||||
},
|
||||
"archive": {
|
||||
"title": "Archivio",
|
||||
"no_subject": "Nessun oggetto",
|
||||
"from": "Da",
|
||||
"sent": "Inviato",
|
||||
"recipients": "Destinatari",
|
||||
"to": "A",
|
||||
"meta_data": "Metadati",
|
||||
"folder": "Cartella",
|
||||
"tags": "Tag",
|
||||
"size": "Dimensione",
|
||||
"email_preview": "Anteprima email",
|
||||
"attachments": "Allegati",
|
||||
"download": "Scarica",
|
||||
"actions": "Azioni",
|
||||
"download_eml": "Scarica Email (.eml)",
|
||||
"delete_email": "Elimina Email",
|
||||
"email_thread": "Thread Email",
|
||||
"delete_confirmation_title": "Sei sicuro di voler eliminare questa email?",
|
||||
"delete_confirmation_description": "Questa azione non può essere annullata e rimuoverà definitivamente l'email e i suoi allegati.",
|
||||
"deleting": "Eliminazione in corso",
|
||||
"confirm": "Conferma",
|
||||
"cancel": "Annulla",
|
||||
"not_found": "Email non trovata.",
|
||||
"integrity_report": "Rapporto di integrità",
|
||||
"email_eml": "Email (.eml)",
|
||||
"valid": "Valido",
|
||||
"invalid": "Non valido",
|
||||
"integrity_check_failed_title": "Controllo di integrità non riuscito",
|
||||
"integrity_check_failed_message": "Impossibile verificare l'integrità dell'email e dei suoi allegati.",
|
||||
"integrity_report_description": "Questo rapporto verifica che il contenuto delle tue email archiviate non sia stato alterato."
|
||||
},
|
||||
"ingestions": {
|
||||
"title": "Fonti di acquisizione",
|
||||
"ingestion_sources": "Fonti di acquisizione",
|
||||
"bulk_actions": "Azioni di massa",
|
||||
"force_sync": "Forza sincronizzazione",
|
||||
"delete": "Elimina",
|
||||
"create_new": "Crea nuovo",
|
||||
"name": "Nome",
|
||||
"provider": "Provider",
|
||||
"status": "Stato",
|
||||
"active": "Attivo",
|
||||
"created_at": "Creato il",
|
||||
"actions": "Azioni",
|
||||
"last_sync_message": "Ultimo messaggio di sincronizzazione",
|
||||
"empty": "Vuoto",
|
||||
"open_menu": "Apri menu",
|
||||
"edit": "Modifica",
|
||||
"create": "Crea",
|
||||
"ingestion_source": "Fonte di acquisizione",
|
||||
"edit_description": "Apporta modifiche alla tua fonte di acquisizione qui.",
|
||||
"create_description": "Aggiungi una nuova fonte di acquisizione per iniziare ad archiviare le email.",
|
||||
"read": "Leggi",
|
||||
"docs_here": "documentazione qui",
|
||||
"delete_confirmation_title": "Sei sicuro di voler eliminare questa acquisizione?",
|
||||
"delete_confirmation_description": "Questo cancellerà tutte le email archiviate, gli allegati, l'indicizzazione e i file associati a questa acquisizione. Se vuoi solo interrompere la sincronizzazione di nuove email, puoi invece mettere in pausa l'acquisizione.",
|
||||
"deleting": "Eliminazione in corso",
|
||||
"confirm": "Conferma",
|
||||
"cancel": "Annulla",
|
||||
"bulk_delete_confirmation_title": "Sei sicuro di voler eliminare {{count}} acquisizioni selezionate?",
|
||||
"bulk_delete_confirmation_description": "Questo cancellerà tutte le email archiviate, gli allegati, l'indicizzazione e i file associati a queste acquisizioni. Se vuoi solo interrompere la sincronizzazione di nuove email, puoi invece mettere in pausa le acquisizioni."
|
||||
},
|
||||
"search": {
|
||||
"title": "Cerca",
|
||||
"description": "Cerca email archiviate.",
|
||||
"email_search": "Ricerca email",
|
||||
"placeholder": "Cerca per parola chiave, mittente, destinatario...",
|
||||
"search_button": "Cerca",
|
||||
"search_options": "Opzioni di ricerca",
|
||||
"strategy_fuzzy": "Approssimativa",
|
||||
"strategy_verbatim": "Testuale",
|
||||
"strategy_frequency": "Frequenza",
|
||||
"select_strategy": "Seleziona una strategia",
|
||||
"error": "Errore",
|
||||
"found_results_in": "Trovati {{total}} risultati in {{seconds}}s",
|
||||
"found_results": "Trovati {{total}} risultati",
|
||||
"from": "Da",
|
||||
"to": "A",
|
||||
"in_email_body": "Nel corpo dell'email",
|
||||
"in_attachment": "Nell'allegato: {{filename}}",
|
||||
"prev": "Prec",
|
||||
"next": "Succ"
|
||||
},
|
||||
"roles": {
|
||||
"title": "Gestione ruoli",
|
||||
"role_management": "Gestione ruoli",
|
||||
"create_new": "Crea nuovo",
|
||||
"name": "Nome",
|
||||
"created_at": "Creato il",
|
||||
"actions": "Azioni",
|
||||
"open_menu": "Apri menu",
|
||||
"view_policy": "Visualizza Policy",
|
||||
"edit": "Modifica",
|
||||
"delete": "Elimina",
|
||||
"no_roles_found": "Nessun ruolo trovato.",
|
||||
"role_policy": "Policy del ruolo",
|
||||
"viewing_policy_for_role": "Visualizzazione della policy per il ruolo: {{name}}",
|
||||
"create": "Crea",
|
||||
"role": "Ruolo",
|
||||
"edit_description": "Apporta modifiche al ruolo qui.",
|
||||
"create_description": "Aggiungi un nuovo ruolo al sistema.",
|
||||
"delete_confirmation_title": "Sei sicuro di voler eliminare questo ruolo?",
|
||||
"delete_confirmation_description": "Questa azione non può essere annullata. Questo cancellerà definitivamente il ruolo.",
|
||||
"deleting": "Eliminazione in corso",
|
||||
"confirm": "Conferma",
|
||||
"cancel": "Annulla"
|
||||
},
|
||||
"account": {
|
||||
"title": "Impostazioni account",
|
||||
"description": "Gestisci il tuo profilo e le impostazioni di sicurezza.",
|
||||
"personal_info": "Informazioni personali",
|
||||
"personal_info_desc": "Aggiorna i tuoi dati personali.",
|
||||
"security": "Sicurezza",
|
||||
"security_desc": "Gestisci la tua password e le preferenze di sicurezza.",
|
||||
"edit_profile": "Modifica profilo",
|
||||
"change_password": "Cambia password",
|
||||
"edit_profile_desc": "Apporta modifiche al tuo profilo qui.",
|
||||
"change_password_desc": "Cambia la tua password. Dovrai inserire la tua password attuale.",
|
||||
"current_password": "Password attuale",
|
||||
"new_password": "Nuova password",
|
||||
"confirm_new_password": "Conferma nuova password",
|
||||
"operation_successful": "Operazione riuscita",
|
||||
"passwords_do_not_match": "Le password non corrispondono"
|
||||
},
|
||||
"system_settings": {
|
||||
"title": "Impostazioni di sistema",
|
||||
"system_settings": "Impostazioni di sistema",
|
||||
"description": "Gestisci le impostazioni globali dell'applicazione.",
|
||||
"language": "Lingua",
|
||||
"default_theme": "Tema predefinito",
|
||||
"light": "Chiaro",
|
||||
"dark": "Scuro",
|
||||
"system": "Sistema",
|
||||
"support_email": "Email di supporto",
|
||||
"saving": "Salvataggio in corso",
|
||||
"save_changes": "Salva modifiche"
|
||||
},
|
||||
"users": {
|
||||
"title": "Gestione utenti",
|
||||
"user_management": "Gestione utenti",
|
||||
"create_new": "Crea nuovo",
|
||||
"name": "Nome",
|
||||
"email": "Email",
|
||||
"role": "Ruolo",
|
||||
"created_at": "Creato il",
|
||||
"actions": "Azioni",
|
||||
"open_menu": "Apri menu",
|
||||
"edit": "Modifica",
|
||||
"delete": "Elimina",
|
||||
"no_users_found": "Nessun utente trovato.",
|
||||
"create": "Crea",
|
||||
"user": "Utente",
|
||||
"edit_description": "Apporta modifiche all'utente qui.",
|
||||
"create_description": "Aggiungi un nuovo utente al sistema.",
|
||||
"delete_confirmation_title": "Sei sicuro di voler eliminare questo utente?",
|
||||
"delete_confirmation_description": "Questa azione non può essere annullata. Questo cancellerà definitivamente l'utente e rimuoverà i suoi dati dai nostri server.",
|
||||
"deleting": "Eliminazione in corso",
|
||||
"confirm": "Conferma",
|
||||
"cancel": "Annulla"
|
||||
},
|
||||
"components": {
|
||||
"charts": {
|
||||
"emails_ingested": "Email acquisite",
|
||||
"storage_used": "Spazio di archiviazione utilizzato",
|
||||
"emails": "Email"
|
||||
},
|
||||
"common": {
|
||||
"submitting": "Invio in corso...",
|
||||
"submit": "Invia",
|
||||
"save": "Salva"
|
||||
},
|
||||
"email_preview": {
|
||||
"loading": "Caricamento anteprima email...",
|
||||
"render_error": "Impossibile visualizzare l'anteprima dell'email.",
|
||||
"not_available": "File .eml grezzo non disponibile per questa email."
|
||||
},
|
||||
"footer": {
|
||||
"all_rights_reserved": "Tutti i diritti riservati.",
|
||||
"new_version_available": "Nuova versione disponibile"
|
||||
},
|
||||
"ingestion_source_form": {
|
||||
"provider_generic_imap": "IMAP generico",
|
||||
"provider_google_workspace": "Google Workspace",
|
||||
"provider_microsoft_365": "Microsoft 365",
|
||||
"provider_pst_import": "Importazione PST",
|
||||
"provider_eml_import": "Importazione EML",
|
||||
"provider_mbox_import": "Importazione Mbox",
|
||||
"select_provider": "Seleziona un provider",
|
||||
"service_account_key": "Chiave dell'account di servizio (JSON)",
|
||||
"service_account_key_placeholder": "Incolla il contenuto JSON della chiave del tuo account di servizio",
|
||||
"impersonated_admin_email": "Email dell'amministratore impersonato",
|
||||
"client_id": "ID applicazione (client)",
|
||||
"client_secret": "Valore del segreto client",
|
||||
"client_secret_placeholder": "Inserisci il valore segreto, non l'ID segreto",
|
||||
"tenant_id": "ID directory (tenant)",
|
||||
"host": "Host",
|
||||
"port": "Porta",
|
||||
"username": "Nome utente",
|
||||
"use_tls": "Usa TLS",
|
||||
"allow_insecure_cert": "Consenti certificato non sicuro",
|
||||
"pst_file": "File PST",
|
||||
"eml_file": "File EML",
|
||||
"mbox_file": "File Mbox",
|
||||
"heads_up": "Attenzione!",
|
||||
"org_wide_warning": "Tieni presente che questa è un'operazione a livello di organizzazione. Questo tipo di acquisizione importerà e indicizzerà <b>tutte</b> le caselle di posta nella tua organizzazione. Se vuoi importare solo caselle di posta specifiche, usa il connettore IMAP.",
|
||||
"upload_failed": "Caricamento non riuscito, riprova"
|
||||
},
|
||||
"role_form": {
|
||||
"policies_json": "Policy (JSON)",
|
||||
"invalid_json": "Formato JSON non valido per le policy."
|
||||
},
|
||||
"theme_switcher": {
|
||||
"toggle_theme": "Attiva/disattiva tema"
|
||||
},
|
||||
"user_form": {
|
||||
"select_role": "Seleziona un ruolo"
|
||||
}
|
||||
},
|
||||
"setup": {
|
||||
"title": "Configurazione",
|
||||
"description": "Configura l'account amministratore iniziale per Open Archiver.",
|
||||
"welcome": "Benvenuto",
|
||||
"create_admin_account": "Crea il primo account amministratore per iniziare.",
|
||||
"first_name": "Nome",
|
||||
"last_name": "Cognome",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"creating_account": "Creazione account",
|
||||
"create_account": "Crea account"
|
||||
},
|
||||
"layout": {
|
||||
"dashboard": "Dashboard",
|
||||
"ingestions": "Acquisizioni",
|
||||
"archived_emails": "Email archiviate",
|
||||
"search": "Cerca",
|
||||
"settings": "Impostazioni",
|
||||
"system": "Sistema",
|
||||
"users": "Utenti",
|
||||
"roles": "Ruoli",
|
||||
"api_keys": "Chiavi API",
|
||||
"account": "Account",
|
||||
"logout": "Disconnetti",
|
||||
"admin": "Amministratore"
|
||||
},
|
||||
"api_keys_page": {
|
||||
"title": "Chiavi API",
|
||||
"header": "Chiavi API",
|
||||
"generate_new_key": "Genera nuova chiave",
|
||||
"name": "Nome",
|
||||
"key": "Chiave",
|
||||
"expires_at": "Scade il",
|
||||
"created_at": "Creato il",
|
||||
"actions": "Azioni",
|
||||
"delete": "Elimina",
|
||||
"no_keys_found": "Nessuna chiave API trovata.",
|
||||
"generate_modal_title": "Genera nuova chiave API",
|
||||
"generate_modal_description": "Fornisci un nome e una scadenza per la tua nuova chiave API.",
|
||||
"expires_in": "Scade tra",
|
||||
"select_expiration": "Seleziona una scadenza",
|
||||
"30_days": "30 giorni",
|
||||
"60_days": "60 giorni",
|
||||
"6_months": "6 mesi",
|
||||
"12_months": "12 mesi",
|
||||
"24_months": "24 mesi",
|
||||
"generate": "Genera",
|
||||
"new_api_key": "Nuova chiave API",
|
||||
"failed_to_delete": "Impossibile eliminare la chiave API",
|
||||
"api_key_deleted": "Chiave API eliminata",
|
||||
"generated_title": "Chiave API generata",
|
||||
"generated_message": "La tua chiave API è stata generata, copiala e salvala in un luogo sicuro. Questa chiave verrà mostrata solo una volta."
|
||||
},
|
||||
"archived_emails_page": {
|
||||
"title": "Email archiviate",
|
||||
"header": "Email archiviate",
|
||||
"select_ingestion_source": "Seleziona una fonte di acquisizione",
|
||||
"date": "Data",
|
||||
"subject": "Oggetto",
|
||||
"sender": "Mittente",
|
||||
"inbox": "Posta in arrivo",
|
||||
"path": "Percorso",
|
||||
"actions": "Azioni",
|
||||
"view": "Visualizza",
|
||||
"no_emails_found": "Nessuna email archiviata trovata.",
|
||||
"prev": "Prec",
|
||||
"next": "Succ"
|
||||
},
|
||||
"dashboard_page": {
|
||||
"title": "Dashboard",
|
||||
"meta_description": "Panoramica del tuo archivio email.",
|
||||
"header": "Dashboard",
|
||||
"create_ingestion": "Crea un'acquisizione",
|
||||
"no_ingestion_header": "Non hai configurato alcuna fonte di acquisizione.",
|
||||
"no_ingestion_text": "Aggiungi una fonte di acquisizione per iniziare ad archiviare le tue caselle di posta.",
|
||||
"total_emails_archived": "Email totali archiviate",
|
||||
"total_storage_used": "Spazio di archiviazione totale utilizzato",
|
||||
"failed_ingestions": "Acquisizioni non riuscite (ultimi 7 giorni)",
|
||||
"ingestion_history": "Cronologia acquisizioni",
|
||||
"no_ingestion_history": "Nessuna cronologia acquisizioni disponibile.",
|
||||
"storage_by_source": "Spazio di archiviazione per fonte di acquisizione",
|
||||
"no_ingestion_sources": "Nessuna fonte di acquisizione disponibile.",
|
||||
"indexed_insights": "Informazioni indicizzate",
|
||||
"top_10_senders": "I 10 mittenti principali",
|
||||
"no_indexed_insights": "Nessuna informazione indicizzata disponibile."
|
||||
},
|
||||
"audit_log": {
|
||||
"title": "Registro di audit",
|
||||
"header": "Registro di audit",
|
||||
"verify_integrity": "Verifica l'integrità del registro",
|
||||
"log_entries": "Voci di registro",
|
||||
"timestamp": "Timestamp",
|
||||
"actor": "Attore",
|
||||
"action": "Azione",
|
||||
"target": "Obiettivo",
|
||||
"details": "Dettagli",
|
||||
"ip_address": "Indirizzo IP",
|
||||
"target_type": "Tipo di obiettivo",
|
||||
"target_id": "ID obiettivo",
|
||||
"no_logs_found": "Nessun registro di audit trovato.",
|
||||
"prev": "Prec",
|
||||
"next": "Succ",
|
||||
"log_entry_details": "Dettagli della voce di registro",
|
||||
"viewing_details_for": "Visualizzazione dei dettagli completi per la voce di registro #",
|
||||
"actor_id": "ID attore",
|
||||
"previous_hash": "Hash precedente",
|
||||
"current_hash": "Hash corrente",
|
||||
"close": "Chiudi",
|
||||
"verification_successful_title": "Verifica riuscita",
|
||||
"verification_successful_message": "Integrità del registro di audit verificata con successo.",
|
||||
"verification_failed_title": "Verifica non riuscita",
|
||||
"verification_failed_message": "Il controllo di integrità del registro di audit non è riuscito. Controlla i registri di sistema per maggiori dettagli.",
|
||||
"verification_error_message": "Si è verificato un errore inatteso durante la verifica. Riprova."
|
||||
},
|
||||
"jobs": {
|
||||
"title": "Code dei lavori",
|
||||
"queues": "Code dei lavori",
|
||||
"active": "Attivo",
|
||||
"completed": "Completato",
|
||||
"failed": "Fallito",
|
||||
"delayed": "Ritardato",
|
||||
"waiting": "In attesa",
|
||||
"paused": "In pausa",
|
||||
"back_to_queues": "Torna alle code",
|
||||
"queue_overview": "Panoramica della coda",
|
||||
"jobs": "Lavori",
|
||||
"id": "ID",
|
||||
"name": "Nome",
|
||||
"state": "Stato",
|
||||
|
||||
"created_at": "Creato il",
|
||||
"processed_at": "Elaborato il",
|
||||
"finished_at": "Terminato il",
|
||||
"showing": "Visualizzazione di",
|
||||
"of": "di",
|
||||
"previous": "Precedente",
|
||||
"next": "Successivo",
|
||||
"ingestion_source": "Fonte di acquisizione"
|
||||
},
|
||||
"license_page": {
|
||||
"title": "Stato della licenza Enterprise",
|
||||
"meta_description": "Visualizza lo stato attuale della tua licenza Open Archiver Enterprise.",
|
||||
"revoked_title": "Licenza revocata",
|
||||
"revoked_message": "La tua licenza è stata revocata dall'amministratore della licenza. Le funzionalità Enterprise verranno disabilitate {{grace_period}}. Contatta il tuo account manager per assistenza.",
|
||||
"revoked_grace_period": "il {{date}}",
|
||||
"revoked_immediately": "immediatamente",
|
||||
"seat_limit_exceeded_title": "Limite di posti superato",
|
||||
"seat_limit_exceeded_message": "La tua licenza è per {{planSeats}} utenti, ma ne stai attualmente utilizzando {{activeSeats}}. Contatta il reparto vendite per modificare il tuo abbonamento.",
|
||||
"customer": "Cliente",
|
||||
"license_details": "Dettagli licenza",
|
||||
"license_status": "Stato licenza",
|
||||
"active": "Attivo",
|
||||
"expired": "Scaduto",
|
||||
"revoked": "Revocato",
|
||||
"unknown": "Sconosciuto",
|
||||
"expires": "Scade",
|
||||
"seat_usage": "Utilizzo posti",
|
||||
"seats_used": "{{activeSeats}} di {{planSeats}} posti utilizzati",
|
||||
"enabled_features": "Funzionalità abilitate",
|
||||
"enabled_features_description": "Le seguenti funzionalità enterprise sono attualmente abilitate.",
|
||||
"feature": "Funzionalità",
|
||||
"status": "Stato",
|
||||
"enabled": "Abilitato",
|
||||
"disabled": "Disabilitato",
|
||||
"could_not_load_title": "Impossibile caricare la licenza",
|
||||
"could_not_load_message": "Si è verificato un errore inatteso."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,6 @@ export const load: LayoutLoad = async ({ url, data }) => {
|
||||
if (data && data.systemSettings?.language) {
|
||||
initLocale = data.systemSettings.language;
|
||||
}
|
||||
|
||||
console.log(initLocale);
|
||||
await loadTranslations(initLocale, pathname);
|
||||
|
||||
return {
|
||||
|
||||
@@ -30,11 +30,23 @@ const handleRequest: RequestHandler = async ({ request, params, fetch }) => {
|
||||
const response = await fetch(proxyRequest);
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error('Proxy request failed:', error);
|
||||
|
||||
// Handle SvelteKit HttpError (e.g. from request.arrayBuffer() exceeding BODY_SIZE_LIMIT)
|
||||
// Or other types of errors, formatting them into the standard ApiErrorResponse
|
||||
const statusCode = error?.status || 500;
|
||||
const message =
|
||||
error?.body?.message || error?.message || 'Failed to connect to the backend service.';
|
||||
|
||||
return json(
|
||||
{ message: `Failed to connect to the backend service. ${JSON.stringify(error)}` },
|
||||
{ status: 500 }
|
||||
{
|
||||
status: 'error',
|
||||
statusCode: statusCode,
|
||||
message: message,
|
||||
errors: null,
|
||||
},
|
||||
{ status: statusCode }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -146,7 +146,7 @@
|
||||
<img src="/logos/logo-sq.svg" alt="OpenArchiver Logo" class="h-8 w-8" />
|
||||
<span class="hidden sm:inline-block">Open Archiver</span>
|
||||
{#if data.enterpriseMode}
|
||||
<Badge class="text-[8px] font-bold px-1 py-0.5">Enterprise</Badge>
|
||||
<Badge class="px-1 py-0.5 text-[8px] font-bold">Enterprise</Badge>
|
||||
{/if}
|
||||
</a>
|
||||
|
||||
|
||||
@@ -120,7 +120,10 @@ export const actions: Actions = {
|
||||
|
||||
if (!response.ok) {
|
||||
const res = await response.json().catch(() => ({}));
|
||||
return { success: false, message: (res as { message?: string }).message || 'Failed to apply label' };
|
||||
return {
|
||||
success: false,
|
||||
message: (res as { message?: string }).message || 'Failed to apply label',
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true, action: 'applied' };
|
||||
@@ -135,7 +138,10 @@ export const actions: Actions = {
|
||||
|
||||
if (!response.ok) {
|
||||
const res = await response.json().catch(() => ({}));
|
||||
return { success: false, message: (res as { message?: string }).message || 'Failed to remove label' };
|
||||
return {
|
||||
success: false,
|
||||
message: (res as { message?: string }).message || 'Failed to remove label',
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true, action: 'removed' };
|
||||
|
||||
@@ -16,7 +16,16 @@
|
||||
import * as Alert from '$lib/components/ui/alert';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import * as HoverCard from '$lib/components/ui/hover-card';
|
||||
import { Clock, Trash2, CalendarClock, AlertCircle, Shield, CircleAlert, Tag } from 'lucide-svelte';
|
||||
import {
|
||||
Clock,
|
||||
Trash2,
|
||||
CalendarClock,
|
||||
AlertCircle,
|
||||
Shield,
|
||||
CircleAlert,
|
||||
Tag,
|
||||
FileDown,
|
||||
} from 'lucide-svelte';
|
||||
import { page } from '$app/state';
|
||||
import { enhance } from '$app/forms';
|
||||
import type { LegalHold, EmailLegalHoldInfo } from '@open-archiver/types';
|
||||
@@ -65,6 +74,9 @@
|
||||
let isApplyingHold = $state(false);
|
||||
let isRemovingHoldId = $state<string | null>(null);
|
||||
|
||||
// --- Integrity report PDF download state (enterprise only) ---
|
||||
let isDownloadingReport = $state(false);
|
||||
|
||||
// React to form results for label and hold actions
|
||||
$effect(() => {
|
||||
if (form) {
|
||||
@@ -143,6 +155,41 @@
|
||||
}
|
||||
}
|
||||
|
||||
/** Downloads the enterprise integrity verification PDF report. */
|
||||
async function downloadIntegrityReportPdf() {
|
||||
if (!browser || !email) return;
|
||||
|
||||
try {
|
||||
isDownloadingReport = true;
|
||||
const response = await api(`/enterprise/integrity-report/${email.id}/pdf`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `integrity-report-${email.id}.pdf`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
a.remove();
|
||||
} catch (error) {
|
||||
console.error('Integrity report download failed:', error);
|
||||
setAlert({
|
||||
type: 'error',
|
||||
title: $t('app.archive.integrity_report_download_error'),
|
||||
message: '',
|
||||
duration: 5000,
|
||||
show: true,
|
||||
});
|
||||
} finally {
|
||||
isDownloadingReport = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!email) return;
|
||||
try {
|
||||
@@ -193,21 +240,19 @@
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-1">
|
||||
<h3 class="font-semibold">{$t('app.archive.recipients')}</h3>
|
||||
<Card.Description>
|
||||
<p>
|
||||
{$t('app.archive.to')}: {email.recipients
|
||||
.map((r) => r.email || r.name)
|
||||
.join(', ')}
|
||||
</p>
|
||||
</Card.Description>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
{$t('app.archive.to')}: {email.recipients
|
||||
.map((r) => r.email || r.name)
|
||||
.join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
<div class=" space-y-1">
|
||||
<div class="space-y-1">
|
||||
<h3 class="font-semibold">{$t('app.archive.meta_data')}</h3>
|
||||
<Card.Description class="space-y-2">
|
||||
<div class="text-muted-foreground space-y-2 text-sm">
|
||||
{#if email.path}
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span>{$t('app.archive.folder')}:</span>
|
||||
<span class=" bg-muted truncate rounded p-1.5 text-xs"
|
||||
<span class="bg-muted truncate rounded p-1.5 text-xs"
|
||||
>{email.path || '/'}</span
|
||||
>
|
||||
</div>
|
||||
@@ -216,7 +261,7 @@
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span> {$t('app.archive.tags')}: </span>
|
||||
{#each email.tags as tag}
|
||||
<span class=" bg-muted truncate rounded p-1.5 text-xs"
|
||||
<span class="bg-muted truncate rounded p-1.5 text-xs"
|
||||
>{tag}</span
|
||||
>
|
||||
{/each}
|
||||
@@ -224,11 +269,11 @@
|
||||
{/if}
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span>{$t('app.archive.size')}:</span>
|
||||
<span class=" bg-muted truncate rounded p-1.5 text-xs"
|
||||
<span class="bg-muted truncate rounded p-1.5 text-xs"
|
||||
>{formatBytes(email.sizeBytes)}</span
|
||||
>
|
||||
</div>
|
||||
</Card.Description>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold">{$t('app.archive.email_preview')}</h3>
|
||||
@@ -282,12 +327,16 @@
|
||||
download(email.storagePath, `${email.subject || 'email'}.eml`)}
|
||||
>{$t('app.archive.download_eml')}</Button
|
||||
>
|
||||
<Button variant="destructive" class="text-xs" onclick={() => (isDeleteDialogOpen = true)}>
|
||||
<Button
|
||||
variant="destructive"
|
||||
class="text-xs"
|
||||
onclick={() => (isDeleteDialogOpen = true)}
|
||||
>
|
||||
{$t('app.archive.delete_email')}
|
||||
</Button>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
|
||||
{#if integrityReport && integrityReport.length > 0}
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
@@ -347,6 +396,22 @@
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{#if enterpriseMode}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="mt-2 w-full text-xs"
|
||||
onclick={downloadIntegrityReportPdf}
|
||||
disabled={isDownloadingReport}
|
||||
>
|
||||
<FileDown class="mr-1.5 h-3.5 w-3.5" />
|
||||
{#if isDownloadingReport}
|
||||
{$t('app.archive.downloading_integrity_report')}
|
||||
{:else}
|
||||
{$t('app.archive.download_integrity_report_pdf')}
|
||||
{/if}
|
||||
</Button>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{:else}
|
||||
@@ -358,6 +423,17 @@
|
||||
</Alert.Description>
|
||||
</Alert.Root>
|
||||
{/if}
|
||||
<!-- Thread discovery -->
|
||||
{#if email.thread && email.thread.length > 1}
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>{$t('app.archive.email_thread')}</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<EmailThread thread={email.thread} currentEmailId={email.id} />
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
<!-- Legal Holds card (Enterprise only) -->
|
||||
{#if enterpriseMode}
|
||||
<Card.Root>
|
||||
@@ -382,14 +458,18 @@
|
||||
{#if emailLegalHolds && emailLegalHolds.length > 0}
|
||||
<div class="space-y-2">
|
||||
{#each emailLegalHolds as holdInfo (holdInfo.legalHoldId)}
|
||||
<div class="flex items-center justify-between rounded-md border p-2">
|
||||
<div
|
||||
class="flex items-center justify-between rounded-md border p-2"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="truncate text-xs font-medium">
|
||||
{holdInfo.holdName}
|
||||
</span>
|
||||
{#if holdInfo.isActive}
|
||||
<Badge class="bg-destructive text-white text-xs">
|
||||
<Badge
|
||||
class="bg-destructive text-xs text-white"
|
||||
>
|
||||
{$t('app.legal_holds.active')}
|
||||
</Badge>
|
||||
{:else}
|
||||
@@ -464,15 +544,20 @@
|
||||
>
|
||||
<Select.Trigger class="w-full text-xs">
|
||||
{#if selectedHoldId}
|
||||
{legalHolds.find((h) => h.id === selectedHoldId)?.name ??
|
||||
$t('app.archive_legal_holds.apply_hold_placeholder')}
|
||||
{legalHolds.find((h) => h.id === selectedHoldId)
|
||||
?.name ??
|
||||
$t(
|
||||
'app.archive_legal_holds.apply_hold_placeholder'
|
||||
)}
|
||||
{:else}
|
||||
{$t('app.archive_legal_holds.apply_hold_placeholder')}
|
||||
{/if}
|
||||
</Select.Trigger>
|
||||
<Select.Content class="text-xs">
|
||||
{#each legalHolds as hold (hold.id)}
|
||||
<Select.Item value={hold.id} class="text-xs">{hold.name}</Select.Item>
|
||||
<Select.Item value={hold.id} class="text-xs"
|
||||
>{hold.name}</Select.Item
|
||||
>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
@@ -517,11 +602,14 @@
|
||||
<Card.Content class="space-y-3">
|
||||
<!-- Override notice: shown when an active retention label is applied -->
|
||||
{#if emailRetentionLabel && !emailRetentionLabel.isLabelDisabled}
|
||||
<div class="flex items-start align-middle gap-2 rounded-md px-2 py-1.5 bg-muted-foreground text-muted">
|
||||
<div
|
||||
class="bg-muted-foreground text-muted flex items-start gap-2 rounded-md px-2 py-1.5 align-middle"
|
||||
>
|
||||
<CircleAlert class=" h-4 w-4 flex-shrink-0" />
|
||||
<div class=" text-xs">
|
||||
{$t('app.archive.retention_policy_overridden_by_label')}
|
||||
<span class="font-medium">{emailRetentionLabel.labelName}</span>.
|
||||
<span class="font-medium">{emailRetentionLabel.labelName}</span
|
||||
>.
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -542,13 +630,13 @@
|
||||
</Badge>
|
||||
</div>
|
||||
{#if scheduledDeletionDate}
|
||||
<div class="flex items-center gap-2">
|
||||
<CalendarClock
|
||||
class="text-muted-foreground h-4 w-4 flex-shrink-0"
|
||||
/>
|
||||
<span class="text-xs font-medium"
|
||||
>{$t('app.archive.retention_scheduled_deletion')}:</span
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<CalendarClock
|
||||
class="text-muted-foreground h-4 w-4 flex-shrink-0"
|
||||
/>
|
||||
<span class="text-xs font-medium"
|
||||
>{$t('app.archive.retention_scheduled_deletion')}:</span
|
||||
>
|
||||
<Badge
|
||||
variant={scheduledDeletionDate <= new Date()
|
||||
? 'destructive'
|
||||
@@ -574,7 +662,7 @@
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each retentionPolicy.matchingPolicyIds as policyId}
|
||||
<Badge variant="outline" class="text-xs font-mono">
|
||||
<Badge variant="outline" class="font-mono text-xs">
|
||||
{policyId}
|
||||
</Badge>
|
||||
{/each}
|
||||
@@ -619,12 +707,19 @@
|
||||
<Badge variant="secondary">
|
||||
{emailRetentionLabel.labelName}
|
||||
</Badge>
|
||||
<Badge variant="outline" class="text-muted-foreground text-xs">
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="text-muted-foreground text-xs"
|
||||
>
|
||||
{$t('app.archive_labels.label_inactive')}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="flex items-start gap-2 rounded-md border border-dashed p-2">
|
||||
<AlertCircle class="text-muted-foreground mt-0.5 h-4 w-4 flex-shrink-0" />
|
||||
<div
|
||||
class="flex items-start gap-2 rounded-md border border-dashed p-2"
|
||||
>
|
||||
<AlertCircle
|
||||
class="text-muted-foreground mt-0.5 h-4 w-4 flex-shrink-0"
|
||||
/>
|
||||
<p class="text-muted-foreground text-xs">
|
||||
{$t('app.archive_labels.label_inactive_note')}
|
||||
</p>
|
||||
@@ -668,7 +763,9 @@
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Clock class="text-muted-foreground h-4 w-4 flex-shrink-0" />
|
||||
<Clock
|
||||
class="text-muted-foreground h-4 w-4 flex-shrink-0"
|
||||
/>
|
||||
<span class="text-xs font-medium">
|
||||
{$t('app.archive.retention_period')}:
|
||||
</span>
|
||||
@@ -749,7 +846,8 @@
|
||||
>
|
||||
<Select.Trigger class="w-full text-xs">
|
||||
{#if selectedLabelId}
|
||||
{retentionLabels.find((l) => l.id === selectedLabelId)?.name ??
|
||||
{retentionLabels.find((l) => l.id === selectedLabelId)
|
||||
?.name ??
|
||||
$t('app.archive_labels.select_label_placeholder')}
|
||||
{:else}
|
||||
{$t('app.archive_labels.select_label_placeholder')}
|
||||
@@ -760,13 +858,14 @@
|
||||
<Select.Item value={label.id}>
|
||||
{label.name}
|
||||
<span class="text-muted-foreground ml-1 text-xs">
|
||||
({label.retentionPeriodDays} {$t('app.retention_labels.days')})
|
||||
({label.retentionPeriodDays}
|
||||
{$t('app.retention_labels.days')})
|
||||
</span>
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
@@ -788,19 +887,6 @@
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
|
||||
{/if}
|
||||
|
||||
{#if email.thread && email.thread.length > 1}
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>{$t('app.archive.email_thread')}</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<EmailThread thread={email.thread} currentEmailId={email.id} />
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -98,7 +98,10 @@ export const actions: Actions = {
|
||||
|
||||
if (!response.ok) {
|
||||
const res = await response.json().catch(() => ({}));
|
||||
return { success: false, message: (res as { message?: string }).message || 'Failed to delete legal hold.' };
|
||||
return {
|
||||
success: false,
|
||||
message: (res as { message?: string }).message || 'Failed to delete legal hold.',
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
@@ -147,7 +150,8 @@ export const actions: Actions = {
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false,
|
||||
message: (res as { message?: string }).message || 'Failed to release emails from hold.',
|
||||
message:
|
||||
(res as { message?: string }).message || 'Failed to release emails from hold.',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -10,11 +10,11 @@
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Textarea } from '$lib/components/ui/textarea';
|
||||
import { enhance } from '$app/forms';
|
||||
import { MoreHorizontal, Plus, Users } from 'lucide-svelte';
|
||||
import { MoreHorizontal, Plus, ShieldCheck } from 'lucide-svelte';
|
||||
import { setAlert } from '$lib/components/custom/alert/alert-state.svelte';
|
||||
import type { LegalHold } from '@open-archiver/types';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
let { data }: { data: PageData; form: ActionData } = $props();
|
||||
|
||||
let holds = $derived(data.holds);
|
||||
|
||||
@@ -85,9 +85,6 @@
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">{$t('app.legal_holds.header')}</h1>
|
||||
<p class="text-muted-foreground mt-1 text-sm">
|
||||
{$t('app.legal_holds.header_description')}
|
||||
</p>
|
||||
</div>
|
||||
<Button onclick={() => (isCreateOpen = true)}>
|
||||
<Plus class="mr-1.5 h-4 w-4" />
|
||||
@@ -115,7 +112,7 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<div>
|
||||
<div>{hold.name}</div>
|
||||
<div class="mt-0.5 font-mono text-[10px] text-muted-foreground">
|
||||
<div class="text-muted-foreground mt-0.5 font-mono text-[10px]">
|
||||
{hold.id}
|
||||
</div>
|
||||
</div>
|
||||
@@ -123,7 +120,9 @@
|
||||
</Table.Cell>
|
||||
<Table.Cell class="max-w-[300px]">
|
||||
{#if hold.reason}
|
||||
<span class="text-muted-foreground line-clamp-2 text-xs">{hold.reason}</span>
|
||||
<span class="text-muted-foreground line-clamp-2 text-xs"
|
||||
>{hold.reason}</span
|
||||
>
|
||||
{:else}
|
||||
<span class="text-muted-foreground text-xs italic">
|
||||
{$t('app.legal_holds.no_reason')}
|
||||
@@ -132,7 +131,7 @@
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Users class="text-muted-foreground h-3.5 w-3.5" />
|
||||
<ShieldCheck class="text-muted-foreground h-3.5 w-3.5" />
|
||||
<Badge variant={hold.emailCount > 0 ? 'secondary' : 'outline'}>
|
||||
{hold.emailCount}
|
||||
</Badge>
|
||||
@@ -182,33 +181,52 @@
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
<!-- Toggle active/inactive -->
|
||||
<form method="POST" action="?/toggleActive" use:enhance={() => {
|
||||
return async ({ result, update }) => {
|
||||
if (result.type === 'success' && result.data?.success !== false) {
|
||||
const newState = result.data?.isActive as boolean;
|
||||
setAlert({
|
||||
type: 'success',
|
||||
title: newState
|
||||
? $t('app.legal_holds.activated_success')
|
||||
: $t('app.legal_holds.deactivated_success'),
|
||||
message: '',
|
||||
duration: 3000,
|
||||
show: true,
|
||||
});
|
||||
} else if (result.type === 'success' && result.data?.success === false) {
|
||||
setAlert({
|
||||
type: 'error',
|
||||
title: $t('app.legal_holds.update_error'),
|
||||
message: String(result.data?.message ?? ''),
|
||||
duration: 5000,
|
||||
show: true,
|
||||
});
|
||||
}
|
||||
await update();
|
||||
};
|
||||
}}>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/toggleActive"
|
||||
use:enhance={() => {
|
||||
return async ({ result, update }) => {
|
||||
if (
|
||||
result.type === 'success' &&
|
||||
result.data?.success !== false
|
||||
) {
|
||||
const newState = result.data
|
||||
?.isActive as boolean;
|
||||
setAlert({
|
||||
type: 'success',
|
||||
title: newState
|
||||
? $t(
|
||||
'app.legal_holds.activated_success'
|
||||
)
|
||||
: $t(
|
||||
'app.legal_holds.deactivated_success'
|
||||
),
|
||||
message: '',
|
||||
duration: 3000,
|
||||
show: true,
|
||||
});
|
||||
} else if (
|
||||
result.type === 'success' &&
|
||||
result.data?.success === false
|
||||
) {
|
||||
setAlert({
|
||||
type: 'error',
|
||||
title: $t('app.legal_holds.update_error'),
|
||||
message: String(result.data?.message ?? ''),
|
||||
duration: 5000,
|
||||
show: true,
|
||||
});
|
||||
}
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="id" value={hold.id} />
|
||||
<input type="hidden" name="isActive" value={String(!hold.isActive)} />
|
||||
<input
|
||||
type="hidden"
|
||||
name="isActive"
|
||||
value={String(!hold.isActive)}
|
||||
/>
|
||||
<DropdownMenu.Item>
|
||||
<button type="submit" class="w-full text-left">
|
||||
{hold.isActive
|
||||
@@ -365,11 +383,7 @@
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label for="edit-reason">{$t('app.legal_holds.reason')}</Label>
|
||||
<Textarea
|
||||
id="edit-reason"
|
||||
name="reason"
|
||||
value={selectedHold.reason ?? ''}
|
||||
/>
|
||||
<Textarea id="edit-reason" name="reason" value={selectedHold.reason ?? ''} />
|
||||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button
|
||||
@@ -467,7 +481,9 @@
|
||||
<Input id="bulk-end" type="date" bind:value={bulkFiltersDateEnd} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-md border border-amber-200 bg-amber-50 p-3 dark:border-amber-900 dark:bg-amber-950">
|
||||
<div
|
||||
class="rounded-md border border-amber-200 bg-amber-50 p-3 dark:border-amber-900 dark:bg-amber-950"
|
||||
>
|
||||
<p class="text-xs text-amber-800 dark:text-amber-200">
|
||||
{$t('app.legal_holds.bulk_apply_warning')}
|
||||
</p>
|
||||
@@ -481,7 +497,11 @@
|
||||
>
|
||||
{$t('app.legal_holds.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isFormLoading || (!bulkQuery && !bulkFiltersFrom && !bulkFiltersDateStart)}>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isFormLoading ||
|
||||
(!bulkQuery && !bulkFiltersFrom && !bulkFiltersDateStart)}
|
||||
>
|
||||
{#if isFormLoading}
|
||||
{$t('app.common.working')}
|
||||
{:else}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { t } from '$lib/translations';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { Tag } from 'lucide-svelte';
|
||||
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';
|
||||
@@ -72,7 +73,7 @@
|
||||
<Table.Row>
|
||||
<Table.Cell class="font-medium">
|
||||
<div>{label.name}</div>
|
||||
<div class="mt-0.5 font-mono text-[10px] text-muted-foreground">
|
||||
<div class="text-muted-foreground mt-0.5 font-mono text-[10px]">
|
||||
{label.id}
|
||||
</div>
|
||||
{#if label.description}
|
||||
@@ -87,9 +88,14 @@
|
||||
</Table.Cell>
|
||||
<!-- Applied email count — shows a subtle badge with the number -->
|
||||
<Table.Cell>
|
||||
<Badge variant={label.appliedEmailCount > 0 ? 'secondary' : 'outline'}>
|
||||
{label.appliedEmailCount}
|
||||
</Badge>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Tag class="text-muted-foreground h-3.5 w-3.5" />
|
||||
<Badge
|
||||
variant={label.appliedEmailCount > 0 ? 'secondary' : 'outline'}
|
||||
>
|
||||
{label.appliedEmailCount}
|
||||
</Badge>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{#if label.isDisabled}
|
||||
@@ -281,12 +287,7 @@
|
||||
<input type="hidden" name="id" value={selectedLabel.id} />
|
||||
<div class="space-y-1.5">
|
||||
<Label for="edit-name">{$t('app.retention_labels.name')}</Label>
|
||||
<Input
|
||||
id="edit-name"
|
||||
name="name"
|
||||
required
|
||||
value={selectedLabel.name}
|
||||
/>
|
||||
<Input id="edit-name" name="name" required value={selectedLabel.name} />
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label for="edit-description">{$t('app.retention_labels.description')}</Label>
|
||||
@@ -364,11 +365,7 @@
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<Dialog.Footer>
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => (isDeleteOpen = false)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
<Button variant="outline" onclick={() => (isDeleteOpen = false)} disabled={isDeleting}>
|
||||
{$t('app.retention_labels.cancel')}
|
||||
</Button>
|
||||
{#if selectedLabel}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { api } from '$lib/server/api';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import type { RetentionPolicy, PolicyEvaluationResult, SafeIngestionSource } from '@open-archiver/types';
|
||||
import type {
|
||||
RetentionPolicy,
|
||||
PolicyEvaluationResult,
|
||||
SafeIngestionSource,
|
||||
} from '@open-archiver/types';
|
||||
|
||||
export const load: PageServerLoad = async (event) => {
|
||||
if (!event.locals.enterpriseMode) {
|
||||
|
||||
@@ -63,7 +63,6 @@
|
||||
const op = policy.conditions.logicalOperator;
|
||||
return `${count} ${$t('app.retention_policies.rules')} (${op})`;
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -102,11 +101,13 @@
|
||||
<Table.Row>
|
||||
<Table.Cell class="font-medium">
|
||||
<div>{policy.name}</div>
|
||||
<div class="mt-0.5 font-mono text-[10px] text-muted-foreground">
|
||||
<div class="text-muted-foreground mt-0.5 font-mono text-[10px]">
|
||||
{policy.id}
|
||||
</div>
|
||||
{#if policy.description}
|
||||
<div class="text-muted-foreground mt-0.5 text-xs">{policy.description}</div>
|
||||
<div class="text-muted-foreground mt-0.5 text-xs">
|
||||
{policy.description}
|
||||
</div>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell>{policy.priority}</Table.Cell>
|
||||
@@ -122,7 +123,9 @@
|
||||
{:else}
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each policy.ingestionScope as sourceId (sourceId)}
|
||||
{@const source = ingestionSources.find((s) => s.id === sourceId)}
|
||||
{@const source = ingestionSources.find(
|
||||
(s) => s.id === sourceId
|
||||
)}
|
||||
<Badge variant="outline" class="text-xs">
|
||||
{source?.name ?? sourceId.slice(0, 8) + '…'}
|
||||
</Badge>
|
||||
@@ -131,7 +134,9 @@
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<span class="text-muted-foreground text-sm">{conditionsSummary(policy)}</span>
|
||||
<span class="text-muted-foreground text-sm"
|
||||
>{conditionsSummary(policy)}</span
|
||||
>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{#if policy.isActive}
|
||||
@@ -330,7 +335,9 @@
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="">
|
||||
<span class="italic">{$t('app.retention_policies.simulator_ingestion_all')}</span>
|
||||
<span class="italic"
|
||||
>{$t('app.retention_policies.simulator_ingestion_all')}</span
|
||||
>
|
||||
</Select.Item>
|
||||
{#each ingestionSources as source (source.id)}
|
||||
<Select.Item value={source.id}>
|
||||
@@ -370,7 +377,9 @@
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
<div class="rounded-md border border-green-200 bg-green-50 p-4 dark:border-green-800 dark:bg-green-950">
|
||||
<div
|
||||
class="rounded-md border border-green-200 bg-green-50 p-4 dark:border-green-800 dark:bg-green-950"
|
||||
>
|
||||
<p class="text-sm font-medium text-green-800 dark:text-green-200">
|
||||
{($t as any)('app.retention_policies.simulator_matched', {
|
||||
days: evaluationResult.appliedRetentionDays,
|
||||
@@ -379,18 +388,24 @@
|
||||
</div>
|
||||
{#if evaluationResult.matchingPolicyIds.length > 0}
|
||||
<div class="space-y-1.5">
|
||||
<p class="text-muted-foreground text-xs font-medium uppercase tracking-wide">
|
||||
<p
|
||||
class="text-muted-foreground text-xs font-medium uppercase tracking-wide"
|
||||
>
|
||||
{$t('app.retention_policies.simulator_matching_policies')}
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each evaluationResult.matchingPolicyIds as policyId (policyId)}
|
||||
{@const matchedPolicy = policies.find((p) => p.id === policyId)}
|
||||
<div class="flex items-center gap-1.5">
|
||||
<code class="bg-muted rounded px-2 py-0.5 font-mono text-xs">
|
||||
<code
|
||||
class="bg-muted rounded px-2 py-0.5 font-mono text-xs"
|
||||
>
|
||||
{policyId}
|
||||
</code>
|
||||
{#if matchedPolicy}
|
||||
<span class="text-muted-foreground text-xs">({matchedPolicy.name})</span>
|
||||
<span class="text-muted-foreground text-xs"
|
||||
>({matchedPolicy.name})</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
@@ -419,11 +434,7 @@
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<Dialog.Footer>
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => (isDeleteOpen = false)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
<Button variant="outline" onclick={() => (isDeleteOpen = false)} disabled={isDeleting}>
|
||||
{$t('app.retention_policies.cancel')}
|
||||
</Button>
|
||||
{#if selectedPolicy}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { setAlert } from '$lib/components/custom/alert/alert-state.svelte';
|
||||
|
||||
|
||||
let { data, form } = $props();
|
||||
let user = $derived(data.user);
|
||||
|
||||
@@ -15,62 +15,61 @@
|
||||
let isPasswordDialogOpen = $state(false);
|
||||
let isSubmitting = $state(false);
|
||||
|
||||
// Profile form state
|
||||
let profileFirstName = $state('');
|
||||
let profileLastName = $state('');
|
||||
let profileEmail = $state('');
|
||||
// Profile form state
|
||||
let profileFirstName = $state('');
|
||||
let profileLastName = $state('');
|
||||
let profileEmail = $state('');
|
||||
|
||||
// Password form state
|
||||
let currentPassword = $state('');
|
||||
let newPassword = $state('');
|
||||
let confirmNewPassword = $state('');
|
||||
// Password form state
|
||||
let currentPassword = $state('');
|
||||
let newPassword = $state('');
|
||||
let confirmNewPassword = $state('');
|
||||
|
||||
// Preload profile form
|
||||
$effect(() => {
|
||||
if (user && isProfileDialogOpen) {
|
||||
profileFirstName = user.first_name || '';
|
||||
profileLastName = user.last_name || '';
|
||||
profileEmail = user.email || '';
|
||||
}
|
||||
});
|
||||
// Preload profile form
|
||||
$effect(() => {
|
||||
if (user && isProfileDialogOpen) {
|
||||
profileFirstName = user.first_name || '';
|
||||
profileLastName = user.last_name || '';
|
||||
profileEmail = user.email || '';
|
||||
}
|
||||
});
|
||||
|
||||
// Handle form actions result
|
||||
$effect(() => {
|
||||
if (form) {
|
||||
isSubmitting = false;
|
||||
if (form.success) {
|
||||
isProfileDialogOpen = false;
|
||||
isPasswordDialogOpen = false;
|
||||
setAlert({
|
||||
type: 'success',
|
||||
title: $t('app.account.operation_successful'),
|
||||
message: $t('app.account.operation_successful'),
|
||||
duration: 3000,
|
||||
show: true
|
||||
});
|
||||
} else if (form.profileError || form.passwordError) {
|
||||
setAlert({
|
||||
type: 'error',
|
||||
title: $t('app.search.error'),
|
||||
message: form.message,
|
||||
duration: 3000,
|
||||
show: true
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
// Handle form actions result
|
||||
$effect(() => {
|
||||
if (form) {
|
||||
isSubmitting = false;
|
||||
if (form.success) {
|
||||
isProfileDialogOpen = false;
|
||||
isPasswordDialogOpen = false;
|
||||
setAlert({
|
||||
type: 'success',
|
||||
title: $t('app.account.operation_successful'),
|
||||
message: $t('app.account.operation_successful'),
|
||||
duration: 3000,
|
||||
show: true,
|
||||
});
|
||||
} else if (form.profileError || form.passwordError) {
|
||||
setAlert({
|
||||
type: 'error',
|
||||
title: $t('app.search.error'),
|
||||
message: form.message,
|
||||
duration: 3000,
|
||||
show: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function openProfileDialog() {
|
||||
isProfileDialogOpen = true;
|
||||
}
|
||||
function openProfileDialog() {
|
||||
isProfileDialogOpen = true;
|
||||
}
|
||||
|
||||
function openPasswordDialog() {
|
||||
currentPassword = '';
|
||||
newPassword = '';
|
||||
confirmNewPassword = '';
|
||||
isPasswordDialogOpen = true;
|
||||
}
|
||||
|
||||
function openPasswordDialog() {
|
||||
currentPassword = '';
|
||||
newPassword = '';
|
||||
confirmNewPassword = '';
|
||||
isPasswordDialogOpen = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -78,141 +77,195 @@
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">{$t('app.account.title')}</h1>
|
||||
<p class="text-muted-foreground">{$t('app.account.description')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">{$t('app.account.title')}</h1>
|
||||
<p class="text-muted-foreground">{$t('app.account.description')}</p>
|
||||
</div>
|
||||
|
||||
<!-- Personal Information -->
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>{$t('app.account.personal_info')}</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label class="text-muted-foreground">{$t('app.users.name')}</Label>
|
||||
<p class="text-sm font-medium">{user?.first_name} {user?.last_name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="text-muted-foreground">{$t('app.users.email')}</Label>
|
||||
<p class="text-sm font-medium">{user?.email}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="text-muted-foreground">{$t('app.users.role')}</Label>
|
||||
<p class="text-sm font-medium">{user?.role?.name || '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Content>
|
||||
<Card.Footer>
|
||||
<Button variant="outline" onclick={openProfileDialog}>{$t('app.account.edit_profile')}</Button>
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
<!-- Personal Information -->
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>{$t('app.account.personal_info')}</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label class="text-muted-foreground">{$t('app.users.name')}</Label>
|
||||
<p class="text-sm font-medium">{user?.first_name} {user?.last_name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="text-muted-foreground">{$t('app.users.email')}</Label>
|
||||
<p class="text-sm font-medium">{user?.email}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="text-muted-foreground">{$t('app.users.role')}</Label>
|
||||
<p class="text-sm font-medium">{user?.role?.name || '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Content>
|
||||
<Card.Footer>
|
||||
<Button variant="outline" onclick={openProfileDialog}
|
||||
>{$t('app.account.edit_profile')}</Button
|
||||
>
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
|
||||
<!-- Security -->
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>{$t('app.account.security')}</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<Label class="text-muted-foreground">{$t('app.auth.password')}</Label>
|
||||
<p class="text-sm">*************</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Content>
|
||||
<Card.Footer>
|
||||
<Button variant="outline" onclick={openPasswordDialog}>{$t('app.account.change_password')}</Button>
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
<!-- Security -->
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>{$t('app.account.security')}</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<Label class="text-muted-foreground">{$t('app.auth.password')}</Label>
|
||||
<p class="text-sm">*************</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Content>
|
||||
<Card.Footer>
|
||||
<Button variant="outline" onclick={openPasswordDialog}
|
||||
>{$t('app.account.change_password')}</Button
|
||||
>
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
</div>
|
||||
|
||||
<!-- Profile Edit Dialog -->
|
||||
<Dialog.Root bind:open={isProfileDialogOpen}>
|
||||
<Dialog.Content class="sm:max-w-[425px]">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('app.account.edit_profile')}</Dialog.Title>
|
||||
<Dialog.Description>{$t('app.account.edit_profile_desc')}</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<form method="POST" action="?/updateProfile" use:enhance={() => {
|
||||
isSubmitting = true;
|
||||
return async ({ update }) => {
|
||||
await update();
|
||||
isSubmitting = false;
|
||||
};
|
||||
}} class="grid gap-4 py-4">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="first_name" class="text-right">{$t('app.setup.first_name')}</Label>
|
||||
<Input id="first_name" name="first_name" bind:value={profileFirstName} class="col-span-3" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="last_name" class="text-right">{$t('app.setup.last_name')}</Label>
|
||||
<Input id="last_name" name="last_name" bind:value={profileLastName} class="col-span-3" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="email" class="text-right">{$t('app.users.email')}</Label>
|
||||
<Input id="email" name="email" type="email" bind:value={profileEmail} class="col-span-3" />
|
||||
</div>
|
||||
<Dialog.Footer>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{#if isSubmitting}
|
||||
{$t('app.components.common.submitting')}
|
||||
{:else}
|
||||
{$t('app.components.common.save')}
|
||||
{/if}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
<Dialog.Content class="sm:max-w-[425px]">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('app.account.edit_profile')}</Dialog.Title>
|
||||
<Dialog.Description>{$t('app.account.edit_profile_desc')}</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/updateProfile"
|
||||
use:enhance={() => {
|
||||
isSubmitting = true;
|
||||
return async ({ update }) => {
|
||||
await update();
|
||||
isSubmitting = false;
|
||||
};
|
||||
}}
|
||||
class="grid gap-4 py-4"
|
||||
>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="first_name" class="text-right">{$t('app.setup.first_name')}</Label>
|
||||
<Input
|
||||
id="first_name"
|
||||
name="first_name"
|
||||
bind:value={profileFirstName}
|
||||
class="col-span-3"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="last_name" class="text-right">{$t('app.setup.last_name')}</Label>
|
||||
<Input
|
||||
id="last_name"
|
||||
name="last_name"
|
||||
bind:value={profileLastName}
|
||||
class="col-span-3"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="email" class="text-right">{$t('app.users.email')}</Label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
bind:value={profileEmail}
|
||||
class="col-span-3"
|
||||
/>
|
||||
</div>
|
||||
<Dialog.Footer>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{#if isSubmitting}
|
||||
{$t('app.components.common.submitting')}
|
||||
{:else}
|
||||
{$t('app.components.common.save')}
|
||||
{/if}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<!-- Change Password Dialog -->
|
||||
<Dialog.Root bind:open={isPasswordDialogOpen}>
|
||||
<Dialog.Content class="sm:max-w-[425px]">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('app.account.change_password')}</Dialog.Title>
|
||||
<Dialog.Description>{$t('app.account.change_password_desc')}</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<form method="POST" action="?/updatePassword" use:enhance={({ cancel }) => {
|
||||
if (newPassword !== confirmNewPassword) {
|
||||
setAlert({
|
||||
type: 'error',
|
||||
title: $t('app.search.error'),
|
||||
message: $t('app.account.passwords_do_not_match'),
|
||||
duration: 3000,
|
||||
show: true
|
||||
});
|
||||
cancel();
|
||||
return;
|
||||
}
|
||||
isSubmitting = true;
|
||||
return async ({ update }) => {
|
||||
await update();
|
||||
isSubmitting = false;
|
||||
};
|
||||
}} class="grid gap-4 py-4">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="currentPassword" class="text-right">{$t('app.account.current_password')}</Label>
|
||||
<Input id="currentPassword" name="currentPassword" type="password" bind:value={currentPassword} class="col-span-3" required />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="newPassword" class="text-right">{$t('app.account.new_password')}</Label>
|
||||
<Input id="newPassword" name="newPassword" type="password" bind:value={newPassword} class="col-span-3" required />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="confirmNewPassword" class="text-right">{$t('app.account.confirm_new_password')}</Label>
|
||||
<Input id="confirmNewPassword" type="password" bind:value={confirmNewPassword} class="col-span-3" required />
|
||||
</div>
|
||||
<Dialog.Footer>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{#if isSubmitting}
|
||||
{$t('app.components.common.submitting')}
|
||||
{:else}
|
||||
{$t('app.components.common.save')}
|
||||
{/if}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
<Dialog.Content class="sm:max-w-[425px]">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('app.account.change_password')}</Dialog.Title>
|
||||
<Dialog.Description>{$t('app.account.change_password_desc')}</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/updatePassword"
|
||||
use:enhance={({ cancel }) => {
|
||||
if (newPassword !== confirmNewPassword) {
|
||||
setAlert({
|
||||
type: 'error',
|
||||
title: $t('app.search.error'),
|
||||
message: $t('app.account.passwords_do_not_match'),
|
||||
duration: 3000,
|
||||
show: true,
|
||||
});
|
||||
cancel();
|
||||
return;
|
||||
}
|
||||
isSubmitting = true;
|
||||
return async ({ update }) => {
|
||||
await update();
|
||||
isSubmitting = false;
|
||||
};
|
||||
}}
|
||||
class="grid gap-4 py-4"
|
||||
>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="currentPassword" class="text-right"
|
||||
>{$t('app.account.current_password')}</Label
|
||||
>
|
||||
<Input
|
||||
id="currentPassword"
|
||||
name="currentPassword"
|
||||
type="password"
|
||||
bind:value={currentPassword}
|
||||
class="col-span-3"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="newPassword" class="text-right">{$t('app.account.new_password')}</Label>
|
||||
<Input
|
||||
id="newPassword"
|
||||
name="newPassword"
|
||||
type="password"
|
||||
bind:value={newPassword}
|
||||
class="col-span-3"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="confirmNewPassword" class="text-right"
|
||||
>{$t('app.account.confirm_new_password')}</Label
|
||||
>
|
||||
<Input
|
||||
id="confirmNewPassword"
|
||||
type="password"
|
||||
bind:value={confirmNewPassword}
|
||||
class="col-span-3"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Dialog.Footer>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{#if isSubmitting}
|
||||
{$t('app.components.common.submitting')}
|
||||
{:else}
|
||||
{$t('app.components.common.save')}
|
||||
{/if}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
@@ -207,7 +207,9 @@
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} variant="ghost" class="h-8 w-8 p-0">
|
||||
<span class="sr-only">{$t('app.users.open_menu')}</span>
|
||||
<span class="sr-only"
|
||||
>{$t('app.users.open_menu')}</span
|
||||
>
|
||||
<MoreHorizontal class="h-4 w-4" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
|
||||
@@ -138,7 +138,9 @@
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} variant="ghost" class="h-8 w-8 p-0">
|
||||
<span class="sr-only">{$t('app.roles.open_menu')}</span>
|
||||
<span class="sr-only"
|
||||
>{$t('app.roles.open_menu')}</span
|
||||
>
|
||||
<MoreHorizontal class="h-4 w-4" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
|
||||
@@ -137,7 +137,9 @@
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} variant="ghost" class="h-8 w-8 p-0">
|
||||
<span class="sr-only">{$t('app.users.open_menu')}</span>
|
||||
<span class="sr-only"
|
||||
>{$t('app.users.open_menu')}</span
|
||||
>
|
||||
<MoreHorizontal class="h-4 w-4" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
|
||||
@@ -149,6 +149,8 @@ export interface IInitialImportJob {
|
||||
export interface IProcessMailboxJob {
|
||||
ingestionSourceId: string;
|
||||
userEmail: string;
|
||||
/** ID of the SyncSession tracking this sync cycle's progress */
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
export interface IPstProcessingJob {
|
||||
|
||||
@@ -4,4 +4,8 @@ export interface IntegrityCheckResult {
|
||||
filename?: string;
|
||||
isValid: boolean;
|
||||
reason?: string;
|
||||
/** SHA-256 hash stored at archival time. */
|
||||
storedHash: string;
|
||||
/** SHA-256 hash computed during this verification. */
|
||||
computedHash: string;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ export enum OpenArchiverFeature {
|
||||
AUDIT_LOG = 'audit-log',
|
||||
RETENTION_POLICY = 'retention-policy',
|
||||
LEGAL_HOLDS = 'legal-holds',
|
||||
INTEGRITY_REPORT = 'integrity-report',
|
||||
SSO = 'sso',
|
||||
STATUS = 'status',
|
||||
ALL = 'all',
|
||||
|
||||
Reference in New Issue
Block a user