diff --git a/src/common/enums/message.enum.ts b/src/common/enums/message.enum.ts index 51d3d35..fb9d913 100755 --- a/src/common/enums/message.enum.ts +++ b/src/common/enums/message.enum.ts @@ -226,6 +226,7 @@ export const enum EmailMessage { MAILBOX_ID_MUST_BE_MONGO_ID = "شناسه میل باکس ایمیل باید یک آی دی معتبر باشد", MESSAGE_MARKED_AS_UNSEEN_SUCCESSFULLY = "پیام با موفقیت به عنوان خوانده نشده علامت گذاری شد", MESSAGE_MARK_AS_UNSEEN_FAILED = "علامت گذاری پیام به عنوان خوانده نشده با خطا مواجه شد", + ATTACHMENT_ID_MUST_BE_STRING = "شناسه پیوست باید یک رشته باشد", } export const enum WebSocketMessage { diff --git a/src/modules/email/DTO/email-params.dto.ts b/src/modules/email/DTO/email-params.dto.ts index 5880b29..ac2633e 100644 --- a/src/modules/email/DTO/email-params.dto.ts +++ b/src/modules/email/DTO/email-params.dto.ts @@ -46,3 +46,9 @@ export class MessageParamDto { @IsNumber({}, { message: EmailMessage.MESSAGE_ID_MUST_BE_NUMBER }) messageId: number; } + +export class AttachmentIdQueryDto extends MailboxIdQueryDto { + @ApiProperty({ description: "Attachment ID", example: "12345" }) + @IsString({ message: EmailMessage.ATTACHMENT_ID_MUST_BE_STRING }) + attachmentId: string; +} diff --git a/src/modules/email/DTO/email-query.dto.ts b/src/modules/email/DTO/email-query.dto.ts index 6f1119d..2295df0 100644 --- a/src/modules/email/DTO/email-query.dto.ts +++ b/src/modules/email/DTO/email-query.dto.ts @@ -1,4 +1,4 @@ -import { ApiPropertyOptional } from "@nestjs/swagger"; +import { ApiPropertyOptional, PickType } from "@nestjs/swagger"; import { IsEnum, IsOptional, IsString } from "class-validator"; import { PaginationDto } from "../../../common/DTO/pagination.dto"; @@ -17,3 +17,10 @@ export class SearchMessagesQueryDto extends PaginationDto { @ApiPropertyOptional({ description: "Search query", example: "search query" }) q?: string; } + +export class EmailSuggestionsQueryDto extends PickType(PaginationDto, ["limit"]) { + @IsOptional() + @IsString({ message: CommonMessage.SEARCH_QUERY_STRING }) + @ApiPropertyOptional({ description: "Email address search query for autocomplete", example: "john@" }) + query?: string; +} diff --git a/src/modules/email/email.controller.ts b/src/modules/email/email.controller.ts index fb6c8ce..db9433a 100644 --- a/src/modules/email/email.controller.ts +++ b/src/modules/email/email.controller.ts @@ -1,10 +1,11 @@ -import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from "@nestjs/common"; +import { Body, Controller, Delete, Get, Param, Patch, Post, Query, Res } from "@nestjs/common"; import { ApiOperation, ApiResponse } from "@nestjs/swagger"; +import { FastifyReply } from "fastify"; import { BulkActionDto } from "./DTO/bulk-actions.dto"; -import { MailboxIdQueryDto, MessageParamDto, QueueIdParamDto } from "./DTO/email-params.dto"; +import { AttachmentIdQueryDto, MailboxIdQueryDto, MessageParamDto, QueueIdParamDto } from "./DTO/email-params.dto"; import { MessageListQueryDto } from "./DTO/email-query.dto"; -import { SearchMessagesQueryDto } from "./DTO/email-query.dto"; +import { EmailSuggestionsQueryDto, SearchMessagesQueryDto } from "./DTO/email-query.dto"; import { SendEmailDto, UpdateDraftDto } from "./DTO/send-email.dto"; import { EmailService } from "./services/email.service"; import { AuthGuards } from "../../common/decorators/auth-guard.decorator"; @@ -52,6 +53,37 @@ export class EmailController { return this.emailService.deleteMessage(userId, params.messageId, query.mailbox); } + @Get("messages/:messageId/attachment") + @ApiOperation({ summary: "Download a message attachment" }) + @ApiResponse({ status: 200, description: "Attachment downloaded successfully" }) + @ApiResponse({ status: 404, description: "Attachment not found" }) + async downloadMessageAttachment( + @UserDec("wildduckUserId") userId: string, + @Param() params: MessageParamDto, + @Query() query: AttachmentIdQueryDto, + @Res({ passthrough: false }) reply: FastifyReply, + ) { + const attachmentData = await this.emailService.downloadMessageAttachment(userId, params.messageId, query.mailbox, query.attachmentId); + + // Set appropriate headers for file download + reply.header("Content-Type", attachmentData.contentType || "application/octet-stream"); + + if (attachmentData.contentLength) { + reply.header("Content-Length", attachmentData.contentLength); + } + + // Set content disposition if available + if (attachmentData.contentDisposition) { + reply.header("Content-Disposition", attachmentData.contentDisposition); + } else { + // Default to attachment if no disposition specified + reply.header("Content-Disposition", `attachment; filename="attachment_${query.attachmentId}"`); + } + + // Send the stream directly + reply.send(attachmentData.stream); + } + @Get("search") @ApiOperation({ summary: "Search messages across all mailboxes" }) @ApiResponse({ status: 200, description: "Search results" }) @@ -59,6 +91,17 @@ export class EmailController { return this.emailService.searchMessages(userId, query); } + @Get("suggestions") + @ApiOperation({ + summary: "Get email address suggestions for composing", + description: + "Returns email address suggestions based on previous email interactions (sent and received). Results are sorted by frequency and recency.", + }) + @ApiResponse({ status: 404, description: "User not found" }) + getEmailSuggestions(@UserDec("wildduckUserId") userId: string, @Query() query: EmailSuggestionsQueryDto) { + return this.emailService.getEmailSuggestions(userId, query.query, query.limit); + } + @Patch("messages/:messageId/seen") @ApiOperation({ summary: "Mark a message as seen" }) @ApiResponse({ status: 200, description: "Message marked as seen successfully" }) diff --git a/src/modules/email/gateways/email.gateway.ts b/src/modules/email/gateways/email.gateway.ts index 1c3987d..f50dc60 100644 --- a/src/modules/email/gateways/email.gateway.ts +++ b/src/modules/email/gateways/email.gateway.ts @@ -27,7 +27,11 @@ import { WebSocketAuthService } from "../services/websocket-auth.service"; @UsePipes(new ValidationPipe({ transform: true })) @UseFilters(WsExceptionFilter) -@WebSocketGateway({ namespace: WEBSOCKET_NAMESPACES.EMAIL, cors: { origin: true, credentials: true } }) +@WebSocketGateway({ + namespace: WEBSOCKET_NAMESPACES.EMAIL, + cors: { origin: true, credentials: true }, + transports: ["websocket", "polling"], +}) export class EmailGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect { @WebSocketServer() server: Server; diff --git a/src/modules/email/services/email.service.ts b/src/modules/email/services/email.service.ts index 3bc013b..112e6ce 100644 --- a/src/modules/email/services/email.service.ts +++ b/src/modules/email/services/email.service.ts @@ -153,6 +153,32 @@ export class EmailService { } } + //######################################################## + async downloadMessageAttachment(wildduckUserId: string, messageId: number, mailboxId: string, attachmentId: string) { + console.log("messageId", messageId); + console.log("mailboxId", mailboxId); + console.log("attachmentId", attachmentId); + + try { + const attachmentResponse = await firstValueFrom( + this.mailServerService.messages.getMessageAttachment(wildduckUserId, mailboxId, messageId, attachmentId), + ); + + return { + stream: attachmentResponse.success, + contentType: attachmentResponse.contentType || "application/octet-stream", + contentDisposition: attachmentResponse.contentDisposition, + contentLength: attachmentResponse.contentLength, + headers: attachmentResponse.headers, + }; + } catch (error) { + this.logger.error( + `Failed to download attachment ${attachmentId} for message ${messageId}: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + throw error; + } + } + //######################################################## async listMessages(wildduckUserId: string, query: MessageListQueryDto) { try { @@ -945,4 +971,109 @@ export class EmailService { trustedImageSources: imageDetection.trustedImageSources, }); } + + //######################################################## + async getEmailSuggestions(wildduckUserId: string, query?: string, limit: number = 10) { + this.logger.log(`Getting email suggestions for user ${wildduckUserId} with query: "${query}"`); + + const emailMap = new Map(); + + const [inboxMailboxId, sentMailboxId] = await Promise.all([ + this.mailboxResolverService.getInboxMailboxId(wildduckUserId), + this.mailboxResolverService.getSentMailboxId(wildduckUserId), + ]); + + const searchPromises = [ + firstValueFrom(this.mailServerService.messages.listMessages(wildduckUserId, sentMailboxId, { limit: 250, order: "desc" })), + firstValueFrom(this.mailServerService.messages.listMessages(wildduckUserId, inboxMailboxId, { limit: 250, order: "desc" })), + ]; + + const [sentMessages, inboxMessages] = await Promise.all(searchPromises); + + sentMessages.results.forEach((message) => { + const timestamp = new Date(message.date || message.idate || Date.now()); + + if (message.to && Array.isArray(message.to)) { + message.to.forEach((recipient) => { + this.addEmailToMap(emailMap, recipient, timestamp); + }); + } + + if (message.cc && Array.isArray(message.cc)) { + message.cc.forEach((recipient) => { + this.addEmailToMap(emailMap, recipient, timestamp); + }); + } + + if (message.bcc && Array.isArray(message.bcc)) { + message.bcc.forEach((recipient) => { + this.addEmailToMap(emailMap, recipient, timestamp); + }); + } + }); + + inboxMessages.results.forEach((message) => { + const timestamp = new Date(message.date || message.idate || Date.now()); + + if (message.from && message.from.address) { + this.addEmailToMap(emailMap, message.from, timestamp); + } + }); + + let suggestions = Array.from(emailMap.values()); + + if (query && query.trim()) { + const normalizedQuery = query.toLowerCase().trim(); + suggestions = suggestions.filter( + (suggestion) => + suggestion.email.toLowerCase().includes(normalizedQuery) || (suggestion.name && suggestion.name.toLowerCase().includes(normalizedQuery)), + ); + } + + suggestions = suggestions + .sort((a, b) => { + if (b.count !== a.count) return b.count - a.count; + return b.lastSeen.getTime() - a.lastSeen.getTime(); + }) + .slice(0, Math.min(limit, 50)); + + this.logger.log(`Returning ${suggestions.length} email suggestions for user ${wildduckUserId}`); + + return { + suggestions: suggestions.map(({ email, name, count, lastSeen }) => ({ + address: email, + name: name || null, + frequency: count, + lastUsed: lastSeen.toISOString(), + })), + total: suggestions.length, + query: query || null, + }; + } + + //============================================== + private addEmailToMap(emailMap: Map, recipient: any, timestamp: Date) { + if (!recipient || !recipient.address) return; + + const email = recipient.address.toLowerCase(); + const name = recipient.name; + + if (emailMap.has(email)) { + const existing = emailMap.get(email)!; + existing.count += 1; + if (name && (!existing.name || name.length > existing.name.length)) { + existing.name = name; + } + if (timestamp > existing.lastSeen) { + existing.lastSeen = timestamp; + } + } else { + emailMap.set(email, { + email: recipient.address, + name, + count: 1, + lastSeen: timestamp, + }); + } + } } diff --git a/src/modules/email/services/websocket-auth.service.ts b/src/modules/email/services/websocket-auth.service.ts index 98b8e31..897509c 100644 --- a/src/modules/email/services/websocket-auth.service.ts +++ b/src/modules/email/services/websocket-auth.service.ts @@ -8,10 +8,6 @@ import { extractTokenFromClient } from "../../utils/services/extract-token.utils import { WEBSOCKET_EVENTS, WebSocketEvent } from "../constants/email-events.constant"; import { AuthenticationResult, WebSocketErrorContext } from "../interfaces/websocket.interface"; -/** - * Service responsible for WebSocket authentication logic - * Follows single responsibility principle and provides reusable authentication methods - */ @Injectable() export class WebSocketAuthService { private readonly logger = new Logger(WebSocketAuthService.name); diff --git a/src/modules/mail-server/interfaces/attachment.interface.ts b/src/modules/mail-server/interfaces/attachment.interface.ts index cb2af8a..dd4b65a 100644 --- a/src/modules/mail-server/interfaces/attachment.interface.ts +++ b/src/modules/mail-server/interfaces/attachment.interface.ts @@ -1,3 +1,7 @@ export interface AttachmentResponse { - success: File; + success: any; // Stream data + headers?: any; + contentType?: string; + contentDisposition?: string; + contentLength?: string; } diff --git a/src/modules/mail-server/services/wildduck-messages.service.ts b/src/modules/mail-server/services/wildduck-messages.service.ts index 775aa49..2a2bfa2 100644 --- a/src/modules/mail-server/services/wildduck-messages.service.ts +++ b/src/modules/mail-server/services/wildduck-messages.service.ts @@ -1,7 +1,9 @@ import { Injectable } from "@nestjs/common"; import { Observable } from "rxjs"; +import { catchError, map, throwError } from "rxjs"; import { WildDuckBaseService } from "./wildduck-base.service"; +import { MailServerException } from "../../../core/exceptions/mail-server.exceptions"; import { ListMessagesQueryDto, SearchMessagesQueryDto, UpdateMessageDto, UploadMessageDto } from "../DTO/message.dto"; import { AttachmentResponse } from "../interfaces/attachment.interface"; import { @@ -75,7 +77,37 @@ export class WildDuckMessagesService extends WildDuckBaseService { */ getMessageAttachment(userId: string, mailboxId: string, messageId: number, attachmentId: string): Observable { // This returns binary content - return this.get(`/users/${userId}/mailboxes/${mailboxId}/messages/${messageId}/attachments/${attachmentId}`); + const url = this.buildUrl(`/users/${userId}/mailboxes/${mailboxId}/messages/${messageId}/attachments/${attachmentId}`); + 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 { + success: response.data, + headers: response.headers, + contentType: response.headers["content-type"], + contentDisposition: response.headers["content-disposition"], + contentLength: response.headers["content-length"], + } as AttachmentResponse; + }), + catchError((error) => { + this.logger.error(`WildDuck API error: ${error.message}`, error.stack); + + if (error.response?.data?.error) { + return throwError(() => new MailServerException(error.response.data.error)); + } + + return throwError(() => error); + }), + ); } /**