Retention policy (#329)

* Retention policy schema/types

* schema generate

* retention policy (backend/frontend)
This commit is contained in:
Wei S.
2026-03-09 18:31:38 +01:00
committed by GitHub
parent b5f95760f4
commit 81b87b4b7e
19 changed files with 3443 additions and 16 deletions

View File

@@ -0,0 +1,3 @@
ALTER TYPE "public"."audit_log_target_type" ADD VALUE 'RetentionPolicy' BEFORE 'Role';--> statement-breakpoint
ALTER TYPE "public"."audit_log_target_type" ADD VALUE 'SystemEvent' BEFORE 'SystemSettings';--> statement-breakpoint
ALTER TABLE "retention_policies" ADD COLUMN "ingestion_scope" jsonb DEFAULT 'null'::jsonb;

File diff suppressed because it is too large Load Diff

View File

@@ -176,6 +176,13 @@
"when": 1772842674479,
"tag": "0024_careful_black_panther",
"breakpoints": true
},
{
"idx": 25,
"version": "7",
"when": 1773013461190,
"tag": "0025_peaceful_grim_reaper",
"breakpoints": true
}
]
}

View File

@@ -12,7 +12,6 @@ import {
varchar,
} from 'drizzle-orm/pg-core';
import { archivedEmails } from './archived-emails';
import { custodians } from './custodians';
import { users } from './users';
// --- Enums ---
@@ -33,6 +32,11 @@ export const retentionPolicies = pgTable('retention_policies', {
actionOnExpiry: retentionActionEnum('action_on_expiry').notNull(),
isEnabled: boolean('is_enabled').notNull().default(true),
conditions: jsonb('conditions'),
/**
* Array of ingestion source UUIDs this policy is restricted to.
* null means the policy applies to all ingestion sources.
*/
ingestionScope: jsonb('ingestion_scope').$type<string[] | null>().default(null),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
});

View File

@@ -6,5 +6,7 @@ export * from './services/AuditService';
export * from './api/middleware/requireAuth';
export * from './api/middleware/requirePermission';
export { db } from './database';
export * as drizzleOrm from 'drizzle-orm';
export * from './database/schema';
export { AuditService } from './services/AuditService';
export * from './config'
export * from './jobs/queues'

View File

@@ -27,3 +27,9 @@ export const indexingQueue = new Queue('indexing', {
connection,
defaultJobOptions,
});
// Queue for the Data Lifecycle Manager (retention policy enforcement)
export const complianceLifecycleQueue = new Queue('compliance-lifecycle', {
connection,
defaultJobOptions,
});

View File

@@ -199,7 +199,13 @@ export class ArchivedEmailService {
emailId: string,
actor: User,
actorIp: string,
options: { systemDelete?: boolean } = {}
options: {
systemDelete?: boolean;
/**
* Human-readable name of the retention rule that triggered deletion
*/
governingRule?: string;
} = {}
): Promise<void> {
checkDeletionEnabled({ allowSystemDelete: options.systemDelete });
@@ -270,15 +276,22 @@ export class ArchivedEmailService {
await db.delete(archivedEmails).where(eq(archivedEmails.id, emailId));
// Build audit details: system-initiated deletions carry retention context
// for GoBD compliance; manual deletions record only the reason.
const auditDetails: Record<string, unknown> = {
reason: options.systemDelete ? 'RetentionExpiration' : 'ManualDeletion',
};
if (options.systemDelete && options.governingRule) {
auditDetails.governingRule = options.governingRule;
}
await this.auditService.createAuditLog({
actorIdentifier: actor.id,
actionType: 'DELETE',
targetType: 'ArchivedEmail',
targetId: emailId,
actorIp,
details: {
reason: 'ManualDeletion',
},
details: auditDetails,
});
}
}

View File

