chore: add new featurew
This commit is contained in:
@@ -226,6 +226,7 @@ export const enum EmailMessage {
|
|||||||
MAILBOX_ID_MUST_BE_MONGO_ID = "شناسه میل باکس ایمیل باید یک آی دی معتبر باشد",
|
MAILBOX_ID_MUST_BE_MONGO_ID = "شناسه میل باکس ایمیل باید یک آی دی معتبر باشد",
|
||||||
MESSAGE_MARKED_AS_UNSEEN_SUCCESSFULLY = "پیام با موفقیت به عنوان خوانده نشده علامت گذاری شد",
|
MESSAGE_MARKED_AS_UNSEEN_SUCCESSFULLY = "پیام با موفقیت به عنوان خوانده نشده علامت گذاری شد",
|
||||||
MESSAGE_MARK_AS_UNSEEN_FAILED = "علامت گذاری پیام به عنوان خوانده نشده با خطا مواجه شد",
|
MESSAGE_MARK_AS_UNSEEN_FAILED = "علامت گذاری پیام به عنوان خوانده نشده با خطا مواجه شد",
|
||||||
|
ATTACHMENT_ID_MUST_BE_STRING = "شناسه پیوست باید یک رشته باشد",
|
||||||
}
|
}
|
||||||
|
|
||||||
export const enum WebSocketMessage {
|
export const enum WebSocketMessage {
|
||||||
|
|||||||
@@ -46,3 +46,9 @@ export class MessageParamDto {
|
|||||||
@IsNumber({}, { message: EmailMessage.MESSAGE_ID_MUST_BE_NUMBER })
|
@IsNumber({}, { message: EmailMessage.MESSAGE_ID_MUST_BE_NUMBER })
|
||||||
messageId: 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 { IsEnum, IsOptional, IsString } from "class-validator";
|
||||||
|
|
||||||
import { PaginationDto } from "../../../common/DTO/pagination.dto";
|
import { PaginationDto } from "../../../common/DTO/pagination.dto";
|
||||||
@@ -17,3 +17,10 @@ export class SearchMessagesQueryDto extends PaginationDto {
|
|||||||
@ApiPropertyOptional({ description: "Search query", example: "search query" })
|
@ApiPropertyOptional({ description: "Search query", example: "search query" })
|
||||||
q?: string;
|
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 { ApiOperation, ApiResponse } from "@nestjs/swagger";
|
||||||
|
import { FastifyReply } from "fastify";
|
||||||
|
|
||||||
import { BulkActionDto } from "./DTO/bulk-actions.dto";
|
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 { 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 { SendEmailDto, UpdateDraftDto } from "./DTO/send-email.dto";
|
||||||
import { EmailService } from "./services/email.service";
|
import { EmailService } from "./services/email.service";
|
||||||
import { AuthGuards } from "../../common/decorators/auth-guard.decorator";
|
import { AuthGuards } from "../../common/decorators/auth-guard.decorator";
|
||||||
@@ -52,6 +53,37 @@ export class EmailController {
|
|||||||
return this.emailService.deleteMessage(userId, params.messageId, query.mailbox);
|
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")
|
@Get("search")
|
||||||
@ApiOperation({ summary: "Search messages across all mailboxes" })
|
@ApiOperation({ summary: "Search messages across all mailboxes" })
|
||||||
@ApiResponse({ status: 200, description: "Search results" })
|
@ApiResponse({ status: 200, description: "Search results" })
|
||||||
@@ -59,6 +91,17 @@ export class EmailController {
|
|||||||
return this.emailService.searchMessages(userId, query);
|
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")
|
@Patch("messages/:messageId/seen")
|
||||||
@ApiOperation({ summary: "Mark a message as seen" })
|
@ApiOperation({ summary: "Mark a message as seen" })
|
||||||
@ApiResponse({ status: 200, description: "Message marked as seen successfully" })
|
@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 }))
|
@UsePipes(new ValidationPipe({ transform: true }))
|
||||||
@UseFilters(WsExceptionFilter)
|
@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 {
|
export class EmailGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect {
|
||||||
@WebSocketServer()
|
@WebSocketServer()
|
||||||
server: Server;
|
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) {
|
async listMessages(wildduckUserId: string, query: MessageListQueryDto) {
|
||||||
try {
|
try {
|
||||||
@@ -945,4 +971,109 @@ export class EmailService {
|
|||||||
trustedImageSources: imageDetection.trustedImageSources,
|
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 { WEBSOCKET_EVENTS, WebSocketEvent } from "../constants/email-events.constant";
|
||||||
import { AuthenticationResult, WebSocketErrorContext } from "../interfaces/websocket.interface";
|
import { AuthenticationResult, WebSocketErrorContext } from "../interfaces/websocket.interface";
|
||||||
|
|
||||||
/**
|
|
||||||
* Service responsible for WebSocket authentication logic
|
|
||||||
* Follows single responsibility principle and provides reusable authentication methods
|
|
||||||
*/
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class WebSocketAuthService {
|
export class WebSocketAuthService {
|
||||||
private readonly logger = new Logger(WebSocketAuthService.name);
|
private readonly logger = new Logger(WebSocketAuthService.name);
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
export interface AttachmentResponse {
|
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 { Injectable } from "@nestjs/common";
|
||||||
import { Observable } from "rxjs";
|
import { Observable } from "rxjs";
|
||||||
|
import { catchError, map, throwError } from "rxjs";
|
||||||
|
|
||||||
import { WildDuckBaseService } from "./wildduck-base.service";
|
import { WildDuckBaseService } from "./wildduck-base.service";
|
||||||
|
import { MailServerException } from "../../../core/exceptions/mail-server.exceptions";
|
||||||
import { ListMessagesQueryDto, SearchMessagesQueryDto, UpdateMessageDto, UploadMessageDto } from "../DTO/message.dto";
|
import { ListMessagesQueryDto, SearchMessagesQueryDto, UpdateMessageDto, UploadMessageDto } from "../DTO/message.dto";
|
||||||
import { AttachmentResponse } from "../interfaces/attachment.interface";
|
import { AttachmentResponse } from "../interfaces/attachment.interface";
|
||||||
import {
|
import {
|
||||||
@@ -75,7 +77,37 @@ export class WildDuckMessagesService extends WildDuckBaseService {
|
|||||||
*/
|
*/
|
||||||
getMessageAttachment(userId: string, mailboxId: string, messageId: number, attachmentId: string): Observable<AttachmentResponse> {
|
getMessageAttachment(userId: string, mailboxId: string, messageId: number, attachmentId: string): Observable<AttachmentResponse> {
|
||||||
// This returns binary content
|
// 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