mirror of
https://github.com/LogicLabs-OU/OpenArchiver.git
synced 2026-04-06 00:31:57 +02:00
Journaling OSS setup
This commit is contained in:
21
.env.example
21
.env.example
@@ -104,3 +104,24 @@ ENCRYPTION_KEY=
|
||||
# Apache Tika Integration
|
||||
# ONLY active if TIKA_URL is set
|
||||
TIKA_URL=http://tika:9998
|
||||
|
||||
|
||||
# Enterprise features (Skip this part if you are using the open-source version)
|
||||
|
||||
# Batch size for managing retention policy lifecycle. (This number of emails will be checked each time when retention policy scans the database. Adjust based on your system capability.)
|
||||
RETENTION_BATCH_SIZE=1000
|
||||
|
||||
# --- SMTP Journaling (Enterprise only) ---
|
||||
# The port the embedded SMTP journaling listener binds to inside the container.
|
||||
# This is the port your MTA (Exchange, MS365, Postfix, etc.) will send journal reports to.
|
||||
# The docker-compose.yml maps this same port on the host side by default.
|
||||
SMTP_JOURNALING_PORT=2525
|
||||
# The domain used to generate routing addresses for journaling sources.
|
||||
# Each source gets a unique address like journal-<id>@<domain>.
|
||||
# Set this to the domain/subdomain whose MX record points to this server.
|
||||
SMTP_JOURNALING_DOMAIN=journal.yourdomain.com
|
||||
# Maximum number of waiting jobs in the journal queue before the SMTP listener
|
||||
# returns 4xx temporary failures (backpressure). The MTA will retry automatically.
|
||||
JOURNAL_QUEUE_BACKPRESSURE_THRESHOLD=10000
|
||||
#BullMQ worker concurrency for processing journaled emails. Increase on servers with more CPU cores.
|
||||
JOURNAL_WORKER_CONCURRENCY=3
|
||||
@@ -6,7 +6,7 @@ services:
|
||||
container_name: open-archiver
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- '3000:3000' # Frontend
|
||||
- '${PORT_FRONTEND:-3000}:3000' # Frontend
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
@@ -42,7 +42,7 @@ services:
|
||||
- open-archiver-net
|
||||
|
||||
meilisearch:
|
||||
image: getmeili/meilisearch:v1.15
|
||||
image: getmeili/meilisearch:v1.38
|
||||
container_name: meilisearch
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
CREATE TYPE "public"."journaling_source_status" AS ENUM('active', 'paused');--> statement-breakpoint
|
||||
ALTER TYPE "public"."ingestion_provider" ADD VALUE 'smtp_journaling';--> statement-breakpoint
|
||||
ALTER TYPE "public"."audit_log_target_type" ADD VALUE 'JournalingSource' BEFORE 'RetentionPolicy';--> statement-breakpoint
|
||||
CREATE TABLE "journaling_sources" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"allowed_ips" jsonb NOT NULL,
|
||||
"require_tls" boolean DEFAULT true NOT NULL,
|
||||
"smtp_username" text,
|
||||
"smtp_password_hash" text,
|
||||
"status" "journaling_source_status" DEFAULT 'active' NOT NULL,
|
||||
"ingestion_source_id" uuid NOT NULL,
|
||||
"routing_address" text NOT NULL,
|
||||
"total_received" integer DEFAULT 0 NOT NULL,
|
||||
"last_received_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "journaling_sources" ADD CONSTRAINT "journaling_sources_ingestion_source_id_ingestion_sources_id_fk" FOREIGN KEY ("ingestion_source_id") REFERENCES "public"."ingestion_sources"("id") ON DELETE cascade ON UPDATE no action;
|
||||
1727
packages/backend/src/database/migrations/meta/0030_snapshot.json
Normal file
1727
packages/backend/src/database/migrations/meta/0030_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -211,6 +211,13 @@
|
||||
"when": 1773927678269,
|
||||
"tag": "0029_lethal_brood",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 30,
|
||||
"version": "7",
|
||||
"when": 1774440788278,
|
||||
"tag": "0030_strong_ultron",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -10,3 +10,4 @@ export * from './schema/api-keys';
|
||||
export * from './schema/audit-logs';
|
||||
export * from './schema/enums';
|
||||
export * from './schema/sync-sessions';
|
||||
export * from './schema/journaling-sources';
|
||||
|
||||
@@ -9,6 +9,7 @@ export const ingestionProviderEnum = pgEnum('ingestion_provider', [
|
||||
'pst_import',
|
||||
'eml_import',
|
||||
'mbox_import',
|
||||
'smtp_journaling',
|
||||
]);
|
||||
|
||||
export const ingestionStatusEnum = pgEnum('ingestion_status', [
|
||||
|
||||
47
packages/backend/src/database/schema/journaling-sources.ts
Normal file
47
packages/backend/src/database/schema/journaling-sources.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
boolean,
|
||||
integer,
|
||||
jsonb,
|
||||
pgEnum,
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
uuid,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { relations } from 'drizzle-orm';
|
||||
import { ingestionSources } from './ingestion-sources';
|
||||
|
||||
export const journalingSourceStatusEnum = pgEnum('journaling_source_status', ['active', 'paused']);
|
||||
|
||||
export const journalingSources = pgTable('journaling_sources', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
name: text('name').notNull(),
|
||||
/** CIDR blocks or IP addresses allowed to send journal reports */
|
||||
allowedIps: jsonb('allowed_ips').notNull().$type<string[]>(),
|
||||
/** Whether to reject non-TLS connections (GDPR compliance) */
|
||||
requireTls: boolean('require_tls').notNull().default(true),
|
||||
/** Optional SMTP AUTH username */
|
||||
smtpUsername: text('smtp_username'),
|
||||
/** Bcrypt-hashed SMTP AUTH password */
|
||||
smtpPasswordHash: text('smtp_password_hash'),
|
||||
status: journalingSourceStatusEnum('status').notNull().default('active'),
|
||||
/** The backing ingestion source that owns all archived emails */
|
||||
ingestionSourceId: uuid('ingestion_source_id')
|
||||
.notNull()
|
||||
.references(() => ingestionSources.id, { onDelete: 'cascade' }),
|
||||
/** Persisted SMTP routing address generated at creation time (immutable unless regenerated) */
|
||||
routingAddress: text('routing_address').notNull(),
|
||||
/** Running count of emails received via this journaling endpoint */
|
||||
totalReceived: integer('total_received').notNull().default(0),
|
||||
/** Timestamp of the last email received */
|
||||
lastReceivedAt: timestamp('last_received_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const journalingSourcesRelations = relations(journalingSources, ({ one }) => ({
|
||||
ingestionSource: one(ingestionSources, {
|
||||
fields: [journalingSources.ingestionSourceId],
|
||||
references: [ingestionSources.id],
|
||||
}),
|
||||
}));
|
||||
@@ -609,6 +609,64 @@
|
||||
"next": "Next",
|
||||
"ingestion_source": "Ingestion Source"
|
||||
},
|
||||
"journaling": {
|
||||
"title": "SMTP Journaling",
|
||||
"header": "SMTP Journaling Sources",
|
||||
"meta_description": "Configure real-time SMTP journaling endpoints for zero-gap email archiving from corporate MTAs.",
|
||||
"header_description": "Receive a real-time copy of every email directly from your mail server via SMTP journaling, ensuring zero data loss.",
|
||||
"create_new": "Create Source",
|
||||
"no_sources_found": "No journaling sources configured.",
|
||||
"name": "Name",
|
||||
"allowed_ips": "Allowed IPs / CIDR",
|
||||
"require_tls": "Require TLS",
|
||||
"smtp_username": "SMTP Username",
|
||||
"smtp_password": "SMTP Password",
|
||||
"status": "Status",
|
||||
"active": "Active",
|
||||
"paused": "Paused",
|
||||
"total_received": "Emails Received",
|
||||
"last_received_at": "Last Received",
|
||||
"created_at": "Created At",
|
||||
"actions": "Actions",
|
||||
"create": "Create",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"pause": "Pause",
|
||||
"activate": "Activate",
|
||||
"save": "Save Changes",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm Delete",
|
||||
"name_placeholder": "e.g. MS365 Journal Receiver",
|
||||
"allowed_ips_placeholder": "e.g. 40.107.0.0/16, 52.100.0.0/14",
|
||||
"allowed_ips_hint": "Comma-separated IP addresses or CIDR blocks of your mail server(s) that are permitted to send journal reports.",
|
||||
"smtp_username_placeholder": "e.g. journal-tenant-123",
|
||||
"smtp_password_placeholder": "Enter a strong password for SMTP AUTH",
|
||||
"smtp_auth_hint": "Optional. If set, the MTA must authenticate with these credentials when connecting.",
|
||||
"create_description": "Configure a new SMTP journaling endpoint. Your MTA will send journal reports to this endpoint for real-time archiving.",
|
||||
"edit_description": "Update the configuration for this journaling source.",
|
||||
"delete_confirmation_title": "Delete this journaling source?",
|
||||
"delete_confirmation_description": "This will permanently delete the journaling endpoint and all associated archived emails. Your MTA will no longer be able to deliver journal reports to this endpoint.",
|
||||
"deleting": "Deleting",
|
||||
"smtp_connection_info": "SMTP Connection Info",
|
||||
"smtp_host": "Host",
|
||||
"smtp_port": "Port",
|
||||
"routing_address": "Routing Address",
|
||||
"routing_address_hint": "Configure this address as the journal recipient in your MTA (Exchange, MS365, Postfix).",
|
||||
"regenerate_address": "Regenerate Address",
|
||||
"regenerate_address_warning": "This will invalidate the current address. You must update your MTA configuration to use the new address.",
|
||||
"regenerate_address_confirm": "Are you sure you want to regenerate the routing address? The current address will stop working immediately and you will need to update your MTA configuration.",
|
||||
"regenerate_address_success": "Routing address regenerated successfully. Update your MTA configuration with the new address.",
|
||||
"regenerate_address_error": "Failed to regenerate routing address.",
|
||||
"create_success": "Journaling source created successfully.",
|
||||
"update_success": "Journaling source updated successfully.",
|
||||
"delete_success": "Journaling source deleted successfully.",
|
||||
"create_error": "Failed to create journaling source.",
|
||||
"update_error": "Failed to update journaling source.",
|
||||
"delete_error": "Failed to delete journaling source.",
|
||||
"health_listening": "SMTP Listener: Active",
|
||||
"health_down": "SMTP Listener: Down",
|
||||
"never": "Never"
|
||||
},
|
||||
"license_page": {
|
||||
"title": "Enterprise License Status",
|
||||
"meta_description": "View the current status of your Open Archiver Enterprise license.",
|
||||
|
||||
@@ -75,6 +75,16 @@
|
||||
];
|
||||
|
||||
const enterpriseNavItems: NavItem[] = [
|
||||
{
|
||||
label: $t('app.archive.title'),
|
||||
subMenu: [
|
||||
{
|
||||
href: '/dashboard/ingestions/journaling',
|
||||
label: $t('app.journaling.title'),
|
||||
},
|
||||
],
|
||||
position: 1,
|
||||
},
|
||||
{
|
||||
label: 'Compliance',
|
||||
subMenu: [
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { api } from '$lib/server/api';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import type { JournalingSource } from '@open-archiver/types';
|
||||
|
||||
export const load: PageServerLoad = async (event) => {
|
||||
if (!event.locals.enterpriseMode) {
|
||||
throw error(
|
||||
403,
|
||||
'This feature is only available in the Enterprise Edition. Please contact Open Archiver to upgrade.'
|
||||
);
|
||||
}
|
||||
|
||||
const sourcesRes = await api('/enterprise/journaling', event);
|
||||
const sourcesJson = await sourcesRes.json();
|
||||
|
||||
if (!sourcesRes.ok) {
|
||||
throw error(sourcesRes.status, sourcesJson.message || JSON.stringify(sourcesJson));
|
||||
}
|
||||
|
||||
const sources: JournalingSource[] = sourcesJson;
|
||||
|
||||
// Fetch SMTP listener health status
|
||||
const healthRes = await api('/enterprise/journaling/health', event);
|
||||
const healthJson = (await healthRes.json()) as { smtp: string; port: string };
|
||||
|
||||
return {
|
||||
sources,
|
||||
smtpHealth: healthRes.ok ? healthJson : { smtp: 'down', port: '2525' },
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
create: async (event) => {
|
||||
const data = await event.request.formData();
|
||||
|
||||
const rawIps = (data.get('allowedIps') as string) || '';
|
||||
const allowedIps = rawIps
|
||||
.split(',')
|
||||
.map((ip) => ip.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
name: data.get('name') as string,
|
||||
allowedIps,
|
||||
requireTls: data.get('requireTls') === 'on',
|
||||
};
|
||||
|
||||
const smtpUsername = data.get('smtpUsername') as string;
|
||||
const smtpPassword = data.get('smtpPassword') as string;
|
||||
if (smtpUsername) body.smtpUsername = smtpUsername;
|
||||
if (smtpPassword) body.smtpPassword = smtpPassword;
|
||||
|
||||
const response = await api('/enterprise/journaling', event, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const res = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false,
|
||||
message: res.message || 'Failed to create journaling source.',
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
update: async (event) => {
|
||||
const data = await event.request.formData();
|
||||
const id = data.get('id') as string;
|
||||
|
||||
const rawIps = (data.get('allowedIps') as string) || '';
|
||||
const allowedIps = rawIps
|
||||
.split(',')
|
||||
.map((ip) => ip.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
name: data.get('name') as string,
|
||||
allowedIps,
|
||||
requireTls: data.get('requireTls') === 'on',
|
||||
};
|
||||
|
||||
const smtpUsername = data.get('smtpUsername') as string;
|
||||
const smtpPassword = data.get('smtpPassword') as string;
|
||||
if (smtpUsername) body.smtpUsername = smtpUsername;
|
||||
if (smtpPassword) body.smtpPassword = smtpPassword;
|
||||
|
||||
const response = await api(`/enterprise/journaling/${id}`, event, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const res = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false,
|
||||
message: res.message || 'Failed to update journaling source.',
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
toggleStatus: async (event) => {
|
||||
const data = await event.request.formData();
|
||||
const id = data.get('id') as string;
|
||||
const status = data.get('status') as string;
|
||||
|
||||
const response = await api(`/enterprise/journaling/${id}`, event, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
|
||||
const res = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
return { success: false, message: res.message || 'Failed to update status.' };
|
||||
}
|
||||
|
||||
return { success: true, status };
|
||||
},
|
||||
|
||||
regenerateAddress: async (event) => {
|
||||
const data = await event.request.formData();
|
||||
const id = data.get('id') as string;
|
||||
|
||||
const response = await api(`/enterprise/journaling/${id}/regenerate-address`, event, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const res = await response.json().catch(() => ({}));
|
||||
return {
|
||||
success: false,
|
||||
message:
|
||||
(res as { message?: string }).message ||
|
||||
'Failed to regenerate routing address.',
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
delete: async (event) => {
|
||||
const data = await event.request.formData();
|
||||
const id = data.get('id') as string;
|
||||
|
||||
const response = await api(`/enterprise/journaling/${id}`, event, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const res = await response.json().catch(() => ({}));
|
||||
return {
|
||||
success: false,
|
||||
message:
|
||||
(res as { message?: string }).message || 'Failed to delete journaling source.',
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,746 @@
|
||||
<script lang="ts">
|
||||
import type { ActionData, PageData } from './$types';
|
||||
import { t } from '$lib/translations';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { enhance } from '$app/forms';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import { MoreHorizontal, Plus, Radio, Mail, Copy, Check, RefreshCw } from 'lucide-svelte';
|
||||
import { setAlert } from '$lib/components/custom/alert/alert-state.svelte';
|
||||
import type { JournalingSource } from '@open-archiver/types';
|
||||
|
||||
let { data }: { data: PageData; form: ActionData } = $props();
|
||||
|
||||
let sources = $derived(data.sources);
|
||||
let smtpHealth = $derived(data.smtpHealth);
|
||||
|
||||
// --- Dialog state ---
|
||||
let isCreateOpen = $state(false);
|
||||
let isEditOpen = $state(false);
|
||||
let isDeleteOpen = $state(false);
|
||||
let isRegenerateOpen = $state(false);
|
||||
|
||||
let selectedSource = $state<JournalingSource | null>(null);
|
||||
let isFormLoading = $state(false);
|
||||
let copiedField = $state<string | null>(null);
|
||||
|
||||
function openEdit(source: JournalingSource) {
|
||||
selectedSource = source;
|
||||
isEditOpen = true;
|
||||
}
|
||||
|
||||
function openDelete(source: JournalingSource) {
|
||||
selectedSource = source;
|
||||
isDeleteOpen = true;
|
||||
}
|
||||
|
||||
async function copyToClipboard(text: string, field: string) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
copiedField = field;
|
||||
setTimeout(() => (copiedField = null), 2000);
|
||||
}
|
||||
|
||||
/** Programmatically submit the regenerateAddress action (avoids nested <form>). */
|
||||
async function handleRegenerateAddress(sourceId: string) {
|
||||
isFormLoading = true;
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.set('id', sourceId);
|
||||
|
||||
const res = await fetch('?/regenerateAddress', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
const result = await res.json();
|
||||
// SvelteKit actions return { type, status, data } wrapped structure
|
||||
const data = result?.data;
|
||||
const success = Array.isArray(data)
|
||||
? data[0]?.success !== false
|
||||
: data?.success !== false;
|
||||
|
||||
if (success) {
|
||||
setAlert({
|
||||
type: 'success',
|
||||
title: $t('app.journaling.regenerate_address_success'),
|
||||
message: '',
|
||||
duration: 5000,
|
||||
show: true,
|
||||
});
|
||||
} else {
|
||||
const msg = Array.isArray(data) ? data[0]?.message : data?.message;
|
||||
setAlert({
|
||||
type: 'error',
|
||||
title: $t('app.journaling.regenerate_address_error'),
|
||||
message: String(msg ?? ''),
|
||||
duration: 5000,
|
||||
show: true,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
setAlert({
|
||||
type: 'error',
|
||||
title: $t('app.journaling.regenerate_address_error'),
|
||||
message: '',
|
||||
duration: 5000,
|
||||
show: true,
|
||||
});
|
||||
} finally {
|
||||
isFormLoading = false;
|
||||
isEditOpen = false;
|
||||
selectedSource = null;
|
||||
// Re-run the load function to get updated data without a full page reload
|
||||
await invalidateAll();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$t('app.journaling.title')} - Open Archiver</title>
|
||||
<meta name="description" content={$t('app.journaling.meta_description')} />
|
||||
<meta
|
||||
name="keywords"
|
||||
content="SMTP journaling, email archiving, journal reports, MTA integration, Exchange journaling, zero-gap archiving"
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">{$t('app.journaling.header')}</h1>
|
||||
<p class="text-muted-foreground mt-1 text-sm">
|
||||
{$t('app.journaling.header_description')}
|
||||
</p>
|
||||
</div>
|
||||
<Button onclick={() => (isCreateOpen = true)}>
|
||||
<Plus class="mr-1.5 h-4 w-4" />
|
||||
{$t('app.journaling.create_new')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- SMTP Listener health badge -->
|
||||
<div class="mb-4 flex items-center gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<Radio
|
||||
class="h-4 w-4 {smtpHealth.smtp === 'listening'
|
||||
? 'text-green-500'
|
||||
: 'text-destructive'}"
|
||||
/>
|
||||
<span class="text-sm font-medium">
|
||||
{smtpHealth.smtp === 'listening'
|
||||
? $t('app.journaling.health_listening')
|
||||
: $t('app.journaling.health_down')}
|
||||
</span>
|
||||
</div>
|
||||
<Badge variant="outline" class="font-mono text-xs">
|
||||
{$t('app.journaling.smtp_port')}: {smtpHealth.port}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div class="rounded-md border">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head>{$t('app.journaling.name')}</Table.Head>
|
||||
<Table.Head>{$t('app.journaling.allowed_ips')}</Table.Head>
|
||||
<Table.Head>{$t('app.journaling.total_received')}</Table.Head>
|
||||
<Table.Head>{$t('app.journaling.status')}</Table.Head>
|
||||
<Table.Head>{$t('app.journaling.last_received_at')}</Table.Head>
|
||||
<Table.Head class="text-right">{$t('app.journaling.actions')}</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#if sources && sources.length > 0}
|
||||
{#each sources as source (source.id)}
|
||||
<Table.Row>
|
||||
<Table.Cell class="font-medium">
|
||||
<div>
|
||||
<div>{source.name}</div>
|
||||
<div class="mt-1 flex items-center gap-1">
|
||||
<code
|
||||
class="bg-muted rounded px-1.5 py-0.5 font-mono text-[11px]"
|
||||
>{source.routingAddress}</code
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="text-muted-foreground hover:text-foreground"
|
||||
onclick={() =>
|
||||
copyToClipboard(
|
||||
source.routingAddress,
|
||||
`route-${source.id}`
|
||||
)}
|
||||
>
|
||||
{#if copiedField === `route-${source.id}`}
|
||||
<Check class="h-3 w-3 text-green-500" />
|
||||
{:else}
|
||||
<Copy class="h-3 w-3" />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each source.allowedIps.slice(0, 3) as ip}
|
||||
<Badge variant="outline" class="font-mono text-[10px]">
|
||||
{ip}
|
||||
</Badge>
|
||||
{/each}
|
||||
{#if source.allowedIps.length > 3}
|
||||
<Badge variant="secondary" class="text-[10px]">
|
||||
+{source.allowedIps.length - 3}
|
||||
</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Mail class="text-muted-foreground h-3.5 w-3.5" />
|
||||
<Badge variant={source.totalReceived > 0 ? 'secondary' : 'outline'}>
|
||||
{source.totalReceived}
|
||||
</Badge>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{#if source.status === 'active'}
|
||||
<Badge class="bg-green-600 text-white">
|
||||
{$t('app.journaling.active')}
|
||||
</Badge>
|
||||
{:else}
|
||||
<Badge variant="secondary">
|
||||
{$t('app.journaling.paused')}
|
||||
</Badge>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{#if source.lastReceivedAt}
|
||||
{new Date(source.lastReceivedAt).toLocaleString()}
|
||||
{:else}
|
||||
<span class="text-muted-foreground text-xs italic">
|
||||
{$t('app.journaling.never')}
|
||||
</span>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-right">
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button
|
||||
{...props}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
aria-label={$t('app.ingestions.open_menu')}
|
||||
>
|
||||
<MoreHorizontal class="h-4 w-4" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item onclick={() => openEdit(source)}>
|
||||
{$t('app.journaling.edit')}
|
||||
</DropdownMenu.Item>
|
||||
<!-- Toggle active/paused -->
|
||||
<form
|
||||
method="POST"
|
||||
action="?/toggleStatus"
|
||||
use:enhance={() => {
|
||||
return async ({ result, update }) => {
|
||||
if (
|
||||
result.type === 'success' &&
|
||||
result.data?.success !== false
|
||||
) {
|
||||
setAlert({
|
||||
type: 'success',
|
||||
title: $t('app.journaling.update_success'),
|
||||
message: '',
|
||||
duration: 3000,
|
||||
show: true,
|
||||
});
|
||||
} else if (
|
||||
result.type === 'success' &&
|
||||
result.data?.success === false
|
||||
) {
|
||||
setAlert({
|
||||
type: 'error',
|
||||
title: $t('app.journaling.update_error'),
|
||||
message: String(result.data?.message ?? ''),
|
||||
duration: 5000,
|
||||
show: true,
|
||||
});
|
||||
}
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="id" value={source.id} />
|
||||
<input
|
||||
type="hidden"
|
||||
name="status"
|
||||
value={source.status === 'active' ? 'paused' : 'active'}
|
||||
/>
|
||||
<DropdownMenu.Item>
|
||||
<button type="submit" class="w-full text-left">
|
||||
{source.status === 'active'
|
||||
? $t('app.journaling.pause')
|
||||
: $t('app.journaling.activate')}
|
||||
</button>
|
||||
</DropdownMenu.Item>
|
||||
</form>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item
|
||||
class="text-destructive focus:text-destructive"
|
||||
onclick={() => openDelete(source)}
|
||||
>
|
||||
{$t('app.journaling.delete')}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
{:else}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={6} class="h-24 text-center">
|
||||
{$t('app.journaling.no_sources_found')}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/if}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
|
||||
<!-- Create dialog -->
|
||||
<Dialog.Root bind:open={isCreateOpen}>
|
||||
<Dialog.Content class="sm:max-w-[560px]">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('app.journaling.create')}</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
{$t('app.journaling.create_description')}
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/create"
|
||||
class="space-y-4"
|
||||
use:enhance={() => {
|
||||
isFormLoading = true;
|
||||
return async ({ result, update }) => {
|
||||
isFormLoading = false;
|
||||
if (result.type === 'success' && result.data?.success !== false) {
|
||||
isCreateOpen = false;
|
||||
setAlert({
|
||||
type: 'success',
|
||||
title: $t('app.journaling.create_success'),
|
||||
message: '',
|
||||
duration: 3000,
|
||||
show: true,
|
||||
});
|
||||
} else if (result.type === 'success' && result.data?.success === false) {
|
||||
setAlert({
|
||||
type: 'error',
|
||||
title: $t('app.journaling.create_error'),
|
||||
message: String(result.data?.message ?? ''),
|
||||
duration: 5000,
|
||||
show: true,
|
||||
});
|
||||
}
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<div class="space-y-1.5">
|
||||
<Label for="create-name">{$t('app.journaling.name')}</Label>
|
||||
<Input
|
||||
id="create-name"
|
||||
name="name"
|
||||
required
|
||||
placeholder={$t('app.journaling.name_placeholder')}
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label for="create-ips">{$t('app.journaling.allowed_ips')}</Label>
|
||||
<Input
|
||||
id="create-ips"
|
||||
name="allowedIps"
|
||||
required
|
||||
placeholder={$t('app.journaling.allowed_ips_placeholder')}
|
||||
/>
|
||||
<p class="text-muted-foreground text-xs">
|
||||
{$t('app.journaling.allowed_ips_hint')}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="create-tls"
|
||||
name="requireTls"
|
||||
class="h-4 w-4 rounded border"
|
||||
checked
|
||||
/>
|
||||
<Label for="create-tls">{$t('app.journaling.require_tls')}</Label>
|
||||
</div>
|
||||
<div class="space-y-3 rounded-md border p-3">
|
||||
<p class="text-muted-foreground text-xs font-medium">
|
||||
{$t('app.journaling.smtp_auth_hint')}
|
||||
</p>
|
||||
<div class="space-y-1.5">
|
||||
<Label for="create-username">{$t('app.journaling.smtp_username')}</Label>
|
||||
<Input
|
||||
id="create-username"
|
||||
name="smtpUsername"
|
||||
placeholder={$t('app.journaling.smtp_username_placeholder')}
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label for="create-password">{$t('app.journaling.smtp_password')}</Label>
|
||||
<Input
|
||||
id="create-password"
|
||||
name="smtpPassword"
|
||||
type="password"
|
||||
placeholder={$t('app.journaling.smtp_password_placeholder')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onclick={() => (isCreateOpen = false)}
|
||||
disabled={isFormLoading}
|
||||
>
|
||||
{$t('app.journaling.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isFormLoading}>
|
||||
{#if isFormLoading}
|
||||
{$t('app.common.working')}
|
||||
{:else}
|
||||
{$t('app.journaling.create')}
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<!-- Edit dialog -->
|
||||
<Dialog.Root bind:open={isEditOpen}>
|
||||
<Dialog.Content class="sm:max-w-[560px]">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('app.journaling.edit')}</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
{$t('app.journaling.edit_description')}
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
{#if selectedSource}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/update"
|
||||
class="space-y-4"
|
||||
use:enhance={() => {
|
||||
isFormLoading = true;
|
||||
return async ({ result, update }) => {
|
||||
isFormLoading = false;
|
||||
if (result.type === 'success' && result.data?.success !== false) {
|
||||
isEditOpen = false;
|
||||
selectedSource = null;
|
||||
setAlert({
|
||||
type: 'success',
|
||||
title: $t('app.journaling.update_success'),
|
||||
message: '',
|
||||
duration: 3000,
|
||||
show: true,
|
||||
});
|
||||
} else if (result.type === 'success' && result.data?.success === false) {
|
||||
setAlert({
|
||||
type: 'error',
|
||||
title: $t('app.journaling.update_error'),
|
||||
message: String(result.data?.message ?? ''),
|
||||
duration: 5000,
|
||||
show: true,
|
||||
});
|
||||
}
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="id" value={selectedSource.id} />
|
||||
|
||||
<!-- SMTP Connection Info (read-only) -->
|
||||
<div class="bg-muted/30 rounded-md border p-3">
|
||||
<p class="mb-2 text-xs font-medium">
|
||||
{$t('app.journaling.smtp_connection_info')}
|
||||
</p>
|
||||
|
||||
<!-- Routing Address (most important) -->
|
||||
<div class="mb-3">
|
||||
<span class="text-muted-foreground text-[10px]"
|
||||
>{$t('app.journaling.routing_address')}</span
|
||||
>
|
||||
<div class="mt-0.5 flex items-center gap-1.5">
|
||||
<code class="bg-muted rounded px-2 py-1 font-mono text-sm font-medium"
|
||||
>{selectedSource.routingAddress}</code
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="text-muted-foreground hover:text-foreground"
|
||||
onclick={() =>
|
||||
copyToClipboard(
|
||||
selectedSource?.routingAddress ?? '',
|
||||
'routing'
|
||||
)}
|
||||
>
|
||||
{#if copiedField === 'routing'}
|
||||
<Check class="h-3.5 w-3.5 text-green-500" />
|
||||
{:else}
|
||||
<Copy class="h-3.5 w-3.5" />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-muted-foreground mt-1 text-[10px]">
|
||||
{$t('app.journaling.routing_address_hint')}
|
||||
</p>
|
||||
<!-- Regenerate address — opens confirmation dialog -->
|
||||
<div class="mt-2 flex items-start gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-7 text-[11px]"
|
||||
disabled={isFormLoading}
|
||||
onclick={() => (isRegenerateOpen = true)}
|
||||
>
|
||||
<RefreshCw class="mr-1 h-3 w-3" />
|
||||
{$t('app.journaling.regenerate_address')}
|
||||
</Button>
|
||||
<p class="text-destructive flex-1 text-[10px] leading-tight">
|
||||
{$t('app.journaling.regenerate_address_warning')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<span class="text-muted-foreground text-[10px]"
|
||||
>{$t('app.journaling.smtp_host')}</span
|
||||
>
|
||||
<div class="flex items-center gap-1">
|
||||
<code class="text-xs"
|
||||
>{typeof window !== 'undefined'
|
||||
? window.location.hostname
|
||||
: 'localhost'}</code
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="text-muted-foreground hover:text-foreground"
|
||||
onclick={() =>
|
||||
copyToClipboard(
|
||||
typeof window !== 'undefined'
|
||||
? window.location.hostname
|
||||
: 'localhost',
|
||||
'host'
|
||||
)}
|
||||
>
|
||||
{#if copiedField === 'host'}
|
||||
<Check class="h-3 w-3 text-green-500" />
|
||||
{:else}
|
||||
<Copy class="h-3 w-3" />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-muted-foreground text-[10px]"
|
||||
>{$t('app.journaling.smtp_port')}</span
|
||||
>
|
||||
<div class="flex items-center gap-1">
|
||||
<code class="text-xs">{smtpHealth.port}</code>
|
||||
<button
|
||||
type="button"
|
||||
class="text-muted-foreground hover:text-foreground"
|
||||
onclick={() => copyToClipboard(smtpHealth.port, 'port')}
|
||||
>
|
||||
{#if copiedField === 'port'}
|
||||
<Check class="h-3 w-3 text-green-500" />
|
||||
{:else}
|
||||
<Copy class="h-3 w-3" />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label for="edit-name">{$t('app.journaling.name')}</Label>
|
||||
<Input id="edit-name" name="name" required value={selectedSource.name} />
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label for="edit-ips">{$t('app.journaling.allowed_ips')}</Label>
|
||||
<Input
|
||||
id="edit-ips"
|
||||
name="allowedIps"
|
||||
required
|
||||
value={selectedSource.allowedIps.join(', ')}
|
||||
/>
|
||||
<p class="text-muted-foreground text-xs">
|
||||
{$t('app.journaling.allowed_ips_hint')}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="edit-tls"
|
||||
name="requireTls"
|
||||
class="h-4 w-4 rounded border"
|
||||
checked={selectedSource.requireTls}
|
||||
/>
|
||||
<Label for="edit-tls">{$t('app.journaling.require_tls')}</Label>
|
||||
</div>
|
||||
<div class="space-y-3 rounded-md border p-3">
|
||||
<p class="text-muted-foreground text-xs font-medium">
|
||||
{$t('app.journaling.smtp_auth_hint')}
|
||||
</p>
|
||||
<div class="space-y-1.5">
|
||||
<Label for="edit-username">{$t('app.journaling.smtp_username')}</Label>
|
||||
<Input
|
||||
id="edit-username"
|
||||
name="smtpUsername"
|
||||
value={selectedSource.smtpUsername ?? ''}
|
||||
placeholder={$t('app.journaling.smtp_username_placeholder')}
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label for="edit-password">{$t('app.journaling.smtp_password')}</Label>
|
||||
<Input
|
||||
id="edit-password"
|
||||
name="smtpPassword"
|
||||
type="password"
|
||||
placeholder={$t('app.journaling.smtp_password_placeholder')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onclick={() => (isEditOpen = false)}
|
||||
disabled={isFormLoading}
|
||||
>
|
||||
{$t('app.journaling.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isFormLoading}>
|
||||
{#if isFormLoading}
|
||||
{$t('app.common.working')}
|
||||
{:else}
|
||||
{$t('app.journaling.save')}
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<!-- Delete confirmation dialog -->
|
||||
<Dialog.Root bind:open={isDeleteOpen}>
|
||||
<Dialog.Content>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('app.journaling.delete_confirmation_title')}</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
{$t('app.journaling.delete_confirmation_description')}
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<Dialog.Footer>
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => (isDeleteOpen = false)}
|
||||
disabled={isFormLoading}
|
||||
>
|
||||
{$t('app.journaling.cancel')}
|
||||
</Button>
|
||||
{#if selectedSource}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/delete"
|
||||
use:enhance={() => {
|
||||
isFormLoading = true;
|
||||
return async ({ result, update }) => {
|
||||
isFormLoading = false;
|
||||
if (result.type === 'success' && result.data?.success !== false) {
|
||||
isDeleteOpen = false;
|
||||
setAlert({
|
||||
type: 'success',
|
||||
title: $t('app.journaling.delete_success'),
|
||||
message: '',
|
||||
duration: 3000,
|
||||
show: true,
|
||||
});
|
||||
selectedSource = null;
|
||||
} else {
|
||||
setAlert({
|
||||
type: 'error',
|
||||
title: $t('app.journaling.delete_error'),
|
||||
message:
|
||||
result.type === 'success'
|
||||
? String(result.data?.message ?? '')
|
||||
: '',
|
||||
duration: 5000,
|
||||
show: true,
|
||||
});
|
||||
}
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="id" value={selectedSource.id} />
|
||||
<Button type="submit" variant="destructive" disabled={isFormLoading}>
|
||||
{#if isFormLoading}
|
||||
{$t('app.journaling.deleting')}
|
||||
{:else}
|
||||
{$t('app.journaling.confirm')}
|
||||
{/if}
|
||||
</Button>
|
||||
</form>
|
||||
{/if}
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<!-- Regenerate address confirmation dialog -->
|
||||
<Dialog.Root bind:open={isRegenerateOpen}>
|
||||
<Dialog.Content>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('app.journaling.regenerate_address')}</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
{$t('app.journaling.regenerate_address_confirm')}
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<Dialog.Footer>
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => (isRegenerateOpen = false)}
|
||||
disabled={isFormLoading}
|
||||
>
|
||||
{$t('app.journaling.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={isFormLoading}
|
||||
onclick={() => {
|
||||
isRegenerateOpen = false;
|
||||
handleRegenerateAddress(selectedSource?.id ?? '');
|
||||
}}
|
||||
>
|
||||
{#if isFormLoading}
|
||||
{$t('app.common.working')}
|
||||
{:else}
|
||||
{$t('app.journaling.regenerate_address')}
|
||||
{/if}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -27,6 +27,7 @@ export const AuditLogTargetTypes = [
|
||||
'ArchivedEmail',
|
||||
'Dashboard',
|
||||
'IngestionSource',
|
||||
'JournalingSource',
|
||||
'RetentionPolicy',
|
||||
'RetentionLabel',
|
||||
'LegalHold',
|
||||
|
||||
@@ -14,3 +14,4 @@ export * from './integrity.types';
|
||||
export * from './jobs.types';
|
||||
export * from './license.types';
|
||||
export * from './retention.types';
|
||||
export * from './journaling.types';
|
||||
|
||||
@@ -24,7 +24,8 @@ export type IngestionProvider =
|
||||
| 'generic_imap'
|
||||
| 'pst_import'
|
||||
| 'eml_import'
|
||||
| 'mbox_import';
|
||||
| 'mbox_import'
|
||||
| 'smtp_journaling';
|
||||
|
||||
export type IngestionStatus =
|
||||
| 'active'
|
||||
@@ -91,6 +92,12 @@ export interface MboxImportCredentials extends BaseIngestionCredentials {
|
||||
localFilePath?: string;
|
||||
}
|
||||
|
||||
export interface SmtpJournalingCredentials extends BaseIngestionCredentials {
|
||||
type: 'smtp_journaling';
|
||||
/** The ID of the journaling_sources row that owns this ingestion source */
|
||||
journalingSourceId: string;
|
||||
}
|
||||
|
||||
// Discriminated union for all possible credential types
|
||||
export type IngestionCredentials =
|
||||
| GenericImapCredentials
|
||||
@@ -98,7 +105,8 @@ export type IngestionCredentials =
|
||||
| Microsoft365Credentials
|
||||
| PSTImportCredentials
|
||||
| EMLImportCredentials
|
||||
| MboxImportCredentials;
|
||||
| MboxImportCredentials
|
||||
| SmtpJournalingCredentials;
|
||||
|
||||
export interface IngestionSource {
|
||||
id: string;
|
||||
|
||||
65
packages/types/src/journaling.types.ts
Normal file
65
packages/types/src/journaling.types.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/** Status of a journaling source's SMTP listener */
|
||||
export type JournalingSourceStatus = 'active' | 'paused';
|
||||
|
||||
/** Represents a configured journaling source */
|
||||
export interface JournalingSource {
|
||||
id: string;
|
||||
name: string;
|
||||
/** CIDR blocks or IP addresses allowed to send journal reports */
|
||||
allowedIps: string[];
|
||||
/** Whether to reject plain-text (non-TLS) connections */
|
||||
requireTls: boolean;
|
||||
/** Optional SMTP AUTH username for the journal endpoint */
|
||||
smtpUsername: string | null;
|
||||
status: JournalingSourceStatus;
|
||||
/** The backing ingestion source ID that owns archived emails */
|
||||
ingestionSourceId: string;
|
||||
/**
|
||||
* The SMTP routing address the admin must configure in their MTA
|
||||
* (e.g. journal-abc12345@archive.yourdomain.com).
|
||||
* Computed server-side from the source ID and SMTP_JOURNALING_DOMAIN.
|
||||
*/
|
||||
routingAddress: string;
|
||||
/** Total number of emails received via this journaling source */
|
||||
totalReceived: number;
|
||||
/** Timestamp of the last email received */
|
||||
lastReceivedAt: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
/** DTO for creating a new journaling source */
|
||||
export interface CreateJournalingSourceDto {
|
||||
name: string;
|
||||
allowedIps: string[];
|
||||
requireTls?: boolean;
|
||||
smtpUsername?: string;
|
||||
smtpPassword?: string;
|
||||
}
|
||||
|
||||
/** DTO for updating an existing journaling source */
|
||||
export interface UpdateJournalingSourceDto {
|
||||
name?: string;
|
||||
allowedIps?: string[];
|
||||
requireTls?: boolean;
|
||||
status?: JournalingSourceStatus;
|
||||
smtpUsername?: string;
|
||||
smtpPassword?: string;
|
||||
}
|
||||
|
||||
/** Job data for the journal-inbound BullMQ job */
|
||||
export interface IJournalInboundJob {
|
||||
/** The journaling source ID that received the email */
|
||||
journalingSourceId: string;
|
||||
/**
|
||||
* Path to the temp file containing the raw email data on the local filesystem.
|
||||
* Raw emails are written to disk instead of embedded in the Redis job payload
|
||||
* to avoid Redis memory pressure (base64 inflates 50MB → ~67MB per job).
|
||||
* The worker is responsible for deleting this file after processing.
|
||||
*/
|
||||
tempFilePath: string;
|
||||
/** IP address of the sending MTA */
|
||||
remoteAddress: string;
|
||||
/** Timestamp when the SMTP listener received the email */
|
||||
receivedAt: string;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ export enum OpenArchiverFeature {
|
||||
RETENTION_POLICY = 'retention-policy',
|
||||
LEGAL_HOLDS = 'legal-holds',
|
||||
INTEGRITY_REPORT = 'integrity-report',
|
||||
JOURNALING = 'journaling',
|
||||
SSO = 'sso',
|
||||
STATUS = 'status',
|
||||
ALL = 'all',
|
||||
|
||||
Reference in New Issue
Block a user