mirror of
https://github.com/LogicLabs-OU/OpenArchiver.git
synced 2026-04-06 00:31:57 +02:00
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:
@@ -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' });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -16,5 +16,7 @@ export const createArchivedEmailRouter = (
|
||||
|
||||
router.get('/:id', archivedEmailController.getArchivedEmailById);
|
||||
|
||||
router.delete('/:id', archivedEmailController.deleteArchivedEmail);
|
||||
|
||||
return router;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user