diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 0b94706..59a0984 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -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'; diff --git a/packages/backend/src/services/IntegrityService.ts b/packages/backend/src/services/IntegrityService.ts index 5e8fd30..4020f34 100644 --- a/packages/backend/src/services/IntegrityService.ts +++ b/packages/backend/src/services/IntegrityService.ts @@ -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: "", }); } } diff --git a/packages/frontend/src/lib/translations/en.json b/packages/frontend/src/lib/translations/en.json index f11998e..0268d86 100644 --- a/packages/frontend/src/lib/translations/en.json +++ b/packages/frontend/src/lib/translations/en.json @@ -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", diff --git a/packages/frontend/src/routes/dashboard/archived-emails/[id]/+page.svelte b/packages/frontend/src/routes/dashboard/archived-emails/[id]/+page.svelte index 42ff690..a02abda 100644 --- a/packages/frontend/src/routes/dashboard/archived-emails/[id]/+page.svelte +++ b/packages/frontend/src/routes/dashboard/archived-emails/[id]/+page.svelte @@ -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(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 @@

{$t('app.archive.recipients')}

- -

- {$t('app.archive.to')}: {email.recipients - .map((r) => r.email || r.name) - .join(', ')} -

-
+

+ {$t('app.archive.to')}: {email.recipients + .map((r) => r.email || r.name) + .join(', ')} +

-
+

{$t('app.archive.meta_data')}

- +
{#if email.path}
{$t('app.archive.folder')}: - {email.path || '/'}
@@ -216,7 +252,7 @@
{$t('app.archive.tags')}: {#each email.tags as tag} - {tag} {/each} @@ -224,11 +260,11 @@ {/if}
{$t('app.archive.size')}: - {formatBytes(email.sizeBytes)}
- +

{$t('app.archive.email_preview')}

@@ -347,6 +383,22 @@ {/each} + {#if enterpriseMode} + + {/if} {:else} @@ -358,6 +410,17 @@ {/if} + + {#if email.thread && email.thread.length > 1} + + + {$t('app.archive.email_thread')} + + + + + + {/if} {#if enterpriseMode} @@ -791,17 +854,6 @@ {/if} - - {#if email.thread && email.thread.length > 1} - - - {$t('app.archive.email_thread')} - - - - - - {/if}
diff --git a/packages/types/src/integrity.types.ts b/packages/types/src/integrity.types.ts index cb08b3d..4c5041d 100644 --- a/packages/types/src/integrity.types.ts +++ b/packages/types/src/integrity.types.ts @@ -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; } diff --git a/packages/types/src/license.types.ts b/packages/types/src/license.types.ts index e96472f..b2f269c 100644 --- a/packages/types/src/license.types.ts +++ b/packages/types/src/license.types.ts @@ -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',