feat: remove archived emails and related data

This commit is contained in:
Til Wegener
2025-08-14 06:26:51 +00:00
parent c4afa471cb
commit 9138c1c753
4 changed files with 154 additions and 25 deletions

View File

@@ -1,5 +1,6 @@
import { Request, Response } from 'express';
import { ArchivedEmailService } from '../../services/ArchivedEmailService';
import { config } from '../../config';
export class ArchivedEmailController {
public getArchivedEmails = async (req: Request, res: Response): Promise<Response> => {
@@ -33,4 +34,23 @@ export class ArchivedEmailController {
return res.status(500).json({ message: 'An internal server error occurred' });
}
};
public deleteArchivedEmail = async (req: Request, res: Response): Promise<Response> => {
if (config.app.isDemo) {
return res
.status(403)
.json({ message: 'This operation is not allowed in demo mode.' });
}
try {
const { id } = req.params;
await ArchivedEmailService.deleteArchivedEmail(id);
return res.status(204).send();
} catch (error) {
console.error(`Delete archived email ${req.params.id} error:`, error);
if (error instanceof Error && error.message === 'Archived email not found') {
return res.status(404).json({ message: error.message });
}
return res.status(500).json({ message: 'An internal server error occurred' });
}
};
}

View File

@@ -16,5 +16,7 @@ export const createArchivedEmailRouter = (
router.get('/:id', archivedEmailController.getArchivedEmailById);
router.delete('/:id', archivedEmailController.deleteArchivedEmail);
return router;
};

View File

@@ -1,8 +1,14 @@
import { count, desc, eq, asc, and } from 'drizzle-orm';
import { db } from '../database';
import { archivedEmails, attachments, emailAttachments } from '../database/schema';
import type { PaginatedArchivedEmails, ArchivedEmail, Recipient, ThreadEmail } from '@open-archiver/types';
import type {
PaginatedArchivedEmails,
ArchivedEmail,
Recipient,
ThreadEmail,
} from '@open-archiver/types';
import { StorageService } from './StorageService';
import { SearchService } from './SearchService';
import type { Readable } from 'stream';
interface DbRecipients {
@@ -139,4 +145,51 @@ export class ArchivedEmailService {
return mappedEmail;
}
public static async deleteArchivedEmail(emailId: string): Promise<void> {
const [email] = await db
.select()
.from(archivedEmails)
.where(eq(archivedEmails.id, emailId));
if (!email) {
throw new Error('Archived email not found');
}
const storage = new StorageService();
// Load attachments before deleting the email
let emailAttachmentsResult: { attachmentId: string; storagePath: string }[] = [];
if (email.hasAttachments) {
emailAttachmentsResult = await db
.select({
attachmentId: attachments.id,
storagePath: attachments.storagePath,
})
.from(emailAttachments)
.innerJoin(attachments, eq(emailAttachments.attachmentId, attachments.id))
.where(eq(emailAttachments.emailId, emailId));
}
// Delete the email file from storage
await storage.delete(email.storagePath);
// Handle attachments: delete only if not referenced elsewhere
for (const attachment of emailAttachmentsResult) {
const [refCount] = await db
.select({ count: count(emailAttachments.emailId) })
.from(emailAttachments)
.where(eq(emailAttachments.attachmentId, attachment.attachmentId));
if (refCount.count === 1) {
await storage.delete(attachment.storagePath);
await db.delete(attachments).where(eq(attachments.id, attachment.attachmentId));
}
}
const searchService = new SearchService();
await searchService.deleteDocumentsByFilter('emails', `id = ${emailId}`);
await db.delete(archivedEmails).where(eq(archivedEmails.id, emailId));
}
}

View File

@@ -2,17 +2,21 @@
import type { PageData } from './$types';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import EmailPreview from '$lib/components/custom/EmailPreview.svelte';
import EmailThread from '$lib/components/custom/EmailThread.svelte';
import { api } from '$lib/api.client';
import { browser } from '$app/environment';
import { formatBytes } from '$lib/utils';
import EmailPreview from '$lib/components/custom/EmailPreview.svelte';
import EmailThread from '$lib/components/custom/EmailThread.svelte';
import { api } from '$lib/api.client';
import { browser } from '$app/environment';
import { formatBytes } from '$lib/utils';
import { goto } from '$app/navigation';
import * as Dialog from '$lib/components/ui/dialog';
let { data }: { data: PageData } = $props();
let email = $derived(data.email);
let { data }: { data: PageData } = $props();
let email = $derived(data.email);
let isDeleteDialogOpen = $state(false);
let isDeleting = $state(false);
async function download(path: string, filename: string) {
if (!browser) return;
async function download(path: string, filename: string) {
if (!browser) return;
try {
const response = await api(`/storage/download?path=${encodeURIComponent(path)}`);
@@ -34,7 +38,26 @@
console.error('Download failed:', error);
// Optionally, show an error message to the user
}
}
}
async function confirmDelete() {
if (!email) return;
try {
isDeleting = true;
const response = await api(`/archived-emails/${email.id}`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error('Failed to delete email');
}
await goto('/dashboard/archived-emails');
} catch (error) {
console.error('Delete failed:', error);
} finally {
isDeleting = false;
isDeleteDialogOpen = false;
}
}
</script>
{#if email}
@@ -112,16 +135,22 @@
</Card.Root>
</div>
<div class="col-span-3 space-y-6 md:col-span-1">
<Card.Root>
<Card.Header>
<Card.Title>Actions</Card.Title>
</Card.Header>
<Card.Content>
<Button onclick={() => download(email.storagePath, `${email.subject || 'email'}.eml`)}
>Download Email (.eml)</Button
>
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title>Actions</Card.Title>
</Card.Header>
<Card.Content class="space-y-2">
<Button onclick={() => download(email.storagePath, `${email.subject || 'email'}.eml`)}
>Download Email (.eml)</Button
>
<Button
variant="destructive"
onclick={() => (isDeleteDialogOpen = true)}
>
Delete Email
</Button>
</Card.Content>
</Card.Root>
{#if email.thread && email.thread.length > 1}
<Card.Root>
@@ -132,9 +161,34 @@
<EmailThread thread={email.thread} currentEmailId={email.id} />
</Card.Content>
</Card.Root>
{/if}
</div>
</div>
{/if}
</div>
</div>
<Dialog.Root bind:open={isDeleteDialogOpen}>
<Dialog.Content class="sm:max-w-lg">
<Dialog.Header>
<Dialog.Title>Are you sure you want to delete this email?</Dialog.Title>
<Dialog.Description>
This action cannot be undone and will permanently remove the email and its
attachments.
</Dialog.Description>
</Dialog.Header>
<Dialog.Footer class="sm:justify-start">
<Button
type="button"
variant="destructive"
onclick={confirmDelete}
disabled={isDeleting}
>
{#if isDeleting}Deleting...{:else}Confirm{/if}
</Button>
<Dialog.Close>
<Button type="button" variant="secondary">Cancel</Button>
</Dialog.Close>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
{:else}
<p>Email not found.</p>
<p>Email not found.</p>
{/if}