Merge pull request #36 from tilwegener/feat/delete-mail-button

feat: delete archived emails + improve IMAP UID and PDF parsing

Note: Will do project-wide formatting in the next commit, merging this PR.
This commit is contained in:
Wei S.
2025-08-15 13:45:31 +03:00
committed by GitHub
7 changed files with 222 additions and 46 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,26 @@ 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) {
if (error.message === 'Archived email not found') {
return res.status(404).json({ message: error.message });
}
return res.status(500).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

@@ -3,17 +3,31 @@ import mammoth from 'mammoth';
import xlsx from 'xlsx';
function extractTextFromPdf(buffer: Buffer): Promise<string> {
return new Promise((resolve, reject) => {
return new Promise((resolve) => {
const pdfParser = new PDFParser(null, true);
let completed = false;
pdfParser.on('pdfParser_dataError', (errData: any) =>
reject(new Error(errData.parserError))
const finish = (text: string) => {
if (completed) return;
completed = true;
pdfParser.removeAllListeners();
resolve(text);
};
pdfParser.on('pdfParser_dataError', () => finish(''));
pdfParser.on('pdfParser_dataReady', () =>
finish(pdfParser.getRawTextContent())
);
pdfParser.on('pdfParser_dataReady', () => {
resolve(pdfParser.getRawTextContent());
});
pdfParser.parseBuffer(buffer);
try {
pdfParser.parseBuffer(buffer);
} catch (err) {
console.error('Error parsing PDF buffer', err);
finish('');
}
// Prevent hanging if the parser never emits events
setTimeout(() => finish(''), 10000);
});
}

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,72 @@ 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 and handle attachments before deleting the email itself
if (email.hasAttachments) {
const emailAttachmentsResult = await db
.select({
attachmentId: attachments.id,
storagePath: attachments.storagePath,
})
.from(emailAttachments)
.innerJoin(attachments, eq(emailAttachments.attachmentId, attachments.id))
.where(eq(emailAttachments.emailId, emailId));
try {
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(emailAttachments)
.where(
and(
eq(emailAttachments.emailId, emailId),
eq(emailAttachments.attachmentId, attachment.attachmentId)
)
);
await db
.delete(attachments)
.where(eq(attachments.id, attachment.attachmentId));
} else {
await db
.delete(emailAttachments)
.where(
and(
eq(emailAttachments.emailId, emailId),
eq(emailAttachments.attachmentId, attachment.attachmentId)
)
);
}
}
} catch {
throw new Error('Failed to delete email attachments');
}
}
// Delete the email file from storage
await storage.delete(email.storagePath);
const searchService = new SearchService();
await searchService.deleteDocuments('emails', [emailId]);
await db.delete(archivedEmails).where(eq(archivedEmails.id, emailId));
}
}

View File

@@ -33,6 +33,11 @@ export class SearchService {
return index.search(query, options);
}
public async deleteDocuments(indexName: string, ids: string[]) {
const index = await this.getIndex(indexName);
return index.deleteDocuments(ids);
}
public async deleteDocumentsByFilter(indexName: string, filter: string | string[]) {
const index = await this.getIndex(indexName);
return index.deleteDocuments({ filter });

View File

@@ -166,7 +166,7 @@ export class ImapConnector implements IEmailConnector {
const lastUid = syncState?.imap?.[mailboxPath]?.maxUid;
let currentMaxUid = lastUid || 0;
if (!lastUid && mailbox.exists > 0) {
if (mailbox.exists > 0) {
const lastMessage = await this.client.fetchOne(String(mailbox.exists), { uid: true });
if (lastMessage && lastMessage.uid > currentMaxUid) {
currentMaxUid = lastMessage.uid;
@@ -178,15 +178,13 @@ export class ImapConnector implements IEmailConnector {
if (mailbox.exists > 0) {
const BATCH_SIZE = 250; // A configurable batch size
let startUid = (lastUid || 0) + 1;
const maxUidToFetch = currentMaxUid;
while (true) {
const endUid = startUid + BATCH_SIZE - 1;
while (startUid <= maxUidToFetch) {
const endUid = Math.min(startUid + BATCH_SIZE - 1, maxUidToFetch);
const searchCriteria = { uid: `${startUid}:${endUid}` };
let messagesInBatch = 0;
for await (const msg of this.client.fetch(searchCriteria, { envelope: true, source: true, bodyStructure: true, uid: true })) {
messagesInBatch++;
if (lastUid && msg.uid <= lastUid) {
continue;
}
@@ -195,14 +193,16 @@ export class ImapConnector implements IEmailConnector {
this.newMaxUids[mailboxPath] = msg.uid;
}
if (msg.envelope && msg.source) {
yield await this.parseMessage(msg, mailboxPath);
}
}
logger.debug({ mailboxPath, uid: msg.uid }, 'Processing message');
// If this batch was smaller than the batch size, we've reached the end
if (messagesInBatch < BATCH_SIZE) {
break;
if (msg.envelope && msg.source) {
try {
yield await this.parseMessage(msg, mailboxPath);
} catch (err: any) {
logger.error({ err, mailboxPath, uid: msg.uid }, 'Failed to parse message');
throw err;
}
}
}
// Move to the next batch
@@ -272,4 +272,4 @@ export class ImapConnector implements IEmailConnector {
return syncState;
}
}
}

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,30 @@
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) {
const errorData = await response.json().catch(() => null);
const message = errorData?.message || 'Failed to delete email';
console.error('Delete failed:', message);
alert(message);
return;
}
await goto('/dashboard/archived-emails', { invalidateAll: true });
} catch (error) {
console.error('Delete failed:', error);
} finally {
isDeleting = false;
isDeleteDialogOpen = false;
}
}
</script>
{#if email}
@@ -112,16 +139,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 +165,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}