@@ -0,0 +1,366 @@
<script lang="ts">
import { t } from '$lib/translations';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Button } from '$lib/components/ui/button';
import { Switch } from '$lib/components/ui/switch';
import { Badge } from '$lib/components/ui/badge';
import * as Select from '$lib/components/ui/select/index.js';
import { enhance } from '$app/forms';
import { Trash2, Plus, Database } from 'lucide-svelte';
import type {
RetentionPolicy,
RetentionRule,
ConditionField,
ConditionOperator,
LogicalOperator,
SafeIngestionSource,
} from '@open-archiver/types';
interface Props {
/** Existing policy to edit; undefined means create mode */
policy?: RetentionPolicy;
isLoading?: boolean;
/** All available ingestion sources for scope selection */
ingestionSources?: SafeIngestionSource[];
/** Form action to target, e.g. '?/create' or '?/update' */
action: string;
onCancel: () => void;
/** Called after successful submission so the parent can close the dialog */
onSuccess: () => void;
}
let {
policy,
isLoading = $bindable(false),
ingestionSources = [],
action,
onCancel,
onSuccess,
}: Props = $props();
// --- Form state ---
let name = $state(policy?.name ?? '');
let description = $state(policy?.description ?? '');
let priority = $state(policy?.priority ?? 10);
let retentionPeriodDays = $state(policy?.retentionPeriodDays ?? 365);
let isEnabled = $state(policy?.isActive ?? true);
// Conditions state
let logicalOperator = $state<LogicalOperator>(
policy?.conditions?.logicalOperator ?? 'AND'
);
let rules = $state<RetentionRule[]>(
policy?.conditions?.rules ? [...policy.conditions.rules] : []
);
// Ingestion scope: set of selected ingestion source IDs
// Empty set = null scope = applies to all
let selectedIngestionIds = $state<Set<string>>(
new Set(policy?.ingestionScope ?? [])
);
// The conditions JSON that gets sent as a hidden form field
const conditionsJson = $derived(JSON.stringify({ logicalOperator, rules }));
// The ingestionScope value: comma-separated UUIDs, or empty string for null (all)
const ingestionScopeValue = $derived(
selectedIngestionIds.size > 0 ? [...selectedIngestionIds].join(',') : ''
);
// --- Field options ---
const fieldOptions: { value: ConditionField; label: string }[] = [
{ value: 'sender', label: $t('app.retention_policies.field_sender') },
{ value: 'recipient', label: $t('app.retention_policies.field_recipient') },
{ value: 'subject', label: $t('app.retention_policies.field_subject') },
{ value: 'attachment_type', label: $t('app.retention_policies.field_attachment_type') },
];
// --- Operator options (grouped for readability) ---
const operatorOptions: { value: ConditionOperator; label: string }[] = [
{ value: 'equals', label: $t('app.retention_policies.operator_equals') },
{ value: 'not_equals', label: $t('app.retention_policies.operator_not_equals') },
{ value: 'contains', label: $t('app.retention_policies.operator_contains') },
{ value: 'not_contains', label: $t('app.retention_policies.operator_not_contains') },
{ value: 'starts_with', label: $t('app.retention_policies.operator_starts_with') },
{ value: 'ends_with', label: $t('app.retention_policies.operator_ends_with') },
{ value: 'domain_match', label: $t('app.retention_policies.operator_domain_match') },
{ value: 'regex_match', label: $t('app.retention_policies.operator_regex_match') },
];
function addRule() {
rules = [...rules, { field: 'sender', operator: 'contains', value: '' }];
}
function removeRule(index: number) {
rules = rules.filter((_, i) => i !== index);
}
function updateRuleField(index: number, field: ConditionField) {
rules = rules.map((r, i) => (i === index ? { ...r, field } : r));
}
function updateRuleOperator(index: number, operator: ConditionOperator) {
rules = rules.map((r, i) => (i === index ? { ...r, operator } : r));
}
function updateRuleValue(index: number, value: string) {
rules = rules.map((r, i) => (i === index ? { ...r, value } : r));
}
function toggleIngestionSource(id: string) {
const next = new Set(selectedIngestionIds);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
selectedIngestionIds = next;
}
</script>
<form
method="POST"
{action}
class="space-y-5"
use:enhance={() => {
isLoading = true;
return async ({ result, update }) => {
isLoading = false;
if (result.type === 'success') {
onSuccess();
}
await update({ reset: false });
};
}}
>
<!-- Hidden fields for policy id (edit mode), serialized conditions, and ingestion scope -->
{#if policy}
<input type="hidden" name="id" value={policy.id} />
{/if}
<input type="hidden" name="conditions" value={conditionsJson} />
<input type="hidden" name="ingestionScope" value={ingestionScopeValue} />
<!-- isEnabled as hidden field since Switch is not a native input -->
<input type="hidden" name="isEnabled" value={String(isEnabled)} />
<!-- Name -->
<div class="space-y-1.5">
<Label for="rp-name">{$t('app.retention_policies.name')}</Label>
<Input
id="rp-name"
name="name"
bind:value={name}
required
placeholder="e.g. Legal Department 7-Year"
/>
</div>
<!-- Description -->
<div class="space-y-1.5">
<Label for="rp-description">{$t('app.retention_policies.description')}</Label>
<Input
id="rp-description"
name="description"
bind:value={description}
placeholder="Optional description"
/>
</div>
<div class="grid grid-cols-2 gap-4">
<!-- Priority -->
<div class="space-y-1.5">
<Label for="rp-priority">{$t('app.retention_policies.priority')}</Label>
<Input
id="rp-priority"
name="priority"
type="number"
min={1}
bind:value={priority}
required
/>
</div>
<!-- Retention Period -->
<div class="space-y-1.5">
<Label for="rp-days">{$t('app.retention_policies.retention_period_days')}</Label>
<Input
id="rp-days"
name="retentionPeriodDays"
type="number"
min={1}
bind:value={retentionPeriodDays}
required
/>
</div>
</div>
<!-- Action on Expiry (fixed to delete_permanently for Phase 1) -->
<div class="space-y-1.5">
<Label>{$t('app.retention_policies.action_on_expiry')}</Label>
<Input value={$t('app.retention_policies.delete_permanently')} disabled />
</div>
<!-- Enabled toggle — value written to hidden input above -->
<div class="flex items-center gap-3">
<Switch
id="rp-enabled"
checked={isEnabled}
onCheckedChange={(v) => (isEnabled = v)}
/>
<Label for="rp-enabled">{$t('app.retention_policies.active')}</Label>
</div>
<!-- Ingestion Scope -->
{#if ingestionSources.length > 0}
<div class="space-y-2">
<div class="flex items-center gap-2">
<Database class="text-muted-foreground h-4 w-4" />
<Label>{$t('app.retention_policies.ingestion_scope')}</Label>
</div>
<p class="text-muted-foreground text-xs">
{$t('app.retention_policies.ingestion_scope_description')}
</p>
<div class="bg-muted/40 rounded-md border p-3">
<!-- "All sources" option -->
<label class="flex cursor-pointer items-center gap-2.5 py-1">
<input
type="checkbox"
class="h-4 w-4 rounded"
checked={selectedIngestionIds.size === 0}
onchange={() => {
selectedIngestionIds = new Set();
}}
/>
<span class="text-sm font-medium italic">
{$t('app.retention_policies.ingestion_scope_all')}
</span>
</label>
<div class="my-2 border-t"></div>
{#each ingestionSources as source (source.id)}
<label class="flex cursor-pointer items-center gap-2.5 py-1">
<input
type="checkbox"
class="h-4 w-4 rounded"
checked={selectedIngestionIds.has(source.id)}
onchange={() => toggleIngestionSource(source.id)}
/>
<span class="text-sm">{source.name}</span>
<Badge variant="secondary" class="ml-auto text-[10px]">
{source.provider.replace(/_/g, ' ')}
</Badge>
</label>
{/each}
</div>
{#if selectedIngestionIds.size > 0}
<p class="text-muted-foreground text-xs">
{($t as any)('app.retention_policies.ingestion_scope_selected', {
count: selectedIngestionIds.size,
})}
</p>
{/if}
</div>
{/if}
<!-- Conditions builder -->
<div class="space-y-3">
<div class="flex items-center justify-between">
<Label>{$t('app.retention_policies.conditions')}</Label>
{#if rules.length > 1}
<Select.Root
type="single"
value={logicalOperator}
onValueChange={(v) => (logicalOperator = v as LogicalOperator)}
>
<Select.Trigger class="h-8 w-24 text-xs">
{logicalOperator}
</Select.Trigger>
<Select.Content>
<Select.Item value="AND">{$t('app.retention_policies.and')}</Select.Item>
<Select.Item value="OR">{$t('app.retention_policies.or')}</Select.Item>
</Select.Content>
</Select.Root>
{/if}
</div>
<p class="text-muted-foreground text-xs">
{$t('app.retention_policies.conditions_description')}
</p>
{#each rules as rule, i (i)}
<div class="bg-muted/40 flex items-center gap-2 rounded-md border p-3">
<!-- Field selector -->
<Select.Root
type="single"
value={rule.field}
onValueChange={(v) => updateRuleField(i, v as ConditionField)}
>
<Select.Trigger class="h-8 flex-1 text-xs">
{fieldOptions.find((f) => f.value === rule.field)?.label ?? rule.field}
</Select.Trigger>
<Select.Content>
{#each fieldOptions as opt}
<Select.Item value={opt.value}>{opt.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<!-- Operator selector -->
<Select.Root
type="single"
value={rule.operator}
onValueChange={(v) => updateRuleOperator(i, v as ConditionOperator)}
>
<Select.Trigger class="h-8 flex-1 text-xs">
{operatorOptions.find((o) => o.value === rule.operator)?.label ?? rule.operator}
</Select.Trigger>
<Select.Content>
{#each operatorOptions as opt}
<Select.Item value={opt.value}>{opt.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<!-- Value input -->
<Input
class="h-8 flex-1 text-xs"
value={rule.value}
oninput={(e) => updateRuleValue(i, (e.target as HTMLInputElement).value)}
placeholder={$t('app.retention_policies.value_placeholder')}
required
/>
<!-- Remove rule -->
<Button
type="button"
variant="ghost"
size="icon"
class="h-8 w-8 shrink-0"
onclick={() => removeRule(i)}
aria-label={$t('app.retention_policies.remove_rule')}
>
<Trash2 class="h-4 w-4" />
</Button>
</div>
{/each}
<Button type="button" variant="outline" size="sm" onclick={addRule}>
<Plus class="mr-1.5 h-4 w-4" />
{$t('app.retention_policies.add_rule')}
</Button>
</div>
<!-- Actions -->
<div class="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onclick={onCancel} disabled={isLoading}>
{$t('app.retention_policies.cancel')}
</Button>
<Button type="submit" disabled={isLoading}>
{#if isLoading}
{$t('app.components.common.submitting')}
{:else if policy}
{$t('app.retention_policies.save')}
{:else}
{$t('app.retention_policies.create')}
{/if}
</Button>
</div>
</form>

View File

@@ -319,6 +319,88 @@
"top_10_senders": "Top 10 Senders",
"no_indexed_insights": "No indexed insights available."
},
"retention_policies": {
"title": "Retention Policies",
"header": "Retention Policies",
"meta_description": "Manage data retention policies to automate email lifecycle and compliance.",
"create_new": "Create New Policy",
"no_policies_found": "No retention policies found.",
"name": "Name",
"description": "Description",
"priority": "Priority",
"retention_period": "Retention Period",
"retention_period_days": "Retention Period (days)",
"action_on_expiry": "Action on Expiry",
"delete_permanently": "Delete Permanently",
"status": "Status",
"active": "Active",
"inactive": "Inactive",
"conditions": "Conditions",
"conditions_description": "Define rules to match emails. If no conditions are set, the policy applies to all emails.",
"logical_operator": "Logical Operator",
"and": "AND",
"or": "OR",
"add_rule": "Add Rule",
"remove_rule": "Remove Rule",
"field": "Field",
"field_sender": "Sender",
"field_recipient": "Recipient",
"field_subject": "Subject",
"field_attachment_type": "Attachment Type",
"operator": "Operator",
"operator_equals": "Equals",
"operator_not_equals": "Not Equals",
"operator_contains": "Contains",
"operator_not_contains": "Not Contains",
"operator_starts_with": "Starts With",
"operator_ends_with": "Ends With",
"operator_domain_match": "Domain Match",
"operator_regex_match": "Regex Match",
"value": "Value",
"value_placeholder": "e.g. user@example.com",
"edit": "Edit",
"delete": "Delete",
"create": "Create",
"save": "Save Changes",
"cancel": "Cancel",
"create_description": "Create a new retention policy to manage the lifecycle of archived emails.",
"edit_description": "Update the settings for this retention policy.",
"delete_confirmation_title": "Delete this retention policy?",
"delete_confirmation_description": "This action cannot be undone. Emails matched by this policy will no longer be subject to automatic deletion.",
"deleting": "Deleting",
"confirm": "Confirm",
"days": "days",
"no_conditions": "All emails (no filter)",
"rules": "rules",
"simulator_title": "Policy Simulator",
"simulator_description": "Test an email's metadata against all active policies to see which retention period would apply.",
"simulator_sender": "Sender Email",
"simulator_sender_placeholder": "e.g. john@finance.company.de",
"simulator_recipients": "Recipients",
"simulator_recipients_placeholder": "Comma-separated, e.g. jane@company.de, bob@company.de",
"simulator_subject": "Subject",
"simulator_subject_placeholder": "e.g. Q4 Tax Report",
"simulator_attachment_types": "Attachment Types",
"simulator_attachment_types_placeholder": "Comma-separated, e.g. .pdf, .xlsx",
"simulator_run": "Run Simulation",
"simulator_running": "Running...",
"simulator_result_title": "Simulation Result",
"simulator_no_match": "No active policy matched this email. It will not be subject to automated deletion.",
"simulator_matched": "Matched — retention period of {{days}} days applies.",
"simulator_matching_policies": "Matching Policy IDs",
"simulator_no_result": "Run a simulation to see which policies apply to a given email.",
"simulator_ingestion_source": "Simulate for Ingestion Source",
"simulator_ingestion_source_description": "Select an ingestion source to test scoped policies. Leave blank to evaluate against all policies regardless of scope.",
"simulator_ingestion_all": "All sources (ignore scope)",
"ingestion_scope": "Ingestion Scope",
"ingestion_scope_description": "Restrict this policy to specific ingestion sources. Leave all unchecked to apply to all sources.",
"ingestion_scope_all": "All ingestion sources",
"ingestion_scope_selected": "{{count}} source(s) selected — this policy will only apply to emails from those sources.",
"create_success": "Retention policy created successfully.",
"update_success": "Retention policy updated successfully.",
"delete_success": "Retention policy deleted successfully.",
"delete_error": "Failed to delete retention policy."
},
"audit_log": {
"title": "Audit Log",
"header": "Audit Log",
@@ -375,20 +457,22 @@
"license_page": {
"title": "Enterprise License Status",
"meta_description": "View the current status of your Open Archiver Enterprise license.",
"revoked_title": "License Revoked",
"revoked_message": "Your license has been revoked by the license administrator. Enterprise features will be disabled {{grace_period}}. Please contact your account manager for assistance.",
"revoked_grace_period": "on {{date}}",
"revoked_immediately": "immediately",
"revoked_title": "License Invalid",
"revoked_message": "Your license has been revoked or your seat overage grace period has expired. All enterprise features are now disabled. Please contact your account manager for assistance.",
"notice_title": "Notice",
"seat_limit_exceeded_title": "Seat Limit Exceeded",
"seat_limit_exceeded_message": "Your license is for {{planSeats}} users, but you are currently using {{activeSeats}}. Please contact sales to adjust your subscription.",
"seat_limit_exceeded_message": "Your license covers {{planSeats}} seats but {{activeSeats}} are currently in use. Please reduce usage or upgrade your plan.",
"seat_limit_grace_deadline": "Enterprise features will be disabled on {{date}} unless the seat count is reduced.",
"customer": "Customer",
"license_details": "License Details",
"license_status": "License Status",
"active": "Active",
"expired": "Expired",
"revoked": "Revoked",
"overage": "Seat Overage",
"unknown": "Unknown",
"expires": "Expires",
"last_checked": "Last verified",
"seat_usage": "Seat Usage",
"seats_used": "{{activeSeats}} of {{planSeats}} seats used",
"enabled_features": "Enabled Features",
@@ -398,7 +482,10 @@
"enabled": "Enabled",
"disabled": "Disabled",
"could_not_load_title": "Could Not Load License",
"could_not_load_message": "An unexpected error occurred."
"could_not_load_message": "An unexpected error occurred.",
"revalidate": "Revalidate License",
"revalidating": "Revalidating...",
"revalidate_success": "License revalidated successfully."
}
}
}

View File

@@ -8,6 +8,7 @@
import { page } from '$app/state';
import ThemeSwitcher from '$lib/components/custom/ThemeSwitcher.svelte';
import { t } from '$lib/translations';
import Badge from '$lib/components/ui/badge/badge.svelte';
let { data, children } = $props();
interface NavItem {
@@ -76,7 +77,13 @@
const enterpriseNavItems: NavItem[] = [
{
label: 'Compliance',
subMenu: [{ href: '/dashboard/compliance/audit-log', label: 'Audit Log' }],
subMenu: [
{ href: '/dashboard/compliance/audit-log', label: $t('app.audit_log.title') },
{
href: '/dashboard/compliance/retention-policies',
label: $t('app.retention_policies.title'),
},
],
position: 3,
},
{
@@ -130,6 +137,9 @@
<a href="/dashboard" class="flex flex-row items-center gap-2 font-bold">
<img src="/logos/logo-sq.svg" alt="OpenArchiver Logo" class="h-8 w-8" />
<span class="hidden sm:inline-block">Open Archiver</span>
{#if data.enterpriseMode}
<Badge class="text-[8px] font-bold px-1 py-0.5">Enterprise</Badge>
{/if}
</a>
<!-- Desktop Navigation -->
@@ -151,7 +161,7 @@
{item.label}
</NavigationMenu.Trigger>
<NavigationMenu.Content>
<ul class="grid w-fit min-w-32 gap-1 p-1">
<ul class="grid w-fit min-w-40 gap-1 p-1">
{#each item.subMenu as subItem}
<li>
<NavigationMenu.Link href={subItem.href}>

View File

@@ -0,0 +1,184 @@
import { api } from '$lib/server/api';
import { error } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import type { RetentionPolicy, PolicyEvaluationResult, SafeIngestionSource } from '@open-archiver/types';
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.'
);
}
// Fetch policies and ingestion sources in parallel
const [policiesRes, ingestionsRes] = await Promise.all([
api('/enterprise/retention-policy/policies', event),
api('/ingestion-sources', event),
]);
const policiesJson = await policiesRes.json();
if (!policiesRes.ok) {
throw error(policiesRes.status, policiesJson.message || JSON.stringify(policiesJson));
}
// Ingestion sources are best-effort — don't hard-fail if unavailable
let ingestionSources: SafeIngestionSource[] = [];
if (ingestionsRes.ok) {
const ingestionsJson = await ingestionsRes.json();
ingestionSources = Array.isArray(ingestionsJson) ? ingestionsJson : [];
}
const policies: RetentionPolicy[] = policiesJson;
return { policies, ingestionSources };
};
export const actions: Actions = {
create: async (event) => {
const data = await event.request.formData();
const conditionsRaw = JSON.parse(
(data.get('conditions') as string) || '{"logicalOperator":"AND","rules":[]}'
);
// Parse ingestionScope: comma-separated UUIDs, or empty = null (all sources)
const ingestionScopeRaw = (data.get('ingestionScope') as string) || '';
const ingestionScope =
ingestionScopeRaw.trim().length > 0
? ingestionScopeRaw
.split(',')
.map((s) => s.trim())
.filter(Boolean)
: null;
const body = {
name: data.get('name') as string,
description: (data.get('description') as string) || undefined,
priority: Number(data.get('priority')),
retentionPeriodDays: Number(data.get('retentionPeriodDays')),
actionOnExpiry: 'delete_permanently' as const,
isEnabled: data.get('isEnabled') === 'true',
// Send null when no rules — means "apply to all emails"
conditions: conditionsRaw.rules.length > 0 ? conditionsRaw : null,
ingestionScope,
};
const response = await api('/enterprise/retention-policy/policies', event, {
method: 'POST',
body: JSON.stringify(body),
});
const res = await response.json();
if (!response.ok) {
return { success: false, message: res.message || 'Failed to create policy' };
}
return { success: true };
},
update: async (event) => {
const data = await event.request.formData();
const id = data.get('id') as string;
const conditionsRaw = JSON.parse(
(data.get('conditions') as string) || '{"logicalOperator":"AND","rules":[]}'
);
const ingestionScopeRaw = (data.get('ingestionScope') as string) || '';
const ingestionScope =
ingestionScopeRaw.trim().length > 0
? ingestionScopeRaw
.split(',')
.map((s) => s.trim())
.filter(Boolean)
: null;
const body = {
name: data.get('name') as string,
description: (data.get('description') as string) || undefined,
priority: Number(data.get('priority')),
retentionPeriodDays: Number(data.get('retentionPeriodDays')),
actionOnExpiry: 'delete_permanently' as const,
isEnabled: data.get('isEnabled') === 'true',
conditions: conditionsRaw.rules.length > 0 ? conditionsRaw : null,
ingestionScope,
};
const response = await api(`/enterprise/retention-policy/policies/${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 policy' };
}
return { success: true };
},
delete: async (event) => {
const data = await event.request.formData();
const id = data.get('id') as string;
const response = await api(`/enterprise/retention-policy/policies/${id}`, event, {
method: 'DELETE',
});
if (!response.ok) {
const res = await response.json().catch(() => ({}));
return { success: false, message: res.message || 'Failed to delete policy' };
}
return { success: true };
},
evaluate: async (event) => {
const data = await event.request.formData();
// Parse recipients and attachment types from comma-separated strings
const recipientsRaw = (data.get('recipients') as string) || '';
const attachmentTypesRaw = (data.get('attachmentTypes') as string) || '';
const ingestionSourceId = (data.get('ingestionSourceId') as string) || undefined;
const body = {
emailMetadata: {
sender: (data.get('sender') as string) || '',
recipients: recipientsRaw
.split(',')
.map((s) => s.trim())
.filter(Boolean),
subject: (data.get('subject') as string) || '',
attachmentTypes: attachmentTypesRaw
.split(',')
.map((s) => s.trim())
.filter(Boolean),
// Only include ingestionSourceId if a non-empty value was provided
...(ingestionSourceId ? { ingestionSourceId } : {}),
},
};
const response = await api('/enterprise/retention-policy/policies/evaluate', event, {
method: 'POST',
body: JSON.stringify(body),
});
const res = await response.json();
if (!response.ok) {
return {
success: false,
message: res.message || 'Failed to evaluate policies',
evaluationResult: null as PolicyEvaluationResult | null,
};
}
return {
success: true,
evaluationResult: res as PolicyEvaluationResult,
};
},
};

View File

@@ -0,0 +1,460 @@
<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 * as Select from '$lib/components/ui/select/index.js';
import { enhance } from '$app/forms';
import { MoreHorizontal, Plus, FlaskConical } from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import RetentionPolicyForm from '$lib/components/custom/RetentionPolicyForm.svelte';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import type { RetentionPolicy, PolicyEvaluationResult } from '@open-archiver/types';
let { data, form }: { data: PageData; form: ActionData } = $props();
let policies = $derived(data.policies);
let ingestionSources = $derived(data.ingestionSources);
// --- Dialog state ---
let isCreateOpen = $state(false);
let isEditOpen = $state(false);
let isDeleteOpen = $state(false);
let selectedPolicy = $state<RetentionPolicy | null>(null);
let isFormLoading = $state(false);
let isDeleting = $state(false);
// --- Simulator state ---
let isSimulating = $state(false);
let evaluationResult = $state<PolicyEvaluationResult | null>(null);
/** The ingestion source ID selected for the simulator (empty string = all sources / no filter) */
let simIngestionSourceId = $state('');
function openEdit(policy: RetentionPolicy) {
selectedPolicy = policy;
isEditOpen = true;
}
function openDelete(policy: RetentionPolicy) {
selectedPolicy = policy;
isDeleteOpen = true;
}
// React to form results (errors and evaluation results)
$effect(() => {
if (form && form.success === false && form.message) {
toast.error(form.message);
}
if (form && 'evaluationResult' in form) {
evaluationResult = form.evaluationResult ?? null;
}
});
/** Returns a human-readable summary of the conditions on a policy. */
function conditionsSummary(policy: RetentionPolicy): string {
if (!policy.conditions || policy.conditions.rules.length === 0) {
return $t('app.retention_policies.no_conditions');
}
const count = policy.conditions.rules.length;
const op = policy.conditions.logicalOperator;
return `${count} ${$t('app.retention_policies.rules')} (${op})`;
}
</script>
<svelte:head>
<title>{$t('app.retention_policies.title')} - Open Archiver</title>
<meta name="description" content={$t('app.retention_policies.meta_description')} />
<meta
name="keywords"
content="retention policies, data retention, email lifecycle, compliance, GDPR"
/>
</svelte:head>
<div class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-bold">{$t('app.retention_policies.header')}</h1>
<Button onclick={() => (isCreateOpen = true)}>
<Plus class="mr-1.5 h-4 w-4" />
{$t('app.retention_policies.create_new')}
</Button>
</div>
<div class="rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>{$t('app.retention_policies.name')}</Table.Head>
<Table.Head>{$t('app.retention_policies.priority')}</Table.Head>
<Table.Head>{$t('app.retention_policies.retention_period')}</Table.Head>
<Table.Head>{$t('app.retention_policies.ingestion_scope')}</Table.Head>
<Table.Head>{$t('app.retention_policies.conditions')}</Table.Head>
<Table.Head>{$t('app.retention_policies.status')}</Table.Head>
<Table.Head class="text-right">{$t('app.ingestions.actions')}</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if policies && policies.length > 0}
{#each policies as policy (policy.id)}
<Table.Row>
<Table.Cell class="font-medium">
<div>{policy.name}</div>
<div class="mt-0.5 font-mono text-[10px] text-muted-foreground">
{policy.id}
</div>
{#if policy.description}
<div class="text-muted-foreground mt-0.5 text-xs">{policy.description}</div>
{/if}
</Table.Cell>
<Table.Cell>{policy.priority}</Table.Cell>
<Table.Cell>
{policy.retentionPeriodDays}
{$t('app.retention_policies.days')}
</Table.Cell>
<Table.Cell>
{#if !policy.ingestionScope || policy.ingestionScope.length === 0}
<span class="text-muted-foreground text-sm italic">
{$t('app.retention_policies.ingestion_scope_all')}
</span>
{:else}
<div class="flex flex-wrap gap-1">
{#each policy.ingestionScope as sourceId (sourceId)}
{@const source = ingestionSources.find((s) => s.id === sourceId)}
<Badge variant="outline" class="text-xs">
{source?.name ?? sourceId.slice(0, 8) + '…'}
</Badge>
{/each}
</div>
{/if}
</Table.Cell>
<Table.Cell>
<span class="text-muted-foreground text-sm">{conditionsSummary(policy)}</span>
</Table.Cell>
<Table.Cell>
{#if policy.isActive}
<Badge variant="default" class="bg-green-500 text-white">
{$t('app.retention_policies.active')}
</Badge>
{:else}
<Badge variant="secondary">
{$t('app.retention_policies.inactive')}
</Badge>
{/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(policy)}>
{$t('app.retention_policies.edit')}
</DropdownMenu.Item>
<DropdownMenu.Item
class="text-destructive focus:text-destructive"
onclick={() => openDelete(policy)}
>
{$t('app.retention_policies.delete')}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
</Table.Row>
{/each}
{:else}
<Table.Row>
<Table.Cell colspan={7} class="h-24 text-center">
{$t('app.retention_policies.no_policies_found')}
</Table.Cell>
</Table.Row>
{/if}
</Table.Body>
</Table.Root>
</div>
<!-- Create dialog -->
<Dialog.Root bind:open={isCreateOpen}>
<Dialog.Content class="sm:max-w-[600px]">
<Dialog.Header>
<Dialog.Title>{$t('app.retention_policies.create')}</Dialog.Title>
<Dialog.Description>
{$t('app.retention_policies.create_description')}
</Dialog.Description>
</Dialog.Header>
<div class="max-h-[70vh] overflow-y-auto pr-1">
<RetentionPolicyForm
action="?/create"
{ingestionSources}
bind:isLoading={isFormLoading}
onCancel={() => (isCreateOpen = false)}
onSuccess={() => {
isCreateOpen = false;
toast.success($t('app.retention_policies.create_success'));
}}
/>
</div>
</Dialog.Content>
</Dialog.Root>
<!-- Edit dialog -->
<Dialog.Root bind:open={isEditOpen}>
<Dialog.Content class="sm:max-w-[600px]">
<Dialog.Header>
<Dialog.Title>{$t('app.retention_policies.edit')}</Dialog.Title>
<Dialog.Description>
{$t('app.retention_policies.edit_description')}
</Dialog.Description>
</Dialog.Header>
{#if selectedPolicy}
<div class="max-h-[70vh] overflow-y-auto pr-1">
<RetentionPolicyForm
policy={selectedPolicy}
action="?/update"
{ingestionSources}
bind:isLoading={isFormLoading}
onCancel={() => (isEditOpen = false)}
onSuccess={() => {
isEditOpen = false;
selectedPolicy = null;
toast.success($t('app.retention_policies.update_success'));
}}
/>
</div>
{/if}
</Dialog.Content>
</Dialog.Root>
<!-- Policy Simulator -->
<div class="mt-8 rounded-md border">
<div class="flex items-center gap-2 border-b px-6 py-4">
<FlaskConical class="text-muted-foreground h-5 w-5" />
<div>
<h2 class="text-base font-semibold">{$t('app.retention_policies.simulator_title')}</h2>
<p class="text-muted-foreground text-sm">
{$t('app.retention_policies.simulator_description')}
</p>
</div>
</div>
<form
method="POST"
action="?/evaluate"
class="grid gap-6 p-6 md:grid-cols-2"
use:enhance={() => {
isSimulating = true;
evaluationResult = null;
return async ({ update }) => {
isSimulating = false;
await update({ reset: false });
};
}}
>
<!-- Hidden field for selected ingestion source -->
<input type="hidden" name="ingestionSourceId" value={simIngestionSourceId} />
<!-- Sender -->
<div class="space-y-1.5">
<Label for="sim-sender">{$t('app.retention_policies.simulator_sender')}</Label>
<Input
id="sim-sender"
name="sender"
type="email"
placeholder={$t('app.retention_policies.simulator_sender_placeholder')}
/>
</div>
<!-- Subject -->
<div class="space-y-1.5">
<Label for="sim-subject">{$t('app.retention_policies.simulator_subject')}</Label>
<Input
id="sim-subject"
name="subject"
placeholder={$t('app.retention_policies.simulator_subject_placeholder')}
/>
</div>
<!-- Recipients -->
<div class="space-y-1.5">
<Label for="sim-recipients">{$t('app.retention_policies.simulator_recipients')}</Label>
<Input
id="sim-recipients"
name="recipients"
placeholder={$t('app.retention_policies.simulator_recipients_placeholder')}
/>
</div>
<!-- Attachment Types -->
<div class="space-y-1.5">
<Label for="sim-attachment-types">
{$t('app.retention_policies.simulator_attachment_types')}
</Label>
<Input
id="sim-attachment-types"
name="attachmentTypes"
placeholder={$t('app.retention_policies.simulator_attachment_types_placeholder')}
/>
</div>
<!-- Ingestion Source filter (only shown when sources are available) -->
{#if ingestionSources.length > 0}
<div class="space-y-1.5 md:col-span-2">
<Label>{$t('app.retention_policies.simulator_ingestion_source')}</Label>
<p class="text-muted-foreground text-xs">
{$t('app.retention_policies.simulator_ingestion_source_description')}
</p>
<Select.Root
type="single"
value={simIngestionSourceId}
onValueChange={(v) => (simIngestionSourceId = v)}
>
<Select.Trigger class="w-full">
{#if simIngestionSourceId}
{ingestionSources.find((s) => s.id === simIngestionSourceId)?.name ??
$t('app.retention_policies.simulator_ingestion_all')}
{:else}
{$t('app.retention_policies.simulator_ingestion_all')}
{/if}
</Select.Trigger>
<Select.Content>
<Select.Item value="">
<span class="italic">{$t('app.retention_policies.simulator_ingestion_all')}</span>
</Select.Item>
{#each ingestionSources as source (source.id)}
<Select.Item value={source.id}>
{source.name}
<span class="text-muted-foreground ml-1 text-xs">
({source.provider.replace(/_/g, ' ')})
</span>
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
{/if}
<!-- Submit spans full width on md -->
<div class="flex items-end md:col-span-2">
<Button type="submit" disabled={isSimulating} class="w-full md:w-auto">
<FlaskConical class="mr-1.5 h-4 w-4" />
{#if isSimulating}
{$t('app.retention_policies.simulator_running')}
{:else}
{$t('app.retention_policies.simulator_run')}
{/if}
</Button>
</div>
</form>
<!-- Result panel — shown only after a simulation has been run -->
{#if evaluationResult !== null}
<div class="border-t px-6 py-4">
<h3 class="mb-3 text-sm font-semibold">
{$t('app.retention_policies.simulator_result_title')}
</h3>
{#if evaluationResult.appliedRetentionDays === 0}
<div class="bg-muted rounded-md p-4 text-sm">
{$t('app.retention_policies.simulator_no_match')}
</div>
{:else}
<div class="space-y-3">
<div class="rounded-md border border-green-200 bg-green-50 p-4 dark:border-green-800 dark:bg-green-950">
<p class="text-sm font-medium text-green-800 dark:text-green-200">
{($t as any)('app.retention_policies.simulator_matched', {
days: evaluationResult.appliedRetentionDays,
})}
</p>
</div>
{#if evaluationResult.matchingPolicyIds.length > 0}
<div class="space-y-1.5">
<p class="text-muted-foreground text-xs font-medium uppercase tracking-wide">
{$t('app.retention_policies.simulator_matching_policies')}
</p>
<div class="flex flex-wrap gap-2">
{#each evaluationResult.matchingPolicyIds as policyId (policyId)}
{@const matchedPolicy = policies.find((p) => p.id === policyId)}
<div class="flex items-center gap-1.5">
<code class="bg-muted rounded px-2 py-0.5 font-mono text-xs">
{policyId}
</code>
{#if matchedPolicy}
<span class="text-muted-foreground text-xs">({matchedPolicy.name})</span>
{/if}
</div>
{/each}
</div>
</div>
{/if}
</div>
{/if}
</div>
{:else if !isSimulating}
<div class="border-t px-6 py-4">
<p class="text-muted-foreground text-sm">
{$t('app.retention_policies.simulator_no_result')}
</p>
</div>
{/if}
</div>
<!-- Delete confirmation dialog -->
<Dialog.Root bind:open={isDeleteOpen}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>{$t('app.retention_policies.delete_confirmation_title')}</Dialog.Title>
<Dialog.Description>
{$t('app.retention_policies.delete_confirmation_description')}
</Dialog.Description>
</Dialog.Header>
<Dialog.Footer>
<Button
variant="outline"
onclick={() => (isDeleteOpen = false)}
disabled={isDeleting}
>
{$t('app.retention_policies.cancel')}
</Button>
{#if selectedPolicy}
<form
method="POST"
action="?/delete"
use:enhance={() => {
isDeleting = true;
return async ({ result, update }) => {
isDeleting = false;
if (result.type === 'success') {
isDeleteOpen = false;
selectedPolicy = null;
toast.success($t('app.retention_policies.delete_success'));
} else {
toast.error($t('app.retention_policies.delete_error'));
}
await update();
};
}}
>
<input type="hidden" name="id" value={selectedPolicy.id} />
<Button type="submit" variant="destructive" disabled={isDeleting}>
{#if isDeleting}
{$t('app.retention_policies.deleting')}
{:else}
{$t('app.retention_policies.confirm')}
{/if}
</Button>
</form>
{/if}
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View File

@@ -27,7 +27,9 @@ export const AuditLogTargetTypes = [
'ArchivedEmail',
'Dashboard',
'IngestionSource',
'RetentionPolicy',
'Role',
'SystemEvent',
'SystemSettings',
'User',
'File', // For uploads and downloads

View File

@@ -1,11 +1,75 @@
// --- Condition Builder Types ---
export type ConditionField = 'sender' | 'recipient' | 'subject' | 'attachment_type';
/**
* All supported string-matching operators for retention rule conditions.
* - equals / not_equals: exact case-insensitive match
* - contains / not_contains: substring match
* - starts_with: prefix match
* - ends_with: suffix match
* - domain_match: email address ends with @<domain>
* - regex_match: ECMAScript regex (server-side only, length-limited for safety)
*/
export type ConditionOperator =
| 'equals'
| 'not_equals'
| 'contains'
| 'not_contains'
| 'starts_with'
| 'ends_with'
| 'domain_match'
| 'regex_match';
export type LogicalOperator = 'AND' | 'OR';
export interface RetentionRule {
field: ConditionField;
operator: ConditionOperator;
value: string;
}
export interface RetentionRuleGroup {
logicalOperator: LogicalOperator;
rules: RetentionRule[];
}
// --- Policy Evaluation Types ---
export interface PolicyEvaluationRequest {
emailMetadata: {
sender: string;
recipients: string[];
subject: string;
attachmentTypes: string[]; // e.g. ['.pdf', '.xml']
/** Optional ingestion source ID to scope the evaluation. */
ingestionSourceId?: string;
};
}
export interface PolicyEvaluationResult {
appliedRetentionDays: number;
actionOnExpiry: 'delete_permanently';
matchingPolicyIds: string[];
}
// --- Entity Types ---
export interface RetentionPolicy {
id: string;
name: string;
description?: string;
priority: number;
conditions: Record<string, any>; // JSON condition logic
conditions: RetentionRuleGroup | null;
/**
* Restricts the policy to specific ingestion sources.
* null means the policy applies to all ingestion sources.
*/
ingestionScope: string[] | null;
retentionPeriodDays: number;
isActive: boolean;
createdAt: string; // ISO Date string
updatedAt: string; // ISO Date string
}
export interface RetentionLabel {
@@ -21,7 +85,7 @@ export interface RetentionEvent {
eventName: string;
eventType: string; // e.g., 'EMPLOYEE_EXIT'
eventTimestamp: string; // ISO Date string
targetCriteria: Record<string, any>; // JSON criteria
targetCriteria: Record<string, unknown>; // JSON criteria
createdAt: string; // ISO Date string
}