From ff676ecb86ec1b4bd1c9c94399bc8320dc8d3a06 Mon Sep 17 00:00:00 2001 From: Til Wegener <38760774+tilwegener@users.noreply.github.com> Date: Wed, 13 Aug 2025 20:11:47 +0200 Subject: [PATCH 1/6] * avoid hanging when pdf2json fails by resolving text extraction with an empty string --- packages/backend/src/helpers/textExtractor.ts | 28 ++++++++++++++----- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/packages/backend/src/helpers/textExtractor.ts b/packages/backend/src/helpers/textExtractor.ts index b51c775..48038e7 100644 --- a/packages/backend/src/helpers/textExtractor.ts +++ b/packages/backend/src/helpers/textExtractor.ts @@ -3,17 +3,31 @@ import mammoth from 'mammoth'; import xlsx from 'xlsx'; function extractTextFromPdf(buffer: Buffer): Promise { - 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); }); } From 187282c68d4bdaa4083972e7d9e7fc22caa5ca26 Mon Sep 17 00:00:00 2001 From: Til Wegener <38760774+tilwegener@users.noreply.github.com> Date: Wed, 13 Aug 2025 19:24:05 +0000 Subject: [PATCH 2/6] fix: handle gaps in IMAP UID ranges --- .../ingestion-connectors/ImapConnector.ts | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/packages/backend/src/services/ingestion-connectors/ImapConnector.ts b/packages/backend/src/services/ingestion-connectors/ImapConnector.ts index 7977122..ae1979d 100644 --- a/packages/backend/src/services/ingestion-connectors/ImapConnector.ts +++ b/packages/backend/src/services/ingestion-connectors/ImapConnector.ts @@ -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; } @@ -200,11 +198,6 @@ export class ImapConnector implements IEmailConnector { } } - // If this batch was smaller than the batch size, we've reached the end - if (messagesInBatch < BATCH_SIZE) { - break; - } - // Move to the next batch startUid = endUid + 1; } @@ -272,4 +265,4 @@ export class ImapConnector implements IEmailConnector { return syncState; } -} +} \ No newline at end of file From c4afa471cb75f1735b2e27c74f1df44facb7613e Mon Sep 17 00:00:00 2001 From: Til Wegener <38760774+tilwegener@users.noreply.github.com> Date: Wed, 13 Aug 2025 19:24:34 +0000 Subject: [PATCH 3/6] chore: log IMAP message UID during processing --- .../src/services/ingestion-connectors/ImapConnector.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/services/ingestion-connectors/ImapConnector.ts b/packages/backend/src/services/ingestion-connectors/ImapConnector.ts index ae1979d..6e3acfa 100644 --- a/packages/backend/src/services/ingestion-connectors/ImapConnector.ts +++ b/packages/backend/src/services/ingestion-connectors/ImapConnector.ts @@ -193,8 +193,15 @@ export class ImapConnector implements IEmailConnector { this.newMaxUids[mailboxPath] = msg.uid; } + logger.debug({ mailboxPath, uid: msg.uid }, 'Processing message'); + if (msg.envelope && msg.source) { - yield await this.parseMessage(msg, mailboxPath); + try { + yield await this.parseMessage(msg, mailboxPath); + } catch (err: any) { + logger.error({ err, mailboxPath, uid: msg.uid }, 'Failed to parse message'); + throw err; + } } } From 9138c1c753b8616fc00f98fbf0f6e697d11deb1a Mon Sep 17 00:00:00 2001 From: Til Wegener <38760774+tilwegener@users.noreply.github.com> Date: Thu, 14 Aug 2025 06:26:51 +0000 Subject: [PATCH 4/6] feat: remove archived emails and related data --- .../controllers/archived-email.controller.ts | 20 ++++ .../src/api/routes/archived-email.routes.ts | 2 + .../src/services/ArchivedEmailService.ts | 55 +++++++++- .../archived-emails/[id]/+page.svelte | 102 +++++++++++++----- 4 files changed, 154 insertions(+), 25 deletions(-) diff --git a/packages/backend/src/api/controllers/archived-email.controller.ts b/packages/backend/src/api/controllers/archived-email.controller.ts index a074756..3faf007 100644 --- a/packages/backend/src/api/controllers/archived-email.controller.ts +++ b/packages/backend/src/api/controllers/archived-email.controller.ts @@ -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 => { @@ -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 => { + 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' }); + } + }; } diff --git a/packages/backend/src/api/routes/archived-email.routes.ts b/packages/backend/src/api/routes/archived-email.routes.ts index b896669..954298d 100644 --- a/packages/backend/src/api/routes/archived-email.routes.ts +++ b/packages/backend/src/api/routes/archived-email.routes.ts @@ -16,5 +16,7 @@ export const createArchivedEmailRouter = ( router.get('/:id', archivedEmailController.getArchivedEmailById); + router.delete('/:id', archivedEmailController.deleteArchivedEmail); + return router; }; diff --git a/packages/backend/src/services/ArchivedEmailService.ts b/packages/backend/src/services/ArchivedEmailService.ts index 0e67bb6..b5144a2 100644 --- a/packages/backend/src/services/ArchivedEmailService.ts +++ b/packages/backend/src/services/ArchivedEmailService.ts @@ -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 { + 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)); + } } diff --git a/packages/frontend/src/routes/dashboard/archived-emails/[id]/+page.svelte b/packages/frontend/src/routes/dashboard/archived-emails/[id]/+page.svelte index 83b8d10..e19c00d 100644 --- a/packages/frontend/src/routes/dashboard/archived-emails/[id]/+page.svelte +++ b/packages/frontend/src/routes/dashboard/archived-emails/[id]/+page.svelte @@ -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; + } + } {#if email} @@ -112,16 +135,22 @@
- - - Actions - - - - - + + + Actions + + + + + + {#if email.thread && email.thread.length > 1} @@ -132,9 +161,34 @@ - {/if} -
- + {/if} + + + + + + + Are you sure you want to delete this email? + + This action cannot be undone and will permanently remove the email and its + attachments. + + + + + + + + + + {:else} -

Email not found.

+

Email not found.

{/if} From cfdfe42fb8211a5514d5c9d1218dce053a544425 Mon Sep 17 00:00:00 2001 From: Til Wegener <38760774+tilwegener@users.noreply.github.com> Date: Thu, 14 Aug 2025 07:25:12 +0000 Subject: [PATCH 5/6] fix(email-deletion): redirect to archived list and purge search index --- packages/backend/src/services/ArchivedEmailService.ts | 2 +- packages/backend/src/services/SearchService.ts | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/services/ArchivedEmailService.ts b/packages/backend/src/services/ArchivedEmailService.ts index b5144a2..2c87afc 100644 --- a/packages/backend/src/services/ArchivedEmailService.ts +++ b/packages/backend/src/services/ArchivedEmailService.ts @@ -188,7 +188,7 @@ export class ArchivedEmailService { } const searchService = new SearchService(); - await searchService.deleteDocumentsByFilter('emails', `id = ${emailId}`); + await searchService.deleteDocuments('emails', [emailId]); await db.delete(archivedEmails).where(eq(archivedEmails.id, emailId)); } diff --git a/packages/backend/src/services/SearchService.ts b/packages/backend/src/services/SearchService.ts index 5f437fb..096e6af 100644 --- a/packages/backend/src/services/SearchService.ts +++ b/packages/backend/src/services/SearchService.ts @@ -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 }); From cba7e05d9873913d22d1d2f55dcf089a3ea54d14 Mon Sep 17 00:00:00 2001 From: Til Wegener <38760774+tilwegener@users.noreply.github.com> Date: Thu, 14 Aug 2025 08:10:58 +0000 Subject: [PATCH 6/6] fix: handle attachment cleanup errors safely and surface messages --- .../controllers/archived-email.controller.ts | 7 ++- .../src/services/ArchivedEmailService.ts | 53 +++++++++++++------ .../archived-emails/[id]/+page.svelte | 8 ++- 3 files changed, 48 insertions(+), 20 deletions(-) diff --git a/packages/backend/src/api/controllers/archived-email.controller.ts b/packages/backend/src/api/controllers/archived-email.controller.ts index 3faf007..3c2b704 100644 --- a/packages/backend/src/api/controllers/archived-email.controller.ts +++ b/packages/backend/src/api/controllers/archived-email.controller.ts @@ -47,8 +47,11 @@ export class ArchivedEmailController { 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 }); + 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' }); } diff --git a/packages/backend/src/services/ArchivedEmailService.ts b/packages/backend/src/services/ArchivedEmailService.ts index 2c87afc..d374479 100644 --- a/packages/backend/src/services/ArchivedEmailService.ts +++ b/packages/backend/src/services/ArchivedEmailService.ts @@ -158,10 +158,9 @@ export class ArchivedEmailService { const storage = new StorageService(); - // Load attachments before deleting the email - let emailAttachmentsResult: { attachmentId: string; storagePath: string }[] = []; + // Load and handle attachments before deleting the email itself if (email.hasAttachments) { - emailAttachmentsResult = await db + const emailAttachmentsResult = await db .select({ attachmentId: attachments.id, storagePath: attachments.storagePath, @@ -169,24 +168,46 @@ export class ArchivedEmailService { .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); - // 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.deleteDocuments('emails', [emailId]); diff --git a/packages/frontend/src/routes/dashboard/archived-emails/[id]/+page.svelte b/packages/frontend/src/routes/dashboard/archived-emails/[id]/+page.svelte index e19c00d..0482bb9 100644 --- a/packages/frontend/src/routes/dashboard/archived-emails/[id]/+page.svelte +++ b/packages/frontend/src/routes/dashboard/archived-emails/[id]/+page.svelte @@ -48,9 +48,13 @@ method: 'DELETE' }); if (!response.ok) { - throw new Error('Failed to delete email'); + 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'); + await goto('/dashboard/archived-emails', { invalidateAll: true }); } catch (error) { console.error('Delete failed:', error); } finally {