Integrity report PDF generation

This commit is contained in:
wayneshn
2026-03-14 11:52:17 +01:00
parent ad03d84d1b
commit bb1e4b901a
6 changed files with 101 additions and 26 deletions

View File

@@ -11,3 +11,4 @@ export { AuditService } from './services/AuditService';
export * from './config'
export * from './jobs/queues'
export { RetentionHook } from './hooks/RetentionHook';
export { IntegrityService } from './services/IntegrityService';

View File

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

View File

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

View File

@@ -16,7 +16,7 @@
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 +65,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 +146,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 +231,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 text-sm space-y-2">
{#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 +252,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 +260,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>
@@ -347,6 +383,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 +410,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>
@@ -791,17 +854,6 @@
{/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>

View File

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

View File

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