mirror of
https://github.com/LogicLabs-OU/OpenArchiver.git
synced 2026-04-06 00:31:57 +02:00
Retention policy (#329)
* Retention policy schema/types * schema generate * retention policy (backend/frontend)
This commit is contained in:
@@ -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;
|
||||
1560
packages/backend/src/database/migrations/meta/0025_snapshot.json
Normal file
1560
packages/backend/src/database/migrations/meta/0025_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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'
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
@@ -27,7 +27,9 @@ export const AuditLogTargetTypes = [
|
||||
'ArchivedEmail',
|
||||
'Dashboard',
|
||||
'IngestionSource',
|
||||
'RetentionPolicy',
|
||||
'Role',
|
||||
'SystemEvent',
|
||||
'SystemSettings',
|
||||
'User',
|
||||
'File', // For uploads and downloads
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user