chore: add new featurew
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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" })
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user