chore: add new featurew

This commit is contained in:
mahyargdz
2025-07-21 16:30:08 +03:30
parent abfdd1b9e7
commit dc5c5898aa
9 changed files with 235 additions and 11 deletions
+1
View File
@@ -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 {
@@ -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;
}
+8 -1
View File
@@ -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;
}
+46 -3
View File
@@ -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" })
+5 -1
View File
@@ -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;
+131
View File
@@ -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<string, { email: string; name?: string; count: number; lastSeen: Date }>();
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<string, { email: string; name?: string; count: number; lastSeen: Date }>, 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,
});
}
}
}
@@ -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);
@@ -1,3 +1,7 @@
export interface AttachmentResponse {
success: File;
success: any; // Stream data
headers?: any;
contentType?: string;
contentDisposition?: string;
contentLength?: string;
}
@@ -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<AttachmentResponse> {
// This returns binary content
return this.get<AttachmentResponse>(`/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);
}),
);
}
/**