diff --git a/src/modules/email-utils/services/email-notification.service.ts b/src/modules/email-utils/services/email-notification.service.ts index 62d474c..87890a9 100644 --- a/src/modules/email-utils/services/email-notification.service.ts +++ b/src/modules/email-utils/services/email-notification.service.ts @@ -81,7 +81,7 @@ export class EmailNotificationService { }; const success = await this.emailGateway.notifyEmailUnread(user.id, payload); - await this.updateUnreadCount(params.userId); + await this.updateUnreadCount(user); if (success) { this.logger.log(`Email unread notification dispatched for user ${user.id}: message ${params.messageId}`); @@ -96,7 +96,6 @@ export class EmailNotificationService { //************************************************************************ */ async notifyEmailRead(params: EmailStatusNotificationParams) { try { - // Find the user to get the correct User entity ID for websocket notifications const em = this.em.fork(); const user = await em.findOne(User, { wildduckUserId: params.userId, @@ -110,14 +109,14 @@ export class EmailNotificationService { } const payload: EmailStatusPayload = { - userId: user.id, // Use User entity ID for websocket + userId: user.id, messageId: params.messageId, mailboxId: params.mailboxId, timestamp: new Date().toISOString(), }; const success = await this.emailGateway.notifyEmailRead(user.id, payload); - await this.updateUnreadCount(params.userId); // Pass wildduckUserId to updateUnreadCount + await this.updateUnreadCount(user); if (success) { this.logger.log(`Email read notification dispatched for user ${user.id}: message ${params.messageId}`); @@ -132,7 +131,6 @@ export class EmailNotificationService { //************************************************************************ */ async notifyEmailDeleted(params: EmailStatusNotificationParams) { try { - // Find the user to get the correct User entity ID for websocket notifications const em = this.em.fork(); const user = await em.findOne(User, { wildduckUserId: params.userId, @@ -146,14 +144,14 @@ export class EmailNotificationService { } const payload: EmailDeletePayload = { - userId: user.id, // Use User entity ID for websocket + userId: user.id, messageId: params.messageId, mailboxId: params.mailboxId, timestamp: new Date().toISOString(), }; const success = await this.emailGateway.notifyEmailDeleted(user.id, payload); - await this.updateUnreadCount(params.userId); // Pass wildduckUserId to updateUnreadCount + await this.updateUnreadCount(user); if (success) { this.logger.log(`Email deleted notification dispatched for user ${user.id}: message ${params.messageId}`); @@ -260,20 +258,8 @@ export class EmailNotificationService { } //************************************************************************ */ - private async updateUnreadCount(userId: string) { + private async updateUnreadCount(user: User) { try { - const em = this.em.fork(); - - const user = await em.findOne(User, { - wildduckUserId: userId, - emailEnabled: true, - deletedAt: null, - }); - - if (!user || !user.wildduckUserId) { - return; - } - const inboxMailboxId = await this.mailboxResolver.getInboxMailboxId(user.wildduckUserId); const { results } = await firstValueFrom( @@ -301,7 +287,7 @@ export class EmailNotificationService { this.logger.warn(`Failed to update unread count for user ${user.id}: user not connected`); } } catch (error) { - this.logger.error(`Failed to update unread count for user ${userId}:`, error); + this.logger.error(`Failed to update unread count for user ${user.wildduckUserId}:`, error); } } diff --git a/src/modules/email/email.controller.ts b/src/modules/email/email.controller.ts index 978295c..ba4e92a 100644 --- a/src/modules/email/email.controller.ts +++ b/src/modules/email/email.controller.ts @@ -66,6 +66,19 @@ export class EmailController { this.emailService.downloadMessageAttachment(userId, params.messageId, query.mailbox, query.attachmentId, reply); } + @Get("messages/:messageId/download") + @ApiOperation({ summary: "Download an email" }) + @ApiResponse({ status: 200, description: "Email downloaded successfully" }) + @ApiResponse({ status: 404, description: "Email not found" }) + downloadEmail( + @UserDec("wildduckUserId") userId: string, + @Param() params: MessageParamDto, + @Query() query: MailboxIdQueryDto, + @Res({ passthrough: false }) reply: FastifyReply, + ) { + return this.emailService.downloadEmail(userId, params.messageId, query.mailbox, reply); + } + @Get("search") @ApiOperation({ summary: "Search messages across all mailboxes" }) @ApiResponse({ status: 200, description: "Search results" }) diff --git a/src/modules/email/queue/email-bulk-action.processor.ts b/src/modules/email/queue/email-bulk-action.processor.ts index cf1e794..6122779 100644 --- a/src/modules/email/queue/email-bulk-action.processor.ts +++ b/src/modules/email/queue/email-bulk-action.processor.ts @@ -1,9 +1,12 @@ +import { randomInt } from "node:crypto"; + import { Processor, WorkerHost } from "@nestjs/bullmq"; import { Logger } from "@nestjs/common"; import { Job } from "bullmq"; import { firstValueFrom } from "rxjs"; import { EmailMessage } from "../../../common/enums/message.enum"; +import { EmailNotificationService } from "../../email-utils/services/email-notification.service"; import { MailServerService } from "../../mail-server/services/mail-server.service"; import { EMAIL_QUEUE_CONSTANTS } from "../constants/email-events.constant"; import { IEmailBulkActionJob } from "../interfaces/email.interface"; @@ -16,6 +19,7 @@ export class EmailBulkActionProcessor extends WorkerHost { constructor( private readonly mailServerService: MailServerService, private readonly emailService: EmailService, + private readonly emailNotificationService: EmailNotificationService, ) { super(); } @@ -108,6 +112,12 @@ export class EmailBulkActionProcessor extends WorkerHost { results.totalMarked = totalMarked; + await this.emailNotificationService.notifyEmailRead({ + userId: wildduckUserId, + messageId: results.successful[0], + mailboxId, + }); + this.logger.log(`Marked ${totalMarked} messages as read for user ${wildduckUserId} in mailbox ${mailboxId}, ${results.failed.length} failed`); return { @@ -134,6 +144,12 @@ export class EmailBulkActionProcessor extends WorkerHost { throw new Error(result.success); } + await this.emailNotificationService.notifyEmailDeleted({ + userId: wildduckUserId, + messageId: randomInt(1, 500), + mailboxId, + }); + return { success: true, message: EmailMessage.TRASH_EMPTYED_SUCCESSFULLY, @@ -157,6 +173,12 @@ export class EmailBulkActionProcessor extends WorkerHost { throw new Error(result.success); } + await this.emailNotificationService.notifyEmailDeleted({ + userId: wildduckUserId, + messageId: randomInt(1, 500), + mailboxId, + }); + return { success: true, message: EmailMessage.SPAM_EMPTYED_SUCCESSFULLY, diff --git a/src/modules/email/services/email.service.ts b/src/modules/email/services/email.service.ts index be8e678..d42d285 100644 --- a/src/modules/email/services/email.service.ts +++ b/src/modules/email/services/email.service.ts @@ -138,7 +138,7 @@ export class EmailService { ); const attachmentData = { - stream: attachmentResponse.success, + stream: attachmentResponse.stream, contentType: attachmentResponse.contentType || "application/octet-stream", contentDisposition: attachmentResponse.contentDisposition, contentLength: attachmentResponse.contentLength, @@ -164,6 +164,37 @@ export class EmailService { fastifyReply.send(attachmentData.stream); } + //######################################################## + async downloadEmail(wildduckUserId: string, messageId: number, mailboxId: string, fastifyReply: FastifyReply) { + const messageResponse = await firstValueFrom(this.mailServerService.messages.getMessageSource(wildduckUserId, mailboxId, messageId)); + + const attachmentData = { + stream: messageResponse.stream, + contentType: messageResponse.contentType || "application/octet-stream", + contentDisposition: messageResponse.contentDisposition, + contentLength: messageResponse.contentLength, + headers: messageResponse.headers, + }; + + // Set appropriate headers for file download + fastifyReply.header("Content-Type", attachmentData.contentType || "application/octet-stream"); + + if (attachmentData.contentLength) { + fastifyReply.header("Content-Length", attachmentData.contentLength); + } + + // Set content disposition if available + if (attachmentData.contentDisposition) { + fastifyReply.header("Content-Disposition", attachmentData.contentDisposition); + } else { + // Default to attachment if no disposition specified + fastifyReply.header("Content-Disposition", `attachment; filename="message_${messageId}.eml"`); + } + + // Send the stream directly + fastifyReply.send(attachmentData.stream); + } + //######################################################## async listMessages(wildduckUserId: string, query: MessageListQueryDto) { const inboxMailboxId = await this.mailboxResolverService.getInboxMailboxId(wildduckUserId); diff --git a/src/modules/mail-server/interfaces/attachment.interface.ts b/src/modules/mail-server/interfaces/attachment.interface.ts index dd4b65a..40cd6b2 100644 --- a/src/modules/mail-server/interfaces/attachment.interface.ts +++ b/src/modules/mail-server/interfaces/attachment.interface.ts @@ -1,7 +1,15 @@ export interface AttachmentResponse { - success: any; // Stream data - headers?: any; + stream: unknown; // Stream data + headers?: unknown; contentType?: string; contentDisposition?: string; contentLength?: string; } + +export interface FileResponse { + stream: unknown; + contentType?: string; + contentDisposition?: string; + contentLength?: string; + headers?: unknown; +} diff --git a/src/modules/mail-server/services/wildduck-messages.service.ts b/src/modules/mail-server/services/wildduck-messages.service.ts index fd8c4de..3849cd7 100644 --- a/src/modules/mail-server/services/wildduck-messages.service.ts +++ b/src/modules/mail-server/services/wildduck-messages.service.ts @@ -6,7 +6,7 @@ import { WildDuckBaseService } from "./wildduck-base.service"; import * as MailServerExceptions from "../../../core/exceptions/mail-server.exceptions"; import { MailServerException } from "../../../core/exceptions/mail-server.exceptions"; import { ListMessagesQueryDto, SearchMessagesMailServerQueryDto, UpdateMessageDto, UploadMessageDto } from "../DTO/message.dto"; -import { AttachmentResponse } from "../interfaces/attachment.interface"; +import { AttachmentResponse, FileResponse } from "../interfaces/attachment.interface"; import { MessageDeleteAllResponse, MessageDetails, @@ -67,9 +67,43 @@ export class WildDuckMessagesService extends WildDuckBaseService { /** * Get message source (raw email) */ - getMessageSource(userId: string, mailboxId: string, messageId: number): Observable { + getMessageSource(userId: string, mailboxId: string, messageId: number): Observable { // This returns raw email content, not JSON - return this.get(`/users/${userId}/mailboxes/${mailboxId}/messages/${messageId}/message.eml`); + const url = this.buildUrl(`/users/${userId}/mailboxes/${mailboxId}/messages/${messageId}/message.eml`); + this.logger.debug(`WildDuck GET: ${url}`); + + return this.httpService + .get(url, { + responseType: "stream", + headers: { + Accept: "*/*", + }, + }) + .pipe( + map((response) => { + this.logger.debug(`HTTP ${response.status}: ${response.config?.url}`); + return { + stream: response.data, + contentType: response.headers["content-type"], + contentDisposition: response.headers["content-disposition"], + contentLength: response.headers["content-length"], + headers: response.headers, + } as FileResponse; + }), + catchError((error) => { + this.logger.error(`WildDuck API error: ${error.message}`, error.stack); + + if (error.response?.data?.error) { + return throwError(() => this.handleMailServerError(error.response.data.error, error.response.status)); + } + + if (error.code === "ECONNREFUSED" || error.code === "ENOTFOUND") { + return throwError(() => new MailServerExceptions.MailServerConnectionException(error.message)); + } + + return throwError(() => new MailServerException(error.message || "Unknown mail server error")); + }), + ); } /** @@ -91,7 +125,7 @@ export class WildDuckMessagesService extends WildDuckBaseService { map((response) => { this.logger.debug(`HTTP ${response.status}: ${response.config?.url}`); return { - success: response.data, + stream: response.data, headers: response.headers, contentType: response.headers["content-type"], contentDisposition: response.headers["content-disposition"],