Journaling OSS setup

This commit is contained in:
wayneshn
2026-03-27 13:14:38 +01:00
parent d99494e030
commit 970f28cc11
17 changed files with 2886 additions and 4 deletions

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -211,6 +211,13 @@
"when": 1773927678269,
"tag": "0029_lethal_brood",
"breakpoints": true
},
{
"idx": 30,
"version": "7",
"when": 1774440788278,
"tag": "0030_strong_ultron",
"breakpoints": true
}
]
}

View File

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

View File

@@ -9,6 +9,7 @@ export const ingestionProviderEnum = pgEnum('ingestion_provider', [
'pst_import',
'eml_import',
'mbox_import',
'smtp_journaling',
]);
export const ingestionStatusEnum = pgEnum('ingestion_status', [

View File

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

View File

@@ -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.",

View File

@@ -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: [

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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