chore: add websocket for real time
This commit is contained in:
@@ -0,0 +1,849 @@
|
|||||||
|
# 📧 Email WebSocket Integration Guide for React
|
||||||
|
|
||||||
|
This guide will help you integrate real-time email notifications into your React application using WebSocket connections.
|
||||||
|
|
||||||
|
## 🚀 Quick Start
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install socket.io-client
|
||||||
|
# or
|
||||||
|
yarn add socket.io-client
|
||||||
|
```
|
||||||
|
|
||||||
|
### Basic Setup
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import io from "socket.io-client";
|
||||||
|
|
||||||
|
const socket = io("ws://localhost:3000/email", {
|
||||||
|
auth: {
|
||||||
|
token: localStorage.getItem("jwt-token"), // Your JWT authentication token
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔌 Connection Setup
|
||||||
|
|
||||||
|
### 1. Basic Connection Hook
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// hooks/useEmailWebSocket.ts
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import io, { Socket } from "socket.io-client";
|
||||||
|
|
||||||
|
interface UseEmailWebSocketOptions {
|
||||||
|
token: string;
|
||||||
|
serverUrl?: string;
|
||||||
|
onConnect?: () => void;
|
||||||
|
onDisconnect?: () => void;
|
||||||
|
onError?: (error: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useEmailWebSocket = ({ token, serverUrl = "ws://localhost:3000/email", onConnect, onDisconnect, onError }: UseEmailWebSocketOptions) => {
|
||||||
|
const [socket, setSocket] = useState<Socket | null>(null);
|
||||||
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
const newSocket = io(serverUrl, {
|
||||||
|
auth: { token },
|
||||||
|
transports: ["websocket", "polling"],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Connection events
|
||||||
|
newSocket.on("connect", () => {
|
||||||
|
console.log("✅ Connected to email WebSocket");
|
||||||
|
setIsConnected(true);
|
||||||
|
onConnect?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
newSocket.on("disconnect", () => {
|
||||||
|
console.log("❌ Disconnected from email WebSocket");
|
||||||
|
setIsConnected(false);
|
||||||
|
onDisconnect?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
newSocket.on("connection_established", (data) => {
|
||||||
|
console.log("🎉 Email WebSocket connection established:", data);
|
||||||
|
});
|
||||||
|
|
||||||
|
newSocket.on("connection_error", (error) => {
|
||||||
|
console.error("💥 Email WebSocket connection error:", error);
|
||||||
|
onError?.(error.error);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Health monitoring
|
||||||
|
newSocket.on("ping", () => {
|
||||||
|
newSocket.emit("pong", { timestamp: new Date().toISOString() });
|
||||||
|
});
|
||||||
|
|
||||||
|
setSocket(newSocket);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
newSocket.close();
|
||||||
|
setSocket(null);
|
||||||
|
};
|
||||||
|
}, [token, serverUrl]);
|
||||||
|
|
||||||
|
return { socket, isConnected };
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Email Events Hook
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// hooks/useEmailEvents.ts
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Socket } from "socket.io-client";
|
||||||
|
|
||||||
|
// TypeScript interfaces (copy from your backend)
|
||||||
|
export interface EmailPayload {
|
||||||
|
messageId: number;
|
||||||
|
userId: string;
|
||||||
|
from: {
|
||||||
|
name?: string;
|
||||||
|
address: string;
|
||||||
|
};
|
||||||
|
to: Array<{
|
||||||
|
name?: string;
|
||||||
|
address: string;
|
||||||
|
}>;
|
||||||
|
subject: string;
|
||||||
|
preview?: string;
|
||||||
|
hasAttachments: boolean;
|
||||||
|
timestamp: string;
|
||||||
|
mailboxId: string;
|
||||||
|
mailboxName: string;
|
||||||
|
isRead: boolean;
|
||||||
|
isFlagged: boolean;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EmailStatusPayload {
|
||||||
|
messageId: number;
|
||||||
|
userId: string;
|
||||||
|
mailboxId: string;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnreadCountPayload {
|
||||||
|
userId: string;
|
||||||
|
totalUnread: number;
|
||||||
|
mailboxCounts: Record<string, number>;
|
||||||
|
timestamp: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EmailEventHandlers {
|
||||||
|
onNewEmail?: (email: EmailPayload) => void;
|
||||||
|
onEmailRead?: (data: EmailStatusPayload) => void;
|
||||||
|
onEmailDeleted?: (data: EmailStatusPayload) => void;
|
||||||
|
onEmailSent?: (data: any) => void;
|
||||||
|
onUnreadCountUpdated?: (data: UnreadCountPayload) => void;
|
||||||
|
onMailboxUpdated?: (data: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useEmailEvents = (socket: Socket | null, handlers: EmailEventHandlers) => {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!socket) return;
|
||||||
|
|
||||||
|
// Register event handlers
|
||||||
|
if (handlers.onNewEmail) {
|
||||||
|
socket.on("new_email", handlers.onNewEmail);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (handlers.onEmailRead) {
|
||||||
|
socket.on("email_read", handlers.onEmailRead);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (handlers.onEmailDeleted) {
|
||||||
|
socket.on("email_deleted", handlers.onEmailDeleted);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (handlers.onEmailSent) {
|
||||||
|
socket.on("email_sent", handlers.onEmailSent);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (handlers.onUnreadCountUpdated) {
|
||||||
|
socket.on("unread_count_updated", handlers.onUnreadCountUpdated);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (handlers.onMailboxUpdated) {
|
||||||
|
socket.on("mailbox_updated", handlers.onMailboxUpdated);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
return () => {
|
||||||
|
socket.off("new_email");
|
||||||
|
socket.off("email_read");
|
||||||
|
socket.off("email_deleted");
|
||||||
|
socket.off("email_sent");
|
||||||
|
socket.off("unread_count_updated");
|
||||||
|
socket.off("mailbox_updated");
|
||||||
|
};
|
||||||
|
}, [socket, handlers]);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📱 Complete React Integration
|
||||||
|
|
||||||
|
### 1. Email Notification Component
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// components/EmailNotifications.tsx
|
||||||
|
import React, { useState, useCallback } from 'react';
|
||||||
|
import { useEmailWebSocket, useEmailEvents, EmailPayload, UnreadCountPayload } from '../hooks';
|
||||||
|
|
||||||
|
interface EmailNotificationsProps {
|
||||||
|
userToken: string;
|
||||||
|
onEmailClick?: (email: EmailPayload) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EmailNotifications: React.FC<EmailNotificationsProps> = ({
|
||||||
|
userToken,
|
||||||
|
onEmailClick
|
||||||
|
}) => {
|
||||||
|
const [notifications, setNotifications] = useState<EmailPayload[]>([]);
|
||||||
|
const [unreadCount, setUnreadCount] = useState(0);
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
|
||||||
|
const { socket, isConnected } = useEmailWebSocket({
|
||||||
|
token: userToken,
|
||||||
|
onConnect: () => console.log('Email notifications connected'),
|
||||||
|
onError: (error) => console.error('WebSocket error:', error),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleNewEmail = useCallback((email: EmailPayload) => {
|
||||||
|
console.log('📧 New email received:', email);
|
||||||
|
|
||||||
|
// Add to notifications list
|
||||||
|
setNotifications(prev => [email, ...prev.slice(0, 9)]); // Keep only 10 latest
|
||||||
|
|
||||||
|
// Show browser notification if permission granted
|
||||||
|
if (Notification.permission === 'granted') {
|
||||||
|
new Notification(`📧 New Email: ${email.subject}`, {
|
||||||
|
body: `From: ${email.from.address}\n${email.preview}`,
|
||||||
|
icon: '/email-icon.png',
|
||||||
|
tag: `email-${email.messageId}`, // Prevent duplicates
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Play notification sound (optional)
|
||||||
|
const audio = new Audio('/notification.mp3');
|
||||||
|
audio.play().catch(() => {}); // Ignore if audio fails
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleUnreadCountUpdate = useCallback((data: UnreadCountPayload) => {
|
||||||
|
setUnreadCount(data.count);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleEmailRead = useCallback((data: any) => {
|
||||||
|
// Remove from notifications if it was there
|
||||||
|
setNotifications(prev =>
|
||||||
|
prev.filter(notification => notification.messageId !== data.messageId)
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEmailEvents(socket, {
|
||||||
|
onNewEmail: handleNewEmail,
|
||||||
|
onUnreadCountUpdated: handleUnreadCountUpdate,
|
||||||
|
onEmailRead: handleEmailRead,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Request notification permission on mount
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (Notification.permission === 'default') {
|
||||||
|
Notification.requestPermission();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="email-notifications">
|
||||||
|
{/* Connection Status */}
|
||||||
|
<div className={`connection-status ${isConnected ? 'connected' : 'disconnected'}`}>
|
||||||
|
<span className="status-dot"></span>
|
||||||
|
{isConnected ? 'Connected' : 'Disconnected'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notification Bell */}
|
||||||
|
<div
|
||||||
|
className="notification-bell"
|
||||||
|
onClick={() => setIsVisible(!isVisible)}
|
||||||
|
>
|
||||||
|
🔔
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<span className="badge">{unreadCount > 99 ? '99+' : unreadCount}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notifications Dropdown */}
|
||||||
|
{isVisible && (
|
||||||
|
<div className="notifications-dropdown">
|
||||||
|
<div className="notifications-header">
|
||||||
|
<h3>📧 Email Notifications</h3>
|
||||||
|
<span>{unreadCount} unread</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="notifications-list">
|
||||||
|
{notifications.length === 0 ? (
|
||||||
|
<div className="no-notifications">No new emails</div>
|
||||||
|
) : (
|
||||||
|
notifications.map((email) => (
|
||||||
|
<div
|
||||||
|
key={email.messageId}
|
||||||
|
className="notification-item"
|
||||||
|
onClick={() => onEmailClick?.(email)}
|
||||||
|
>
|
||||||
|
<div className="sender">
|
||||||
|
<strong>{email.from.name || email.from.address}</strong>
|
||||||
|
{email.hasAttachments && <span className="attachment-icon">📎</span>}
|
||||||
|
</div>
|
||||||
|
<div className="subject">{email.subject}</div>
|
||||||
|
<div className="preview">{email.preview}</div>
|
||||||
|
<div className="timestamp">
|
||||||
|
{new Date(email.timestamp).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Email Dashboard Component
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// components/EmailDashboard.tsx
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useEmailWebSocket, useEmailEvents } from '../hooks';
|
||||||
|
|
||||||
|
interface EmailDashboardProps {
|
||||||
|
userToken: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EmailDashboard: React.FC<EmailDashboardProps> = ({ userToken }) => {
|
||||||
|
const [emailStats, setEmailStats] = useState({
|
||||||
|
totalUnread: 0,
|
||||||
|
newToday: 0,
|
||||||
|
sentToday: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { socket, isConnected } = useEmailWebSocket({
|
||||||
|
token: userToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEmailEvents(socket, {
|
||||||
|
onNewEmail: (email) => {
|
||||||
|
setEmailStats(prev => ({
|
||||||
|
...prev,
|
||||||
|
totalUnread: prev.totalUnread + 1,
|
||||||
|
newToday: prev.newToday + 1,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
onEmailSent: () => {
|
||||||
|
setEmailStats(prev => ({
|
||||||
|
...prev,
|
||||||
|
sentToday: prev.sentToday + 1,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
onUnreadCountUpdated: (data) => {
|
||||||
|
setEmailStats(prev => ({
|
||||||
|
...prev,
|
||||||
|
totalUnread: data.count,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="email-dashboard">
|
||||||
|
<h2>📧 Email Dashboard</h2>
|
||||||
|
|
||||||
|
<div className="stats-grid">
|
||||||
|
<div className="stat-card">
|
||||||
|
<h3>{emailStats.totalUnread}</h3>
|
||||||
|
<p>Unread Emails</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="stat-card">
|
||||||
|
<h3>{emailStats.newToday}</h3>
|
||||||
|
<p>Received Today</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="stat-card">
|
||||||
|
<h3>{emailStats.sentToday}</h3>
|
||||||
|
<p>Sent Today</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="stat-card">
|
||||||
|
<h3 className={isConnected ? 'connected' : 'disconnected'}>
|
||||||
|
{isConnected ? '🟢' : '🔴'}
|
||||||
|
</h3>
|
||||||
|
<p>{isConnected ? 'Connected' : 'Disconnected'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Context Provider (Advanced)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// context/EmailWebSocketContext.tsx
|
||||||
|
import React, { createContext, useContext, ReactNode } from 'react';
|
||||||
|
import { useEmailWebSocket } from '../hooks/useEmailWebSocket';
|
||||||
|
import { Socket } from 'socket.io-client';
|
||||||
|
|
||||||
|
interface EmailWebSocketContextValue {
|
||||||
|
socket: Socket | null;
|
||||||
|
isConnected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EmailWebSocketContext = createContext<EmailWebSocketContextValue | null>(null);
|
||||||
|
|
||||||
|
interface EmailWebSocketProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
token: string;
|
||||||
|
serverUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EmailWebSocketProvider: React.FC<EmailWebSocketProviderProps> = ({
|
||||||
|
children,
|
||||||
|
token,
|
||||||
|
serverUrl,
|
||||||
|
}) => {
|
||||||
|
const { socket, isConnected } = useEmailWebSocket({ token, serverUrl });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EmailWebSocketContext.Provider value={{ socket, isConnected }}>
|
||||||
|
{children}
|
||||||
|
</EmailWebSocketContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useEmailWebSocketContext = () => {
|
||||||
|
const context = useContext(EmailWebSocketContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useEmailWebSocketContext must be used within EmailWebSocketProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎨 CSS Styles
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* styles/email-notifications.css */
|
||||||
|
.email-notifications {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-status {
|
||||||
|
position: absolute;
|
||||||
|
top: -8px;
|
||||||
|
right: -8px;
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-status.connected {
|
||||||
|
background: #d4edda;
|
||||||
|
color: #155724;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-status.disconnected {
|
||||||
|
background: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell {
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 24px;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell:hover {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
background: #dc3545;
|
||||||
|
color: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
min-width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
font-size: 11px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notifications-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
right: 0;
|
||||||
|
width: 350px;
|
||||||
|
max-height: 400px;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
z-index: 1000;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notifications-header {
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-bottom: 1px solid #dee2e6;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notifications-list {
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-item {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-item:hover {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sender {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-icon {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subject {
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview {
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timestamp {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-notifications {
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dashboard Styles */
|
||||||
|
.email-dashboard {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: white;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card h3 {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card h3.connected {
|
||||||
|
color: #28a745;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card h3.disconnected {
|
||||||
|
color: #dc3545;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card p {
|
||||||
|
margin: 0;
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🧪 Testing & Manual Triggers
|
||||||
|
|
||||||
|
### Manual Email Check
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// API call to manually trigger email check
|
||||||
|
const triggerEmailCheck = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/email/gateway/check-emails", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${userToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
console.log("✅ Manual email check triggered:", result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ Failed to trigger email check:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Send Test Notification
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const sendTestNotification = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/email/gateway/test-notification", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${userToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
message: "Test notification from React app!",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
console.log("✅ Test notification sent:", result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ Failed to send test notification:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 Complete App Integration
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// App.tsx - Complete example
|
||||||
|
import React from 'react';
|
||||||
|
import { EmailWebSocketProvider } from './context/EmailWebSocketContext';
|
||||||
|
import { EmailNotifications } from './components/EmailNotifications';
|
||||||
|
import { EmailDashboard } from './components/EmailDashboard';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const userToken = localStorage.getItem('jwt-token') || '';
|
||||||
|
|
||||||
|
const handleEmailClick = (email: any) => {
|
||||||
|
console.log('Email clicked:', email);
|
||||||
|
// Navigate to email detail page
|
||||||
|
// router.push(`/emails/${email.messageId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!userToken) {
|
||||||
|
return <div>Please login to access email notifications</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EmailWebSocketProvider token={userToken}>
|
||||||
|
<div className="app">
|
||||||
|
<header>
|
||||||
|
<h1>📧 Email App</h1>
|
||||||
|
<EmailNotifications
|
||||||
|
userToken={userToken}
|
||||||
|
onEmailClick={handleEmailClick}
|
||||||
|
/>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<EmailDashboard userToken={userToken} />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</EmailWebSocketProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚨 Error Handling & Edge Cases
|
||||||
|
|
||||||
|
### Connection Recovery
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const useConnectionRecovery = (socket: Socket | null) => {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!socket) return;
|
||||||
|
|
||||||
|
const handleReconnect = () => {
|
||||||
|
console.log("🔄 WebSocket reconnecting...");
|
||||||
|
// Optionally refresh data or show reconnecting state
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReconnectError = () => {
|
||||||
|
console.error("❌ Failed to reconnect");
|
||||||
|
// Show user notification or retry logic
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.on("reconnect", handleReconnect);
|
||||||
|
socket.on("reconnect_error", handleReconnectError);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
socket.off("reconnect", handleReconnect);
|
||||||
|
socket.off("reconnect_error", handleReconnectError);
|
||||||
|
};
|
||||||
|
}, [socket]);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Token Refresh
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const useTokenRefresh = (socket: Socket | null) => {
|
||||||
|
useEffect(() => {
|
||||||
|
const refreshToken = () => {
|
||||||
|
const newToken = localStorage.getItem("jwt-token");
|
||||||
|
if (socket && newToken) {
|
||||||
|
socket.auth = { token: newToken };
|
||||||
|
socket.disconnect();
|
||||||
|
socket.connect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Listen for token refresh events
|
||||||
|
window.addEventListener("token-refreshed", refreshToken);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("token-refreshed", refreshToken);
|
||||||
|
};
|
||||||
|
}, [socket]);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 Environment Configuration
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// config/websocket.ts
|
||||||
|
export const WEBSOCKET_CONFIG = {
|
||||||
|
development: {
|
||||||
|
serverUrl: "ws://localhost:3000/email",
|
||||||
|
reconnectionAttempts: 5,
|
||||||
|
timeout: 20000,
|
||||||
|
},
|
||||||
|
production: {
|
||||||
|
serverUrl: "wss://your-domain.com/email",
|
||||||
|
reconnectionAttempts: 10,
|
||||||
|
timeout: 30000,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getWebSocketConfig = () => {
|
||||||
|
const env = process.env.NODE_ENV || "development";
|
||||||
|
return WEBSOCKET_CONFIG[env as keyof typeof WEBSOCKET_CONFIG];
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎯 Best Practices
|
||||||
|
|
||||||
|
1. **🔐 Security**: Always use HTTPS/WSS in production
|
||||||
|
2. **🔄 Reconnection**: Implement proper reconnection logic
|
||||||
|
3. **🎯 Performance**: Limit notification history (max 10-50 items)
|
||||||
|
4. **📱 UX**: Request notification permissions gracefully
|
||||||
|
5. **🔊 Audio**: Make notification sounds optional
|
||||||
|
6. **💾 Persistence**: Consider storing notifications in localStorage
|
||||||
|
7. **🎨 Theming**: Support light/dark mode for notifications
|
||||||
|
8. **♿ Accessibility**: Add proper ARIA labels and keyboard navigation
|
||||||
|
|
||||||
|
## 🐛 Troubleshooting
|
||||||
|
|
||||||
|
**Connection Issues:**
|
||||||
|
|
||||||
|
- Check JWT token validity
|
||||||
|
- Verify server URL and port
|
||||||
|
- Check browser console for errors
|
||||||
|
- Ensure CORS is properly configured
|
||||||
|
|
||||||
|
**Missing Notifications:**
|
||||||
|
|
||||||
|
- Verify WebSocket event names match backend
|
||||||
|
- Check browser notification permissions
|
||||||
|
- Confirm user is authenticated properly
|
||||||
|
- Test with manual triggers
|
||||||
|
|
||||||
|
**Performance Issues:**
|
||||||
|
|
||||||
|
- Limit notification history
|
||||||
|
- Debounce rapid events
|
||||||
|
- Use React.memo for components
|
||||||
|
- Implement virtual scrolling for long lists
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Ready to Go!
|
||||||
|
|
||||||
|
Your React app is now ready to receive real-time email notifications! The WebSocket connection will automatically:
|
||||||
|
|
||||||
|
- ✅ Authenticate users with JWT tokens
|
||||||
|
- ✅ Receive new email notifications instantly
|
||||||
|
- ✅ Update unread counts in real-time
|
||||||
|
- ✅ Handle connection recovery automatically
|
||||||
|
- ✅ Show browser notifications (with permission)
|
||||||
|
- ✅ Provide a beautiful UI for email notifications
|
||||||
|
|
||||||
|
Happy coding! 🎉
|
||||||
@@ -15,7 +15,7 @@ export class PaginationDto {
|
|||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@Min(1)
|
@Min(1)
|
||||||
@Max(50)
|
@Max(100)
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
@ApiPropertyOptional({ type: "number", required: false })
|
@ApiPropertyOptional({ type: "number", required: false })
|
||||||
limit?: number;
|
limit?: number;
|
||||||
|
|||||||
@@ -218,7 +218,8 @@ export const enum EmailMessage {
|
|||||||
BULK_ACTION_PARTIALLY_COMPLETED = "عملیات گروهی تا حدی انجام شد",
|
BULK_ACTION_PARTIALLY_COMPLETED = "عملیات گروهی تا حدی انجام شد",
|
||||||
BULK_ACTION_FAILED = "عملیات گروهی با خطا مواجه شد",
|
BULK_ACTION_FAILED = "عملیات گروهی با خطا مواجه شد",
|
||||||
USER_NOT_FOUND = "کاربر یافت نشد",
|
USER_NOT_FOUND = "کاربر یافت نشد",
|
||||||
PRIORITY_MUST_BE_ONE_OF_HIGH_NORMAL_LOW = "Priority must be one of: high, normal, low",
|
PRIORITY_MUST_BE_ONE_OF_HIGH_NORMAL_LOW = "اولویت باید یکی از مقادیر high, normal, low باشد",
|
||||||
|
SUBJECT_REQUIRED = "موضوع ایمیل مورد نیاز است",
|
||||||
}
|
}
|
||||||
|
|
||||||
export const enum WebSocketMessage {
|
export const enum WebSocketMessage {
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
|
import { HttpModuleAsyncOptions } from "@nestjs/axios";
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
|
||||||
export const wildduckConfig = () => ({
|
export const wildduckConfig = (): HttpModuleAsyncOptions => ({
|
||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
useFactory: (configService: ConfigService) => ({
|
useFactory: (configService: ConfigService) => ({
|
||||||
baseUrl: configService.getOrThrow<string>("WILDDUCK_BASE_URL"),
|
baseURL: configService.getOrThrow<string>("WILDDUCK_BASE_URL"),
|
||||||
accessToken: configService.getOrThrow<string>("WILDDUCK_ACCESS_TOKEN"),
|
|
||||||
timeout: configService.getOrThrow<number>("WILDDUCK_TIMEOUT"),
|
timeout: configService.getOrThrow<number>("WILDDUCK_TIMEOUT"),
|
||||||
retries: configService.getOrThrow<number>("WILDDUCK_RETRIES"),
|
retries: configService.getOrThrow<number>("WILDDUCK_RETRIES"),
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
"X-Access-Token": configService.getOrThrow<string>("WILDDUCK_ACCESS_TOKEN"),
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { ArgumentsHost, Catch, ExceptionFilter, Logger, UnauthorizedException } from "@nestjs/common";
|
||||||
|
import { WsException } from "@nestjs/websockets";
|
||||||
|
import { Socket } from "socket.io";
|
||||||
|
|
||||||
|
import { WebSocketMessage } from "../../common/enums/message.enum";
|
||||||
|
import { WEBSOCKET_EVENTS } from "../../modules/email/constants/email-events.constant";
|
||||||
|
|
||||||
|
@Catch(WsException)
|
||||||
|
export class WsExceptionFilter implements ExceptionFilter {
|
||||||
|
private readonly logger = new Logger(WsExceptionFilter.name);
|
||||||
|
|
||||||
|
catch(exception: WsException, host: ArgumentsHost) {
|
||||||
|
const client = host.switchToWs().getClient<Socket>();
|
||||||
|
const data = host.switchToWs().getData();
|
||||||
|
|
||||||
|
this.logger.error(`WebSocket Error: ${exception.message}`);
|
||||||
|
this.logger.error(`Client ID: ${client.id}`);
|
||||||
|
this.logger.error(`Data: ${JSON.stringify(data)}`);
|
||||||
|
|
||||||
|
if (exception instanceof UnauthorizedException) {
|
||||||
|
client.emit(WEBSOCKET_EVENTS.CONNECTION_ERROR, {
|
||||||
|
message: WebSocketMessage.INVALID_TOKEN,
|
||||||
|
});
|
||||||
|
client.disconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorResponse = {
|
||||||
|
status: "error",
|
||||||
|
message: exception.message || WebSocketMessage.WEBSOCKET_ERROR,
|
||||||
|
};
|
||||||
|
|
||||||
|
client.emit(WEBSOCKET_EVENTS.CONNECTION_ERROR, errorResponse);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -86,12 +86,12 @@ export class PaginationInterceptor implements NestInterceptor {
|
|||||||
if (!page) return false;
|
if (!page) return false;
|
||||||
|
|
||||||
const { protocol, hostname, url } = request;
|
const { protocol, hostname, url } = request;
|
||||||
const baseUrl = url.split("?")[0];
|
const baseURL = url.split("?")[0];
|
||||||
const queryParams = new URLSearchParams({
|
const queryParams = new URLSearchParams({
|
||||||
page: page.toString(),
|
page: page.toString(),
|
||||||
limit: limit.toString(),
|
limit: limit.toString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
return `${protocol}://${hostname}${baseUrl}?${queryParams.toString()}`;
|
return `${protocol}://${hostname}${baseURL}?${queryParams.toString()}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -151,10 +151,11 @@ export class SendEmailDto {
|
|||||||
// })
|
// })
|
||||||
headers?: EmailHeaderDto[];
|
headers?: EmailHeaderDto[];
|
||||||
|
|
||||||
@IsOptional()
|
// @IsOptional()
|
||||||
|
@IsNotEmpty({ message: EmailMessage.SUBJECT_REQUIRED })
|
||||||
@IsString({ message: EmailMessage.SUBJECT_MUST_BE_STRING })
|
@IsString({ message: EmailMessage.SUBJECT_MUST_BE_STRING })
|
||||||
@ApiPropertyOptional({ description: "Message subject. If not then resolved from Reference message (optional)", example: "Test email" })
|
@ApiPropertyOptional({ description: "Message subject. If not then resolved from Reference message (optional)", example: "Test email" })
|
||||||
subject?: string;
|
subject: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString({ message: EmailMessage.TEXT_CONTENT_MUST_BE_STRING })
|
@IsString({ message: EmailMessage.TEXT_CONTENT_MUST_BE_STRING })
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ export const WEBSOCKET_EVENTS = {
|
|||||||
// Connection Events
|
// Connection Events
|
||||||
CONNECTION_ESTABLISHED: "connection_established",
|
CONNECTION_ESTABLISHED: "connection_established",
|
||||||
CONNECTION_ERROR: "connection_error",
|
CONNECTION_ERROR: "connection_error",
|
||||||
|
CONNECTION_CLOSED: "connection_closed",
|
||||||
|
USER_LEFT: "user_left",
|
||||||
|
UNAUTHORIZED: "unauthorized",
|
||||||
// Email Events - Server to Client
|
// Email Events - Server to Client
|
||||||
NEW_EMAIL: "new_email",
|
NEW_EMAIL: "new_email",
|
||||||
EMAIL_READ: "email_read",
|
EMAIL_READ: "email_read",
|
||||||
@@ -34,3 +36,10 @@ export const WEBSOCKET_NAMESPACES = {
|
|||||||
export type WebSocketEvent = (typeof WEBSOCKET_EVENTS)[keyof typeof WEBSOCKET_EVENTS];
|
export type WebSocketEvent = (typeof WEBSOCKET_EVENTS)[keyof typeof WEBSOCKET_EVENTS];
|
||||||
export type WebSocketRoom = string;
|
export type WebSocketRoom = string;
|
||||||
export type WebSocketNamespace = (typeof WEBSOCKET_NAMESPACES)[keyof typeof WEBSOCKET_NAMESPACES];
|
export type WebSocketNamespace = (typeof WEBSOCKET_NAMESPACES)[keyof typeof WEBSOCKET_NAMESPACES];
|
||||||
|
|
||||||
|
export const EMAIL_QUEUE_CONSTANTS = {
|
||||||
|
EMAIL_QUEUE: "email_queue",
|
||||||
|
EMAIL_QUEUE_PROCESSING: "email_queue_processing",
|
||||||
|
EMAIL_QUEUE_FAILED: "email_queue_failed",
|
||||||
|
EMAIL_QUEUE_COMPLETED: "email_queue_completed",
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
// import { Body, Controller, Get, Param, Post } from "@nestjs/common";
|
||||||
|
// import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
|
||||||
|
|
||||||
|
// import { AuthGuards } from "../../../common/decorators/auth-guard.decorator";
|
||||||
|
// import { UserDec } from "../../../common/decorators/user.decorator";
|
||||||
|
// import { EmailMonitoringService } from "../services/email-monitoring.service";
|
||||||
|
// import { EmailNotificationService } from "../services/email-notification.service";
|
||||||
|
|
||||||
|
// @ApiTags("Email Gateway")
|
||||||
|
// @AuthGuards()
|
||||||
|
// @Controller("email/gateway")
|
||||||
|
// export class EmailGatewayController {
|
||||||
|
// constructor(
|
||||||
|
// private readonly emailMonitoringService: EmailMonitoringService,
|
||||||
|
// private readonly emailNotificationService: EmailNotificationService,
|
||||||
|
// ) {}
|
||||||
|
|
||||||
|
// @Get("status")
|
||||||
|
// @ApiOperation({ summary: "Get email gateway status" })
|
||||||
|
// @ApiResponse({ status: 200, description: "Gateway status retrieved successfully" })
|
||||||
|
// async getGatewayStatus() {
|
||||||
|
// const [gatewayStats, queueStatus] = await Promise.all([
|
||||||
|
// this.emailNotificationService.getGatewayStats(),
|
||||||
|
// this.emailMonitoringService.getQueueStatus(),
|
||||||
|
// ]);
|
||||||
|
|
||||||
|
// return {
|
||||||
|
// message: "Email gateway status retrieved successfully",
|
||||||
|
// data: {
|
||||||
|
// gateway: gatewayStats,
|
||||||
|
// queue: queueStatus,
|
||||||
|
// },
|
||||||
|
// };
|
||||||
|
// }
|
||||||
|
|
||||||
|
// @Post("check-emails")
|
||||||
|
// @ApiOperation({ summary: "Manually trigger email check for current user" })
|
||||||
|
// @ApiResponse({ status: 200, description: "Email check initiated successfully" })
|
||||||
|
// async checkUserEmails(@UserDec("id") userId: string) {
|
||||||
|
// await this.emailMonitoringService.checkUserEmails(userId);
|
||||||
|
|
||||||
|
// return {
|
||||||
|
// message: "Email check initiated successfully",
|
||||||
|
// userId,
|
||||||
|
// };
|
||||||
|
// }
|
||||||
|
|
||||||
|
// @Get("connected-users")
|
||||||
|
// @ApiOperation({ summary: "Get connected users count" })
|
||||||
|
// @ApiResponse({ status: 200, description: "Connected users count retrieved successfully" })
|
||||||
|
// async getConnectedUsers() {
|
||||||
|
// const stats = await this.emailNotificationService.getGatewayStats();
|
||||||
|
|
||||||
|
// return {
|
||||||
|
// message: "Connected users retrieved successfully",
|
||||||
|
// data: stats,
|
||||||
|
// };
|
||||||
|
// }
|
||||||
|
|
||||||
|
// @Post("test-notification")
|
||||||
|
// @ApiOperation({ summary: "Send test notification to current user" })
|
||||||
|
// @ApiResponse({ status: 200, description: "Test notification sent successfully" })
|
||||||
|
// async sendTestNotification(@UserDec("id") userId: string, @Body() body: { message?: string }) {
|
||||||
|
// await this.emailNotificationService.broadcastSystemNotification("test", {
|
||||||
|
// message: body.message || "Test notification from Email Gateway",
|
||||||
|
// userId,
|
||||||
|
// });
|
||||||
|
|
||||||
|
// return {
|
||||||
|
// message: "Test notification sent successfully",
|
||||||
|
// userId,
|
||||||
|
// };
|
||||||
|
// }
|
||||||
|
|
||||||
|
// @Get("business/:businessId/users")
|
||||||
|
// @ApiOperation({ summary: "Get connected users for a business" })
|
||||||
|
// @ApiResponse({ status: 200, description: "Business connected users retrieved successfully" })
|
||||||
|
// async getBusinessConnectedUsers(@Param("businessId") businessId: string) {
|
||||||
|
// const connectedUsers = await this.emailNotificationService.getBusinessConnectedUsers(businessId);
|
||||||
|
|
||||||
|
// return {
|
||||||
|
// message: "Business connected users retrieved successfully",
|
||||||
|
// businessId,
|
||||||
|
// data: {
|
||||||
|
// connectedUsers,
|
||||||
|
// count: connectedUsers.length,
|
||||||
|
// },
|
||||||
|
// };
|
||||||
|
// }
|
||||||
|
|
||||||
|
// @Post("cleanup")
|
||||||
|
// @ApiOperation({ summary: "Clean up old monitoring jobs" })
|
||||||
|
// @ApiResponse({ status: 200, description: "Cleanup completed successfully" })
|
||||||
|
// async cleanupJobs() {
|
||||||
|
// await this.emailMonitoringService.cleanupJobs();
|
||||||
|
|
||||||
|
// return {
|
||||||
|
// message: "Cleanup completed successfully",
|
||||||
|
// };
|
||||||
|
// }
|
||||||
|
// }
|
||||||
@@ -1,9 +1,16 @@
|
|||||||
import { MikroOrmModule } from "@mikro-orm/nestjs";
|
import { MikroOrmModule } from "@mikro-orm/nestjs";
|
||||||
|
import { BullModule } from "@nestjs/bullmq";
|
||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { JwtModule } from "@nestjs/jwt";
|
import { JwtModule } from "@nestjs/jwt";
|
||||||
|
|
||||||
|
// import { EmailGatewayController } from "./controllers/email-gateway.controller";
|
||||||
import { EmailController } from "./email.controller";
|
import { EmailController } from "./email.controller";
|
||||||
|
import { EmailGateway } from "./gateways/email.gateway";
|
||||||
|
import { EmailMonitoringProcessor } from "./queue/email-monitoring.processor";
|
||||||
import { EmailHeadersService } from "./services/email-headers.service";
|
import { EmailHeadersService } from "./services/email-headers.service";
|
||||||
|
import { EmailMonitoringSchedulerService } from "./services/email-monitoring-scheduler.service";
|
||||||
|
import { EmailMonitoringService } from "./services/email-monitoring.service";
|
||||||
|
import { EmailNotificationService } from "./services/email-notification.service";
|
||||||
import { EmailService } from "./services/email.service";
|
import { EmailService } from "./services/email.service";
|
||||||
import { MailboxResolverService } from "./services/mailbox-resolver.service";
|
import { MailboxResolverService } from "./services/mailbox-resolver.service";
|
||||||
import { jwtConfig } from "../../configs/jwt.config";
|
import { jwtConfig } from "../../configs/jwt.config";
|
||||||
@@ -11,11 +18,32 @@ import { MailServerModule } from "../mail-server/mail-server.module";
|
|||||||
import { TemplatesModule } from "../templates/templates.module";
|
import { TemplatesModule } from "../templates/templates.module";
|
||||||
import { User } from "../users/entities/user.entity";
|
import { User } from "../users/entities/user.entity";
|
||||||
import { UsersModule } from "../users/users.module";
|
import { UsersModule } from "../users/users.module";
|
||||||
|
import { EMAIL_QUEUE_CONSTANTS } from "./constants/email-events.constant";
|
||||||
|
import { WebSocketAuthService } from "./services/websocket-auth.service";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [MailServerModule, UsersModule, TemplatesModule, JwtModule.registerAsync(jwtConfig()), MikroOrmModule.forFeature([User])],
|
imports: [
|
||||||
|
MailServerModule,
|
||||||
|
UsersModule,
|
||||||
|
TemplatesModule,
|
||||||
|
JwtModule.registerAsync(jwtConfig()),
|
||||||
|
MikroOrmModule.forFeature([User]),
|
||||||
|
BullModule.registerQueue({
|
||||||
|
name: EMAIL_QUEUE_CONSTANTS.EMAIL_QUEUE,
|
||||||
|
}),
|
||||||
|
],
|
||||||
controllers: [EmailController],
|
controllers: [EmailController],
|
||||||
providers: [EmailService, MailboxResolverService, EmailHeadersService],
|
providers: [
|
||||||
exports: [EmailService, MailboxResolverService, EmailHeadersService],
|
EmailService,
|
||||||
|
MailboxResolverService,
|
||||||
|
EmailHeadersService,
|
||||||
|
EmailGateway,
|
||||||
|
EmailMonitoringService,
|
||||||
|
EmailMonitoringSchedulerService,
|
||||||
|
EmailNotificationService,
|
||||||
|
EmailMonitoringProcessor,
|
||||||
|
WebSocketAuthService,
|
||||||
|
],
|
||||||
|
exports: [EmailService, MailboxResolverService, EmailHeadersService, EmailGateway, EmailMonitoringService, EmailNotificationService],
|
||||||
})
|
})
|
||||||
export class EmailModule {}
|
export class EmailModule {}
|
||||||
|
|||||||
@@ -0,0 +1,268 @@
|
|||||||
|
import { Logger, UseFilters, UseGuards, UsePipes, ValidationPipe } from "@nestjs/common";
|
||||||
|
import {
|
||||||
|
ConnectedSocket,
|
||||||
|
OnGatewayConnection,
|
||||||
|
OnGatewayDisconnect,
|
||||||
|
OnGatewayInit,
|
||||||
|
SubscribeMessage,
|
||||||
|
WebSocketGateway,
|
||||||
|
WebSocketServer,
|
||||||
|
} from "@nestjs/websockets";
|
||||||
|
import { Server, Socket } from "socket.io";
|
||||||
|
|
||||||
|
import { WebSocketMessage } from "../../../common/enums/message.enum";
|
||||||
|
import { WsExceptionFilter } from "../../../core/filters/ws-exception.filter";
|
||||||
|
import { WEBSOCKET_EVENTS, WEBSOCKET_NAMESPACES, WEBSOCKET_ROOMS, WebSocketEvent } from "../constants/email-events.constant";
|
||||||
|
import { WebSocketAuthGuard } from "../guards/websocket-auth.guard";
|
||||||
|
import {
|
||||||
|
EmailDeletePayload,
|
||||||
|
EmailPayload,
|
||||||
|
EmailSentPayload,
|
||||||
|
EmailStatusPayload,
|
||||||
|
MailboxUpdatePayload,
|
||||||
|
UnreadCountPayload,
|
||||||
|
} from "../interfaces/email-events.interface";
|
||||||
|
import { ConnectedUserInfo } from "../interfaces/websocket.interface";
|
||||||
|
import { WebSocketAuthService } from "../services/websocket-auth.service";
|
||||||
|
|
||||||
|
@UsePipes(new ValidationPipe({ transform: true }))
|
||||||
|
@UseFilters(WsExceptionFilter)
|
||||||
|
@WebSocketGateway({ namespace: WEBSOCKET_NAMESPACES.EMAIL, cors: { origin: true, credentials: true } })
|
||||||
|
export class EmailGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect {
|
||||||
|
@WebSocketServer()
|
||||||
|
server: Server;
|
||||||
|
|
||||||
|
private readonly logger = new Logger(EmailGateway.name);
|
||||||
|
|
||||||
|
private readonly connectedUsers = new Map<string, ConnectedUserInfo>();
|
||||||
|
private readonly userSessions = new Map<string, Set<string>>();
|
||||||
|
|
||||||
|
constructor(private readonly authService: WebSocketAuthService) {}
|
||||||
|
|
||||||
|
afterInit(server: Server) {
|
||||||
|
this.logger.log("Email WebSocket Gateway initialized");
|
||||||
|
this.logger.log(`Namespace: ${WEBSOCKET_NAMESPACES.EMAIL}`);
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
server.emit(WEBSOCKET_EVENTS.PING, { timestamp: new Date().toISOString() });
|
||||||
|
}, 30000);
|
||||||
|
}
|
||||||
|
|
||||||
|
//************************************************************************ */
|
||||||
|
async handleConnection(client: Socket) {
|
||||||
|
const connectionContext = {
|
||||||
|
clientId: client.id,
|
||||||
|
clientIP: client.handshake.address,
|
||||||
|
userAgent: client.handshake.headers["user-agent"],
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.logger.log("New WebSocket connection attempt", connectionContext);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const authResult = await this.authService.authenticateClient(client);
|
||||||
|
|
||||||
|
if (!authResult.success) {
|
||||||
|
this.authService.handleAuthenticationFailure(client, authResult.error!);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = authResult.user!;
|
||||||
|
this.registerUserConnection(client.id, user.id);
|
||||||
|
|
||||||
|
const userRoom = WEBSOCKET_ROOMS.USER(user.id);
|
||||||
|
await client.join(userRoom);
|
||||||
|
|
||||||
|
const userInfo = this.connectedUsers.get(client.id);
|
||||||
|
this.authService.emitAuthenticationSuccess(client, user);
|
||||||
|
|
||||||
|
this.logger.log("WebSocket connection established", {
|
||||||
|
...connectionContext,
|
||||||
|
userId: user.id,
|
||||||
|
sessionId: userInfo?.sessionId,
|
||||||
|
userRoom,
|
||||||
|
authenticated: true,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error("WebSocket connection error", {
|
||||||
|
...connectionContext,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
|
||||||
|
this.authService.handleAuthenticationFailure(client, WebSocketMessage.CONNECTION_FAILED);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//************************************************************************ */
|
||||||
|
handleDisconnect(client: Socket): void {
|
||||||
|
const userInfo = this.connectedUsers.get(client.id);
|
||||||
|
|
||||||
|
this.logger.log("WebSocket client disconnected", {
|
||||||
|
clientId: client.id,
|
||||||
|
userId: userInfo?.userId,
|
||||||
|
sessionId: userInfo?.sessionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (userInfo) {
|
||||||
|
// Leave user room
|
||||||
|
const userRoom = WEBSOCKET_ROOMS.USER(userInfo.userId);
|
||||||
|
client.leave(userRoom);
|
||||||
|
|
||||||
|
this.cleanupUserConnection(client, userInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.connectedUsers.delete(client.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
//=======================================================
|
||||||
|
//=======================================================
|
||||||
|
|
||||||
|
@SubscribeMessage(WEBSOCKET_EVENTS.PING)
|
||||||
|
@UseGuards(WebSocketAuthGuard)
|
||||||
|
handlePing(@ConnectedSocket() client: Socket) {
|
||||||
|
client.emit(WEBSOCKET_EVENTS.PONG, { timestamp: new Date().toISOString() });
|
||||||
|
}
|
||||||
|
|
||||||
|
//************************************************************************ */
|
||||||
|
async notifyNewEmail(userId: string, emailData: EmailPayload) {
|
||||||
|
return await this.sendUserNotification(userId, WEBSOCKET_EVENTS.NEW_EMAIL, emailData, `New email: ${emailData.subject}`);
|
||||||
|
}
|
||||||
|
//************************************************************************ */
|
||||||
|
async notifyEmailRead(userId: string, data: EmailStatusPayload) {
|
||||||
|
return await this.sendUserNotification(userId, WEBSOCKET_EVENTS.EMAIL_READ, data, `Message ${data.messageId} marked as read`);
|
||||||
|
}
|
||||||
|
//************************************************************************ */
|
||||||
|
async notifyEmailDeleted(userId: string, data: EmailDeletePayload) {
|
||||||
|
return await this.sendUserNotification(userId, WEBSOCKET_EVENTS.EMAIL_DELETED, data, `Message ${data.messageId} deleted`);
|
||||||
|
}
|
||||||
|
//************************************************************************ */
|
||||||
|
async notifyEmailSent(userId: string, data: EmailSentPayload) {
|
||||||
|
return await this.sendUserNotification(
|
||||||
|
userId,
|
||||||
|
WEBSOCKET_EVENTS.EMAIL_SENT,
|
||||||
|
data,
|
||||||
|
`Email ${data.messageId} sent to ${data.to.map((t) => t.address).join(", ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
//************************************************************************ */
|
||||||
|
async notifyUnreadCountUpdate(userId: string, data: UnreadCountPayload) {
|
||||||
|
return await this.sendUserNotification(userId, WEBSOCKET_EVENTS.UNREAD_COUNT_UPDATED, data, `Unread count: ${data.count}`);
|
||||||
|
}
|
||||||
|
//************************************************************************ */
|
||||||
|
|
||||||
|
async notifyMailboxUpdate(userId: string, data: MailboxUpdatePayload) {
|
||||||
|
return await this.sendUserNotification(userId, WEBSOCKET_EVENTS.MAILBOX_UPDATED, data, `Mailbox updated: ${data.mailboxName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
//************************************************************************ */
|
||||||
|
|
||||||
|
async broadcastSystemMessage(event: WebSocketEvent, data: Record<string, unknown>) {
|
||||||
|
try {
|
||||||
|
const enhancedData = {
|
||||||
|
...data,
|
||||||
|
timestamp: data.timestamp || new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.server.emit(event, enhancedData);
|
||||||
|
this.logger.log(`System message broadcasted: ${event}`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to broadcast system message ${event}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//************************************************************************ */
|
||||||
|
async getConnectedUsersCount(): Promise<number> {
|
||||||
|
const sockets = await this.server.fetchSockets();
|
||||||
|
return sockets.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
//************************************************************************ */
|
||||||
|
getConnectedUserIds(): string[] {
|
||||||
|
return Array.from(this.userSessions.keys());
|
||||||
|
}
|
||||||
|
|
||||||
|
//************************************************************************ */
|
||||||
|
isUserConnected(userId: string): boolean {
|
||||||
|
const userSockets = this.userSessions.get(userId);
|
||||||
|
return userSockets !== undefined && userSockets.size > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
//************************************************************************ */
|
||||||
|
getConnectedUsersInList(userIds: string[]): string[] {
|
||||||
|
return userIds.filter((userId) => this.isUserConnected(userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
//************************************************************************ */
|
||||||
|
async getBusinessConnectedUsers(businessId: string): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
// Note: Currently returning all connected users since business filtering
|
||||||
|
// requires additional user data lookup. This can be enhanced later.
|
||||||
|
this.logger.warn(`getBusinessConnectedUsers called for business ${businessId} - returning all connected users for now`);
|
||||||
|
return this.getConnectedUserIds();
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to get business connected users for ${businessId}:`, error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// PRIVATE HELPER METHODS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
private async sendUserNotification(userId: string, event: WebSocketEvent, data: Record<string, any>, context?: string) {
|
||||||
|
try {
|
||||||
|
const userRoom = WEBSOCKET_ROOMS.USER(userId);
|
||||||
|
const enhancedData = {
|
||||||
|
...data,
|
||||||
|
timestamp: data.timestamp || new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.server.to(userRoom).emit(event, enhancedData);
|
||||||
|
|
||||||
|
this.logger.log(`${event} notification sent to user ${userId}${context ? ` - ${context}` : ""}`);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to send ${event} notification to user ${userId}:`, error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//************************************************************************ */
|
||||||
|
private registerUserConnection(clientId: string, userId: string): void {
|
||||||
|
const sessionId = this.generateSessionId();
|
||||||
|
this.connectedUsers.set(clientId, { userId, sessionId });
|
||||||
|
|
||||||
|
if (!this.userSessions.has(userId)) {
|
||||||
|
this.userSessions.set(userId, new Set());
|
||||||
|
}
|
||||||
|
this.userSessions.get(userId)!.add(clientId);
|
||||||
|
|
||||||
|
this.logger.debug(`User ${userId} registered with session ${sessionId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
//************************************************************************ */
|
||||||
|
private generateSessionId(): string {
|
||||||
|
return `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
//************************************************************************ */
|
||||||
|
private cleanupUserConnection(client: Socket, userInfo: ConnectedUserInfo): void {
|
||||||
|
const userSockets = this.userSessions.get(userInfo.userId);
|
||||||
|
if (userSockets) {
|
||||||
|
userSockets.delete(client.id);
|
||||||
|
if (userSockets.size === 0) {
|
||||||
|
this.userSessions.delete(userInfo.userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userInfo.sessionId) {
|
||||||
|
client.leave(`session_${userInfo.sessionId}`);
|
||||||
|
|
||||||
|
this.server.to(`session_${userInfo.sessionId}`).emit(WEBSOCKET_EVENTS.CONNECTION_CLOSED, {
|
||||||
|
userId: userInfo.userId,
|
||||||
|
sessionId: userInfo.sessionId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { CanActivate, ExecutionContext, Injectable, Logger } from "@nestjs/common";
|
||||||
|
import { JwtService } from "@nestjs/jwt";
|
||||||
|
import { WsException } from "@nestjs/websockets";
|
||||||
|
import { Socket } from "socket.io";
|
||||||
|
|
||||||
|
import { WebSocketMessage } from "../../../common/enums/message.enum";
|
||||||
|
import { WEBSOCKET_EVENTS } from "../constants/email-events.constant";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WebSocketAuthGuard implements CanActivate {
|
||||||
|
private readonly logger = new Logger(WebSocketAuthGuard.name);
|
||||||
|
|
||||||
|
constructor(private jwtService: JwtService) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const client: Socket = context.switchToWs().getClient<Socket>();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get token from auth object or handshake query
|
||||||
|
const token = this.extractTokenFromHeader(client);
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
this.logger.warn("No token provided in WebSocket connection");
|
||||||
|
throw new WsException({
|
||||||
|
event: WEBSOCKET_EVENTS.UNAUTHORIZED,
|
||||||
|
message: WebSocketMessage.AUTHENTICATION_REQUIRED,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify JWT token
|
||||||
|
const payload = await this.jwtService.verifyAsync(token);
|
||||||
|
|
||||||
|
client.data.user = payload;
|
||||||
|
this.logger.log(`WebSocket authentication successful for user: ${payload.id}`);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error("WebSocket authentication failed:", error);
|
||||||
|
|
||||||
|
client.emit(WEBSOCKET_EVENTS.UNAUTHORIZED, {
|
||||||
|
message: WebSocketMessage.INVALID_TOKEN,
|
||||||
|
});
|
||||||
|
|
||||||
|
throw new WsException({
|
||||||
|
event: WEBSOCKET_EVENTS.UNAUTHORIZED,
|
||||||
|
message: WebSocketMessage.INVALID_TOKEN,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractTokenFromHeader(client: Socket): string | undefined {
|
||||||
|
const authToken = client.handshake?.auth?.token || client.handshake?.query?.token;
|
||||||
|
return authToken;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,26 @@
|
|||||||
export interface EmailPayload {
|
// Base interface with common properties
|
||||||
messageId: number;
|
export interface BaseEventPayload {
|
||||||
userId: string;
|
userId: string;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Base interface for message-related events
|
||||||
|
export interface BaseMessageEventPayload extends BaseEventPayload {
|
||||||
|
messageId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Base interface for mailbox-related events
|
||||||
|
export interface BaseMailboxEventPayload extends BaseEventPayload {
|
||||||
|
mailboxId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Base interface for message events that also involve mailboxes
|
||||||
|
export interface BaseMessageMailboxEventPayload extends BaseMessageEventPayload {
|
||||||
|
mailboxId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Specific email event interfaces
|
||||||
|
export interface EmailPayload extends BaseMessageMailboxEventPayload {
|
||||||
from: {
|
from: {
|
||||||
name?: string;
|
name?: string;
|
||||||
address: string;
|
address: string;
|
||||||
@@ -12,64 +32,46 @@ export interface EmailPayload {
|
|||||||
subject: string;
|
subject: string;
|
||||||
preview?: string;
|
preview?: string;
|
||||||
hasAttachments: boolean;
|
hasAttachments: boolean;
|
||||||
timestamp: string;
|
|
||||||
mailboxId: string;
|
|
||||||
mailboxName: string;
|
mailboxName: string;
|
||||||
isRead: boolean;
|
isRead: boolean;
|
||||||
isFlagged: boolean;
|
|
||||||
size: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EmailStatusPayload {
|
export type EmailStatusPayload = BaseMessageMailboxEventPayload;
|
||||||
messageId: number;
|
|
||||||
userId: string;
|
|
||||||
mailboxId: string;
|
|
||||||
timestamp: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EmailMovePayload {
|
export interface EmailMovePayload extends BaseMessageEventPayload {
|
||||||
messageId: number;
|
|
||||||
userId: string;
|
|
||||||
fromMailboxId: string;
|
fromMailboxId: string;
|
||||||
toMailboxId: string;
|
toMailboxId: string;
|
||||||
fromMailboxName: string;
|
fromMailboxName: string;
|
||||||
toMailboxName: string;
|
toMailboxName: string;
|
||||||
timestamp: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EmailDeletePayload {
|
export type EmailDeletePayload = BaseMessageMailboxEventPayload;
|
||||||
messageId: number;
|
|
||||||
userId: string;
|
|
||||||
mailboxId: string;
|
|
||||||
timestamp: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EmailSentPayload {
|
export interface EmailSentPayload extends BaseMessageMailboxEventPayload {
|
||||||
messageId: number;
|
from: {
|
||||||
userId: string;
|
name?: string;
|
||||||
|
address: string;
|
||||||
|
};
|
||||||
to: Array<{
|
to: Array<{
|
||||||
name?: string;
|
name?: string;
|
||||||
address: string;
|
address: string;
|
||||||
}>;
|
}>;
|
||||||
subject: string;
|
subject: string;
|
||||||
timestamp: string;
|
hasAttachments: boolean;
|
||||||
mailboxId: string;
|
mailboxName: string;
|
||||||
|
isRead: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MailboxUpdatePayload {
|
export interface MailboxUpdatePayload extends BaseMailboxEventPayload {
|
||||||
userId: string;
|
|
||||||
mailboxId: string;
|
|
||||||
mailboxName: string;
|
mailboxName: string;
|
||||||
unreadCount: number;
|
unreadCount: number;
|
||||||
totalCount: number;
|
totalCount: number;
|
||||||
timestamp: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UnreadCountPayload {
|
export interface UnreadCountPayload extends BaseEventPayload {
|
||||||
userId: string;
|
|
||||||
totalUnread: number;
|
totalUnread: number;
|
||||||
mailboxCounts: Record<string, number>;
|
mailboxCounts: Record<string, number>;
|
||||||
timestamp: string;
|
count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EmailWebSocketEvents {
|
export interface EmailWebSocketEvents {
|
||||||
@@ -93,3 +95,39 @@ export interface EmailWebSocketEvents {
|
|||||||
ping: { timestamp: string };
|
ping: { timestamp: string };
|
||||||
pong: { timestamp: string };
|
pong: { timestamp: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EmailMonitoringJob {
|
||||||
|
userId: string;
|
||||||
|
lastCheckTimestamp?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NewEmailData {
|
||||||
|
userId: string;
|
||||||
|
messageId: number;
|
||||||
|
wildduckUserId: string;
|
||||||
|
mailboxId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper interfaces for notification service parameters
|
||||||
|
export interface EmailSentNotificationParams {
|
||||||
|
userId: string;
|
||||||
|
messageId: number;
|
||||||
|
mailboxId: string;
|
||||||
|
recipients: Array<{ name?: string; address: string }>;
|
||||||
|
subject: string;
|
||||||
|
from: { name?: string; address: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EmailStatusNotificationParams {
|
||||||
|
userId: string;
|
||||||
|
messageId: number;
|
||||||
|
mailboxId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MailboxUpdateNotificationParams {
|
||||||
|
userId: string;
|
||||||
|
mailboxId: string;
|
||||||
|
mailboxName: string;
|
||||||
|
unreadCount: number;
|
||||||
|
totalCount: number;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,134 +1,52 @@
|
|||||||
import { Socket } from "socket.io";
|
import { Socket } from "socket.io";
|
||||||
|
|
||||||
|
import { ITokenPayload } from "../../auth/interfaces/IToken-payload";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Information about a connected user
|
* Extended Socket interface with authenticated user data
|
||||||
|
*/
|
||||||
|
export interface AuthenticatedSocket extends Socket {
|
||||||
|
data: {
|
||||||
|
user: ITokenPayload;
|
||||||
|
sessionId?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User connection information stored in gateway
|
||||||
*/
|
*/
|
||||||
export interface ConnectedUserInfo {
|
export interface ConnectedUserInfo {
|
||||||
userId: string;
|
userId: string;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
connectedAt?: Date;
|
|
||||||
lastActivity?: Date;
|
|
||||||
metadata?: Record<string, any>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* WebSocket authentication result
|
* WebSocket error context for logging and debugging
|
||||||
*/
|
*/
|
||||||
export interface WebSocketAuthResult {
|
export interface WebSocketErrorContext {
|
||||||
success: boolean;
|
|
||||||
user?: WebSocketUser;
|
|
||||||
error?: string;
|
|
||||||
errorCode?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* User information extracted from JWT token
|
|
||||||
*/
|
|
||||||
export interface WebSocketUser {
|
|
||||||
id: string;
|
|
||||||
email?: string;
|
|
||||||
permissions?: string[];
|
|
||||||
isAdmin?: boolean;
|
|
||||||
businessId?: string;
|
|
||||||
sessionId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* WebSocket connection context for logging and debugging
|
|
||||||
*/
|
|
||||||
export interface WebSocketConnectionContext {
|
|
||||||
clientId: string;
|
clientId: string;
|
||||||
clientIP: string;
|
|
||||||
userAgent?: string;
|
|
||||||
timestamp: string;
|
|
||||||
userId?: string;
|
userId?: string;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
|
event?: string;
|
||||||
|
data?: unknown;
|
||||||
|
timestamp: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* WebSocket session information
|
* WebSocket event response structure
|
||||||
*/
|
*/
|
||||||
export interface WebSocketSession {
|
export interface WebSocketResponse<T = unknown> {
|
||||||
id: string;
|
status: "success" | "error";
|
||||||
userId: string;
|
message?: string;
|
||||||
createdAt: Date;
|
data?: T;
|
||||||
lastActivity: Date;
|
timestamp?: string;
|
||||||
clientInfo: {
|
|
||||||
userAgent: string;
|
|
||||||
ip: string;
|
|
||||||
};
|
|
||||||
isActive: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* WebSocket room subscription information
|
* Authentication result for WebSocket connections
|
||||||
*/
|
*/
|
||||||
export interface RoomSubscription {
|
export interface AuthenticationResult {
|
||||||
roomId: string;
|
success: boolean;
|
||||||
userId: string;
|
user?: ITokenPayload;
|
||||||
subscribedAt: Date;
|
error?: string;
|
||||||
permissions?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* WebSocket error information
|
|
||||||
*/
|
|
||||||
export interface WebSocketError {
|
|
||||||
code: string;
|
|
||||||
message: string;
|
|
||||||
timestamp: string;
|
|
||||||
context?: Record<string, any>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* WebSocket authentication payload from client
|
|
||||||
*/
|
|
||||||
export interface WebSocketAuthPayload {
|
|
||||||
token: string;
|
|
||||||
sessionId?: string;
|
|
||||||
clientInfo?: {
|
|
||||||
userAgent?: string;
|
|
||||||
deviceId?: string;
|
|
||||||
platform?: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extended Socket interface with user data
|
|
||||||
*/
|
|
||||||
export interface AuthenticatedSocket extends Socket {
|
|
||||||
data: {
|
|
||||||
user: WebSocketUser;
|
|
||||||
sessionId?: string;
|
|
||||||
connectedAt: Date;
|
|
||||||
lastActivity: Date;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* WebSocket health check information
|
|
||||||
*/
|
|
||||||
export interface WebSocketHealthInfo {
|
|
||||||
totalConnections: number;
|
|
||||||
activeUsers: number;
|
|
||||||
activeSessions: number;
|
|
||||||
uptime: number;
|
|
||||||
memoryUsage?: {
|
|
||||||
used: number;
|
|
||||||
total: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* WebSocket metrics for monitoring
|
|
||||||
*/
|
|
||||||
export interface WebSocketMetrics {
|
|
||||||
connectionsPerSecond: number;
|
|
||||||
messagesPerSecond: number;
|
|
||||||
errorsPerSecond: number;
|
|
||||||
averageResponseTime: number;
|
|
||||||
activeConnections: number;
|
|
||||||
peakConnections: number;
|
|
||||||
totalMessages: number;
|
|
||||||
totalErrors: number;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { Processor, WorkerHost } from "@nestjs/bullmq";
|
||||||
|
import { Logger } from "@nestjs/common";
|
||||||
|
import { Job } from "bullmq";
|
||||||
|
|
||||||
|
import { EMAIL_QUEUE_CONSTANTS } from "../constants/email-events.constant";
|
||||||
|
import { EmailMonitoringJob } from "../interfaces/email-events.interface";
|
||||||
|
import { EmailMonitoringService } from "../services/email-monitoring.service";
|
||||||
|
|
||||||
|
@Processor(EMAIL_QUEUE_CONSTANTS.EMAIL_QUEUE)
|
||||||
|
export class EmailMonitoringProcessor extends WorkerHost {
|
||||||
|
private readonly logger = new Logger(EmailMonitoringProcessor.name);
|
||||||
|
|
||||||
|
constructor(private readonly emailMonitoringService: EmailMonitoringService) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
async process(job: Job<EmailMonitoringJob>) {
|
||||||
|
const { name, data } = job;
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (name) {
|
||||||
|
case EMAIL_QUEUE_CONSTANTS.EMAIL_QUEUE_PROCESSING:
|
||||||
|
await this.emailMonitoringService.processUserEmailCheck(data);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
this.logger.warn(`Unknown job type: ${name}`);
|
||||||
|
throw new Error(`Unknown job type: ${name}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to process email monitoring job ${name} for user ${data.userId}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { Injectable, Logger, OnModuleInit } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { EmailMonitoringService } from "./email-monitoring.service";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class EmailMonitoringSchedulerService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(EmailMonitoringSchedulerService.name);
|
||||||
|
private monitoringInterval: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
constructor(private readonly emailMonitoringService: EmailMonitoringService) {}
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
this.logger.log("Initializing Email Monitoring Scheduler");
|
||||||
|
await this.startMonitoring();
|
||||||
|
}
|
||||||
|
|
||||||
|
//************************************************************************ */
|
||||||
|
async startMonitoring() {
|
||||||
|
if (this.monitoringInterval) {
|
||||||
|
this.logger.warn("Email monitoring is already running");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log("Starting email monitoring scheduler - checking every 5 seconds");
|
||||||
|
|
||||||
|
await this.runMonitoringCheck();
|
||||||
|
|
||||||
|
this.monitoringInterval = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
await this.runMonitoringCheck();
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error("Error in scheduled email monitoring:", error);
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
this.logger.log("Email monitoring scheduler started successfully");
|
||||||
|
}
|
||||||
|
|
||||||
|
//************************************************************************ */
|
||||||
|
stopMonitoring() {
|
||||||
|
if (this.monitoringInterval) {
|
||||||
|
clearInterval(this.monitoringInterval);
|
||||||
|
this.monitoringInterval = null;
|
||||||
|
this.logger.log("Email monitoring scheduler stopped");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//************************************************************************ */
|
||||||
|
private async runMonitoringCheck() {
|
||||||
|
try {
|
||||||
|
this.logger.log("Running scheduled email monitoring check");
|
||||||
|
await this.emailMonitoringService.scheduleEmailCheck();
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error("Failed to run email monitoring check:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//************************************************************************ */
|
||||||
|
getStatus() {
|
||||||
|
return {
|
||||||
|
isRunning: this.monitoringInterval !== null,
|
||||||
|
checkInterval: 5000,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
//************************************************************************ */
|
||||||
|
async restartMonitoring() {
|
||||||
|
this.logger.log("Restarting email monitoring scheduler");
|
||||||
|
this.stopMonitoring();
|
||||||
|
await this.startMonitoring();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import { EntityManager } from "@mikro-orm/core";
|
||||||
|
import { InjectQueue } from "@nestjs/bullmq";
|
||||||
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
|
import { Queue } from "bullmq";
|
||||||
|
import { firstValueFrom } from "rxjs";
|
||||||
|
|
||||||
|
import { MailboxResolverService } from "./mailbox-resolver.service";
|
||||||
|
import { MailServerService } from "../../mail-server/services/mail-server.service";
|
||||||
|
import { MailboxEnum } from "../../mailbox/enums/mailbox.enum";
|
||||||
|
import { User } from "../../users/entities/user.entity";
|
||||||
|
import { EMAIL_QUEUE_CONSTANTS } from "../constants/email-events.constant";
|
||||||
|
import { EmailGateway } from "../gateways/email.gateway";
|
||||||
|
import { EmailMonitoringJob, EmailPayload } from "../interfaces/email-events.interface";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class EmailMonitoringService {
|
||||||
|
private readonly logger = new Logger(EmailMonitoringService.name);
|
||||||
|
private lastGlobalCheck: Date = new Date();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly emailGateway: EmailGateway,
|
||||||
|
private readonly mailboxResolver: MailboxResolverService,
|
||||||
|
private readonly mailServerService: MailServerService,
|
||||||
|
private readonly em: EntityManager,
|
||||||
|
@InjectQueue(EMAIL_QUEUE_CONSTANTS.EMAIL_QUEUE) private readonly monitoringQueue: Queue,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
//****************************************************** */
|
||||||
|
async scheduleEmailCheck() {
|
||||||
|
try {
|
||||||
|
// Get only connected users from the websocket gateway
|
||||||
|
const connectedUserIds = this.emailGateway.getConnectedUserIds();
|
||||||
|
|
||||||
|
if (connectedUserIds.length === 0) {
|
||||||
|
return; // No connected users, skip monitoring
|
||||||
|
}
|
||||||
|
|
||||||
|
const em = this.em.fork();
|
||||||
|
|
||||||
|
const activeUsers = await em.find(User, {
|
||||||
|
id: { $in: connectedUserIds },
|
||||||
|
emailEnabled: true,
|
||||||
|
deletedAt: null,
|
||||||
|
wildduckUserId: { $ne: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`Monitoring ${activeUsers.length} connected users`);
|
||||||
|
|
||||||
|
for (const user of activeUsers) {
|
||||||
|
await this.monitoringQueue.add(
|
||||||
|
EMAIL_QUEUE_CONSTANTS.EMAIL_QUEUE_PROCESSING,
|
||||||
|
{
|
||||||
|
userId: user.id,
|
||||||
|
wildduckUserId: user.wildduckUserId,
|
||||||
|
lastCheckTimestamp: this.lastGlobalCheck,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
delay: Math.random() * 10000,
|
||||||
|
attempts: 3,
|
||||||
|
backoff: {
|
||||||
|
type: "exponential",
|
||||||
|
delay: 2000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lastGlobalCheck = new Date();
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error("Error scheduling email monitoring checks:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//****************************************************** */
|
||||||
|
async processUserEmailCheck(job: EmailMonitoringJob) {
|
||||||
|
const { userId, lastCheckTimestamp } = job;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const em = this.em.fork();
|
||||||
|
|
||||||
|
const user = await em.findOne(User, {
|
||||||
|
id: userId,
|
||||||
|
emailEnabled: true,
|
||||||
|
deletedAt: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user || !user.wildduckUserId) {
|
||||||
|
this.logger.warn(`User ${userId} not found or email not enabled`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inboxMailboxId = await this.mailboxResolver.getInboxMailboxId(user.wildduckUserId);
|
||||||
|
|
||||||
|
const query: Record<string, unknown> = {
|
||||||
|
limit: 100,
|
||||||
|
order: "desc",
|
||||||
|
unseen: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (lastCheckTimestamp) {
|
||||||
|
// Handle case where BullMQ serializes Date to string
|
||||||
|
const timestamp = lastCheckTimestamp instanceof Date ? lastCheckTimestamp : new Date(lastCheckTimestamp);
|
||||||
|
query.datestart = timestamp.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { results } = await firstValueFrom(this.mailServerService.messages.listMessages(user.wildduckUserId, inboxMailboxId, query));
|
||||||
|
|
||||||
|
if (results.length > 0) {
|
||||||
|
this.logger.log(`${user.emailAddress}: ${results.length} new messages`);
|
||||||
|
|
||||||
|
for (const message of results) {
|
||||||
|
await this.processNewEmailMessage(user.id, user.wildduckUserId, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.updateUnreadCount(userId, user.wildduckUserId, user.emailAddress);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Error checking emails for user ${userId}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//****************************************************** */
|
||||||
|
private async processNewEmailMessage(userId: string, _wildduckUserId: string, message: Record<string, any>) {
|
||||||
|
try {
|
||||||
|
if (message.seen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emailPayload: EmailPayload = {
|
||||||
|
messageId: message.id,
|
||||||
|
userId,
|
||||||
|
from: message.from,
|
||||||
|
to: message.to,
|
||||||
|
subject: message.subject,
|
||||||
|
hasAttachments: message.hasAttachments,
|
||||||
|
mailboxName: message.mailboxName,
|
||||||
|
isRead: message.seen,
|
||||||
|
mailboxId: message.mailboxId,
|
||||||
|
timestamp: message.timestamp,
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.emailGateway.notifyNewEmail(userId, emailPayload);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Error processing new email message for user ${userId}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//************************************************************************ */
|
||||||
|
private async updateUnreadCount(userId: string, wildduckUserId: string, userEmailAddress?: string) {
|
||||||
|
try {
|
||||||
|
const inboxMailboxId = await this.mailboxResolver.getInboxMailboxId(wildduckUserId);
|
||||||
|
|
||||||
|
// Get all messages to properly count unread ones
|
||||||
|
const { results } = await firstValueFrom(
|
||||||
|
this.mailServerService.messages.listMessages(wildduckUserId, inboxMailboxId, {
|
||||||
|
limit: 250,
|
||||||
|
unseen: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const unreadCount = results.length;
|
||||||
|
|
||||||
|
// Always send unread count notification
|
||||||
|
this.emailGateway.notifyUnreadCountUpdate(userId, {
|
||||||
|
count: unreadCount,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
totalUnread: unreadCount,
|
||||||
|
mailboxCounts: {
|
||||||
|
[MailboxEnum.INBOX]: unreadCount,
|
||||||
|
},
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log the unread count if there are unread messages
|
||||||
|
if (unreadCount > 0 && userEmailAddress) {
|
||||||
|
this.logger.log(`${userEmailAddress}: ${unreadCount} unread total`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Error updating unread count for user ${userId}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//****************************************************** */
|
||||||
|
async checkUserEmails(userId: string): Promise<void> {
|
||||||
|
await this.monitoringQueue.add(EMAIL_QUEUE_CONSTANTS.EMAIL_QUEUE_PROCESSING, {
|
||||||
|
userId,
|
||||||
|
lastCheckTimestamp: new Date(Date.now() - 60000),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
//****************************************************** */
|
||||||
|
async getQueueStatus() {
|
||||||
|
const waiting = await this.monitoringQueue.getWaiting();
|
||||||
|
const active = await this.monitoringQueue.getActive();
|
||||||
|
const completed = await this.monitoringQueue.getCompleted();
|
||||||
|
const failed = await this.monitoringQueue.getFailed();
|
||||||
|
|
||||||
|
return {
|
||||||
|
waiting: waiting.length,
|
||||||
|
active: active.length,
|
||||||
|
completed: completed.length,
|
||||||
|
failed: failed.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
//****************************************************** */
|
||||||
|
async cleanupJobs() {
|
||||||
|
await this.monitoringQueue.clean(1000 * 60 * 60, 100); // Clean jobs older than 1 hour
|
||||||
|
this.logger.log("Cleaned up old monitoring jobs");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import { EntityManager } from "@mikro-orm/core";
|
||||||
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
|
import { firstValueFrom } from "rxjs";
|
||||||
|
|
||||||
|
import { MailboxResolverService } from "./mailbox-resolver.service";
|
||||||
|
import { MailServerService } from "../../mail-server/services/mail-server.service";
|
||||||
|
import { MailboxEnum } from "../../mailbox/enums/mailbox.enum";
|
||||||
|
import { User } from "../../users/entities/user.entity";
|
||||||
|
import { WebSocketEvent } from "../constants/email-events.constant";
|
||||||
|
import { EmailGateway } from "../gateways/email.gateway";
|
||||||
|
import {
|
||||||
|
EmailDeletePayload,
|
||||||
|
EmailSentNotificationParams,
|
||||||
|
EmailSentPayload,
|
||||||
|
EmailStatusNotificationParams,
|
||||||
|
EmailStatusPayload,
|
||||||
|
MailboxUpdateNotificationParams,
|
||||||
|
MailboxUpdatePayload,
|
||||||
|
UnreadCountPayload,
|
||||||
|
} from "../interfaces/email-events.interface";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class EmailNotificationService {
|
||||||
|
private readonly logger = new Logger(EmailNotificationService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly emailGateway: EmailGateway,
|
||||||
|
private readonly mailboxResolver: MailboxResolverService,
|
||||||
|
private readonly mailServerService: MailServerService,
|
||||||
|
private readonly em: EntityManager,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
//************************************************************************ */
|
||||||
|
async notifyEmailSent(params: EmailSentNotificationParams) {
|
||||||
|
try {
|
||||||
|
const payload: EmailSentPayload = {
|
||||||
|
userId: params.userId,
|
||||||
|
messageId: params.messageId,
|
||||||
|
mailboxId: params.mailboxId,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
from: params.from,
|
||||||
|
to: params.recipients,
|
||||||
|
subject: params.subject,
|
||||||
|
hasAttachments: false,
|
||||||
|
mailboxName: MailboxEnum.SENT,
|
||||||
|
isRead: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const success = await this.emailGateway.notifyEmailSent(params.userId, payload);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
this.logger.log(`Email sent notification dispatched for user ${params.userId}: ${params.messageId}`);
|
||||||
|
} else {
|
||||||
|
this.logger.warn(`Failed to dispatch email sent notification for user ${params.userId}: user not connected`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to notify email sent for user ${params.userId}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//************************************************************************ */
|
||||||
|
async notifyEmailRead(params: EmailStatusNotificationParams) {
|
||||||
|
try {
|
||||||
|
const payload: EmailStatusPayload = {
|
||||||
|
userId: params.userId,
|
||||||
|
messageId: params.messageId,
|
||||||
|
mailboxId: params.mailboxId,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const success = await this.emailGateway.notifyEmailRead(params.userId, payload);
|
||||||
|
await this.updateUnreadCount(params.userId);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
this.logger.log(`Email read notification dispatched for user ${params.userId}: message ${params.messageId}`);
|
||||||
|
} else {
|
||||||
|
this.logger.warn(`Failed to dispatch email read notification for user ${params.userId}: user not connected`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to notify email read for user ${params.userId}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//************************************************************************ */
|
||||||
|
async notifyEmailDeleted(params: EmailStatusNotificationParams) {
|
||||||
|
try {
|
||||||
|
const payload: EmailDeletePayload = {
|
||||||
|
userId: params.userId,
|
||||||
|
messageId: params.messageId,
|
||||||
|
mailboxId: params.mailboxId,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const success = await this.emailGateway.notifyEmailDeleted(params.userId, payload);
|
||||||
|
await this.updateUnreadCount(params.userId);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
this.logger.log(`Email deleted notification dispatched for user ${params.userId}: message ${params.messageId}`);
|
||||||
|
} else {
|
||||||
|
this.logger.warn(`Failed to dispatch email deleted notification for user ${params.userId}: user not connected`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to notify email deleted for user ${params.userId}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//************************************************************************ */
|
||||||
|
async notifyMailboxUpdate(params: MailboxUpdateNotificationParams) {
|
||||||
|
try {
|
||||||
|
const payload: MailboxUpdatePayload = {
|
||||||
|
userId: params.userId,
|
||||||
|
mailboxId: params.mailboxId,
|
||||||
|
mailboxName: params.mailboxName,
|
||||||
|
unreadCount: params.unreadCount,
|
||||||
|
totalCount: params.totalCount,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const success = await this.emailGateway.notifyMailboxUpdate(params.userId, payload);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
this.logger.log(`Mailbox update notification dispatched for user ${params.userId}: ${params.mailboxName}`);
|
||||||
|
} else {
|
||||||
|
this.logger.warn(`Failed to dispatch mailbox update notification for user ${params.userId}: user not connected`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to notify mailbox update for user ${params.userId}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//************************************************************************ */
|
||||||
|
private async updateUnreadCount(userId: string) {
|
||||||
|
try {
|
||||||
|
const em = this.em.fork();
|
||||||
|
|
||||||
|
const user = await em.findOne(User, {
|
||||||
|
id: userId,
|
||||||
|
emailEnabled: true,
|
||||||
|
deletedAt: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user || !user.wildduckUserId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inboxMailboxId = await this.mailboxResolver.getInboxMailboxId(user.wildduckUserId);
|
||||||
|
|
||||||
|
const { results } = await firstValueFrom(
|
||||||
|
this.mailServerService.messages.listMessages(user.wildduckUserId, inboxMailboxId, {
|
||||||
|
limit: 50,
|
||||||
|
unseen: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const unreadCount = results.length;
|
||||||
|
|
||||||
|
const payload: UnreadCountPayload = {
|
||||||
|
userId,
|
||||||
|
count: unreadCount,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
totalUnread: unreadCount,
|
||||||
|
mailboxCounts: {
|
||||||
|
[inboxMailboxId]: unreadCount,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const success = await this.emailGateway.notifyUnreadCountUpdate(userId, payload);
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
this.logger.warn(`Failed to update unread count for user ${userId}: user not connected`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to update unread count for user ${userId}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//************************************************************************ */
|
||||||
|
async broadcastSystemNotification(event: WebSocketEvent, data: Record<string, unknown>) {
|
||||||
|
try {
|
||||||
|
this.emailGateway.broadcastSystemMessage(event, {
|
||||||
|
...data,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`System notification broadcasted: ${event}`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to broadcast system notification:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//************************************************************************ */
|
||||||
|
async getGatewayStats() {
|
||||||
|
try {
|
||||||
|
const connectedUsers = await this.emailGateway.getConnectedUsersCount();
|
||||||
|
|
||||||
|
return {
|
||||||
|
connectedUsers,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error("Failed to get gateway statistics:", error);
|
||||||
|
return {
|
||||||
|
connectedUsers: 0,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { BadRequestException, Injectable, Logger } from "@nestjs/common";
|
|||||||
import { firstValueFrom } from "rxjs";
|
import { firstValueFrom } from "rxjs";
|
||||||
|
|
||||||
import { EmailHeadersService } from "./email-headers.service";
|
import { EmailHeadersService } from "./email-headers.service";
|
||||||
|
import { EmailNotificationService } from "./email-notification.service";
|
||||||
import { MailboxResolverService } from "./mailbox-resolver.service";
|
import { MailboxResolverService } from "./mailbox-resolver.service";
|
||||||
import { EmailMessage } from "../../../common/enums/message.enum";
|
import { EmailMessage } from "../../../common/enums/message.enum";
|
||||||
import { MailServerService } from "../../mail-server/services/mail-server.service";
|
import { MailServerService } from "../../mail-server/services/mail-server.service";
|
||||||
@@ -21,6 +22,7 @@ export class EmailService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly mailServerService: MailServerService,
|
private readonly mailServerService: MailServerService,
|
||||||
private readonly mailboxResolverService: MailboxResolverService,
|
private readonly mailboxResolverService: MailboxResolverService,
|
||||||
|
private readonly emailNotificationService: EmailNotificationService,
|
||||||
private readonly templateProcessorService: TemplateProcessorService,
|
private readonly templateProcessorService: TemplateProcessorService,
|
||||||
private readonly userRepository: UserRepository,
|
private readonly userRepository: UserRepository,
|
||||||
private readonly emailHeadersService: EmailHeadersService,
|
private readonly emailHeadersService: EmailHeadersService,
|
||||||
@@ -66,6 +68,17 @@ export class EmailService {
|
|||||||
|
|
||||||
this.logger.log(`Email sent successfully: ${response.message.id} (Queue: ${response.message.queueId})`);
|
this.logger.log(`Email sent successfully: ${response.message.id} (Queue: ${response.message.queueId})`);
|
||||||
|
|
||||||
|
const sentMailboxId = await this.mailboxResolverService.getSentMailboxId(userId);
|
||||||
|
|
||||||
|
await this.emailNotificationService.notifyEmailSent({
|
||||||
|
userId,
|
||||||
|
messageId: parseInt(response.message.id),
|
||||||
|
mailboxId: sentMailboxId,
|
||||||
|
recipients: sendEmailDto.to,
|
||||||
|
subject: sendEmailDto.subject,
|
||||||
|
from: sendEmailDto.from,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
message: EmailMessage.EMAIL_SENDING_SUCCESS,
|
message: EmailMessage.EMAIL_SENDING_SUCCESS,
|
||||||
messageId: response.message.id,
|
messageId: response.message.id,
|
||||||
@@ -101,15 +114,35 @@ export class EmailService {
|
|||||||
this.logger.log(`Searching messages for user ${userId} with query: ${query.q}`);
|
this.logger.log(`Searching messages for user ${userId} with query: ${query.q}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await firstValueFrom(
|
// Transform query parameters for WildDuck API compatibility
|
||||||
this.mailServerService.messages.searchMessages(userId, { query: query.q || "", limit: query.limit, page: query.page }),
|
const wildDuckQuery = {
|
||||||
);
|
query: query.q || "",
|
||||||
|
limit: query.limit || 20,
|
||||||
|
page: query.page || 1,
|
||||||
|
order: "desc" as const,
|
||||||
|
};
|
||||||
|
|
||||||
this.logger.log(`Found ${response.results.length} messages matching search for user: ${userId}`);
|
// Ensure limit is within bounds
|
||||||
|
if (wildDuckQuery.limit > 250) wildDuckQuery.limit = 250;
|
||||||
|
if (wildDuckQuery.limit < 1) wildDuckQuery.limit = 1;
|
||||||
|
if (wildDuckQuery.page < 1) wildDuckQuery.page = 1;
|
||||||
|
|
||||||
|
this.logger.debug("Search query for WildDuck:", wildDuckQuery);
|
||||||
|
|
||||||
|
const response = await firstValueFrom(this.mailServerService.messages.searchMessages(userId, wildDuckQuery));
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Found ${response.results.length} messages (${response.total} total, page ${response.page}) matching search for user: ${userId}`,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
message: EmailMessage.MESSAGES_SEARCHED_SUCCESSFULLY,
|
message: EmailMessage.MESSAGES_SEARCHED_SUCCESSFULLY,
|
||||||
...response,
|
results: response.results,
|
||||||
|
total: response.total,
|
||||||
|
page: response.page,
|
||||||
|
previousCursor: response.previousCursor,
|
||||||
|
nextCursor: response.nextCursor,
|
||||||
|
query: response.query,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`Failed to search messages for user ${userId}: ${error instanceof Error ? error.message : "Unknown error"}`);
|
this.logger.error(`Failed to search messages for user ${userId}: ${error instanceof Error ? error.message : "Unknown error"}`);
|
||||||
@@ -123,14 +156,21 @@ export class EmailService {
|
|||||||
const inboxMailboxId = await this.mailboxResolverService.getInboxMailboxId(userId);
|
const inboxMailboxId = await this.mailboxResolverService.getInboxMailboxId(userId);
|
||||||
this.logger.log(`Listing messages in mailbox ${inboxMailboxId} for user: ${userId}`);
|
this.logger.log(`Listing messages in mailbox ${inboxMailboxId} for user: ${userId}`);
|
||||||
|
|
||||||
const { results, total: count } = await firstValueFrom(this.mailServerService.messages.listMessages(userId, inboxMailboxId, query));
|
// Transform query parameters for WildDuck API compatibility
|
||||||
|
const wildDuckQuery = this.transformPaginationQuery(query);
|
||||||
|
this.logger.debug("Transformed query for WildDuck:", wildDuckQuery);
|
||||||
|
|
||||||
this.logger.log(`Found ${count} messages in mailbox ${inboxMailboxId} for user: ${userId}`);
|
const response = await firstValueFrom(this.mailServerService.messages.listMessages(userId, inboxMailboxId, wildDuckQuery));
|
||||||
|
|
||||||
|
this.logger.log(`Found ${response.total} total messages (page ${response.page}) in mailbox ${inboxMailboxId} for user: ${userId}`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
message: EmailMessage.MESSAGES_LISTED_SUCCESSFULLY,
|
message: EmailMessage.MESSAGES_LISTED_SUCCESSFULLY,
|
||||||
results,
|
results: response.results,
|
||||||
count,
|
count: response.total,
|
||||||
|
page: response.page,
|
||||||
|
previousCursor: response.previousCursor,
|
||||||
|
nextCursor: response.nextCursor,
|
||||||
paginate: true,
|
paginate: true,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -201,6 +241,12 @@ export class EmailService {
|
|||||||
|
|
||||||
this.logger.log(`Message ${messageId} marked as seen successfully in ${messageLocation.mailboxName}`);
|
this.logger.log(`Message ${messageId} marked as seen successfully in ${messageLocation.mailboxName}`);
|
||||||
|
|
||||||
|
await this.emailNotificationService.notifyEmailRead({
|
||||||
|
userId,
|
||||||
|
messageId,
|
||||||
|
mailboxId: messageLocation.mailboxId,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: EmailMessage.MESSAGE_MARKED_AS_SEEN_SUCCESSFULLY,
|
message: EmailMessage.MESSAGE_MARKED_AS_SEEN_SUCCESSFULLY,
|
||||||
@@ -218,11 +264,17 @@ export class EmailService {
|
|||||||
async getSentMessages(userId: string, query: MessageListQueryDto) {
|
async getSentMessages(userId: string, query: MessageListQueryDto) {
|
||||||
try {
|
try {
|
||||||
const sentMailboxId = await this.mailboxResolverService.getSentMailboxId(userId);
|
const sentMailboxId = await this.mailboxResolverService.getSentMailboxId(userId);
|
||||||
const { results: sentMails, total: count } = await firstValueFrom(this.mailServerService.messages.listMessages(userId, sentMailboxId, query));
|
const wildDuckQuery = this.transformPaginationQuery(query);
|
||||||
|
const response = await firstValueFrom(this.mailServerService.messages.listMessages(userId, sentMailboxId, wildDuckQuery));
|
||||||
|
|
||||||
|
this.logger.log(`Found ${response.total} sent messages (page ${response.page}) for user: ${userId}`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sentMails,
|
sentMails: response.results,
|
||||||
count,
|
count: response.total,
|
||||||
|
page: response.page,
|
||||||
|
previousCursor: response.previousCursor,
|
||||||
|
nextCursor: response.nextCursor,
|
||||||
paginate: true,
|
paginate: true,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -235,10 +287,17 @@ export class EmailService {
|
|||||||
async getTrashMessages(userId: string, query: MessageListQueryDto) {
|
async getTrashMessages(userId: string, query: MessageListQueryDto) {
|
||||||
try {
|
try {
|
||||||
const trashMailboxId = await this.mailboxResolverService.getTrashMailboxId(userId);
|
const trashMailboxId = await this.mailboxResolverService.getTrashMailboxId(userId);
|
||||||
const { results: trashMails, total: count } = await firstValueFrom(this.mailServerService.messages.listMessages(userId, trashMailboxId, query));
|
const wildDuckQuery = this.transformPaginationQuery(query);
|
||||||
|
const response = await firstValueFrom(this.mailServerService.messages.listMessages(userId, trashMailboxId, wildDuckQuery));
|
||||||
|
|
||||||
|
this.logger.log(`Found ${response.total} trash messages (page ${response.page}) for user: ${userId}`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
trashMails,
|
trashMails: response.results,
|
||||||
count,
|
count: response.total,
|
||||||
|
page: response.page,
|
||||||
|
previousCursor: response.previousCursor,
|
||||||
|
nextCursor: response.nextCursor,
|
||||||
paginate: true,
|
paginate: true,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -251,11 +310,17 @@ export class EmailService {
|
|||||||
async getJunkMessages(userId: string, query: MessageListQueryDto) {
|
async getJunkMessages(userId: string, query: MessageListQueryDto) {
|
||||||
try {
|
try {
|
||||||
const junkMailboxId = await this.mailboxResolverService.getJunkMailboxId(userId);
|
const junkMailboxId = await this.mailboxResolverService.getJunkMailboxId(userId);
|
||||||
const { results: junkMails, total: count } = await firstValueFrom(this.mailServerService.messages.listMessages(userId, junkMailboxId, query));
|
const wildDuckQuery = this.transformPaginationQuery(query);
|
||||||
|
const response = await firstValueFrom(this.mailServerService.messages.listMessages(userId, junkMailboxId, wildDuckQuery));
|
||||||
|
|
||||||
|
this.logger.log(`Found ${response.total} junk messages (page ${response.page}) for user: ${userId}`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
junkMails,
|
junkMails: response.results,
|
||||||
count,
|
count: response.total,
|
||||||
|
page: response.page,
|
||||||
|
previousCursor: response.previousCursor,
|
||||||
|
nextCursor: response.nextCursor,
|
||||||
paginate: true,
|
paginate: true,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -268,13 +333,17 @@ export class EmailService {
|
|||||||
async getArchivedMessages(userId: string, query: MessageListQueryDto) {
|
async getArchivedMessages(userId: string, query: MessageListQueryDto) {
|
||||||
try {
|
try {
|
||||||
const archiveMailboxId = await this.mailboxResolverService.getArchiveMailboxId(userId);
|
const archiveMailboxId = await this.mailboxResolverService.getArchiveMailboxId(userId);
|
||||||
const { results: archiveMails, total: count } = await firstValueFrom(
|
const wildDuckQuery = this.transformPaginationQuery(query);
|
||||||
this.mailServerService.messages.listMessages(userId, archiveMailboxId, query),
|
const response = await firstValueFrom(this.mailServerService.messages.listMessages(userId, archiveMailboxId, wildDuckQuery));
|
||||||
);
|
|
||||||
|
this.logger.log(`Found ${response.total} archived messages (page ${response.page}) for user: ${userId}`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
archiveMails,
|
archiveMails: response.results,
|
||||||
count,
|
count: response.total,
|
||||||
|
page: response.page,
|
||||||
|
previousCursor: response.previousCursor,
|
||||||
|
nextCursor: response.nextCursor,
|
||||||
paginate: true,
|
paginate: true,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -287,17 +356,21 @@ export class EmailService {
|
|||||||
async getFavoriteMessages(userId: string, query: MessageListQueryDto) {
|
async getFavoriteMessages(userId: string, query: MessageListQueryDto) {
|
||||||
try {
|
try {
|
||||||
const favoriteMailboxId = await this.mailboxResolverService.getFavoriteMailboxId(userId);
|
const favoriteMailboxId = await this.mailboxResolverService.getFavoriteMailboxId(userId);
|
||||||
const { results: favoriteMails, total: count } = await firstValueFrom(
|
const wildDuckQuery = this.transformPaginationQuery(query);
|
||||||
this.mailServerService.messages.listMessages(userId, favoriteMailboxId, query),
|
const response = await firstValueFrom(this.mailServerService.messages.listMessages(userId, favoriteMailboxId, wildDuckQuery));
|
||||||
);
|
|
||||||
|
this.logger.log(`Found ${response.total} favorite messages (page ${response.page}) for user: ${userId}`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
favoriteMails,
|
favoriteMails: response.results,
|
||||||
count,
|
count: response.total,
|
||||||
|
page: response.page,
|
||||||
|
previousCursor: response.previousCursor,
|
||||||
|
nextCursor: response.nextCursor,
|
||||||
paginate: true,
|
paginate: true,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`Failed to get junk messages for user ${userId}: ${error instanceof Error ? error.message : "Unknown error"}`);
|
this.logger.error(`Failed to get favorite messages for user ${userId}: ${error instanceof Error ? error.message : "Unknown error"}`);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -307,13 +380,17 @@ export class EmailService {
|
|||||||
async getDraftsMessages(userId: string, query: MessageListQueryDto) {
|
async getDraftsMessages(userId: string, query: MessageListQueryDto) {
|
||||||
try {
|
try {
|
||||||
const draftsMailboxId = await this.mailboxResolverService.getDraftsMailboxId(userId);
|
const draftsMailboxId = await this.mailboxResolverService.getDraftsMailboxId(userId);
|
||||||
|
const wildDuckQuery = this.transformPaginationQuery(query);
|
||||||
|
const response = await firstValueFrom(this.mailServerService.messages.listMessages(userId, draftsMailboxId, wildDuckQuery));
|
||||||
|
|
||||||
|
this.logger.log(`Found ${response.total} draft messages (page ${response.page}) for user: ${userId}`);
|
||||||
|
|
||||||
const { results: draftsMail, total: count } = await firstValueFrom(
|
|
||||||
this.mailServerService.messages.listMessages(userId, draftsMailboxId, query),
|
|
||||||
);
|
|
||||||
return {
|
return {
|
||||||
draftsMail,
|
draftsMail: response.results,
|
||||||
count,
|
count: response.total,
|
||||||
|
page: response.page,
|
||||||
|
previousCursor: response.previousCursor,
|
||||||
|
nextCursor: response.nextCursor,
|
||||||
paginate: true,
|
paginate: true,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -743,6 +820,38 @@ export class EmailService {
|
|||||||
|
|
||||||
//########################################################
|
//########################################################
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transform pagination query for WildDuck API compatibility
|
||||||
|
*/
|
||||||
|
private transformPaginationQuery(query: MessageListQueryDto): Record<string, unknown> {
|
||||||
|
const transformedQuery: Record<string, any> = { ...query };
|
||||||
|
|
||||||
|
// Set default values if not provided
|
||||||
|
if (!transformedQuery.page) transformedQuery.page = 1;
|
||||||
|
if (!transformedQuery.limit) transformedQuery.limit = 20;
|
||||||
|
|
||||||
|
// Ensure limit is within WildDuck's bounds (1-250)
|
||||||
|
if (transformedQuery.limit > 250) transformedQuery.limit = 250;
|
||||||
|
if (transformedQuery.limit < 1) transformedQuery.limit = 1;
|
||||||
|
|
||||||
|
// Ensure page is at least 1
|
||||||
|
if (transformedQuery.page < 1) transformedQuery.page = 1;
|
||||||
|
|
||||||
|
// Set default order if not provided
|
||||||
|
if (!transformedQuery.order) transformedQuery.order = "desc";
|
||||||
|
|
||||||
|
// Remove undefined/null values
|
||||||
|
Object.keys(transformedQuery).forEach((key) => {
|
||||||
|
if (transformedQuery[key] === undefined || transformedQuery[key] === null) {
|
||||||
|
delete transformedQuery[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return transformedQuery;
|
||||||
|
}
|
||||||
|
|
||||||
|
//########################################################
|
||||||
|
|
||||||
private async findMessageMailbox(userId: string, messageId: number): Promise<{ mailboxId: string; mailboxName: string } | null> {
|
private async findMessageMailbox(userId: string, messageId: number): Promise<{ mailboxId: string; mailboxName: string } | null> {
|
||||||
try {
|
try {
|
||||||
const mailboxIds = await this.mailboxResolverService.getUserMailboxIds(userId);
|
const mailboxIds = await this.mailboxResolverService.getUserMailboxIds(userId);
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
|
import { JsonWebTokenError, JwtService, NotBeforeError, TokenExpiredError } from "@nestjs/jwt";
|
||||||
|
import { Socket } from "socket.io";
|
||||||
|
|
||||||
|
import { WebSocketMessage } from "../../../common/enums/message.enum";
|
||||||
|
import { ITokenPayload } from "../../auth/interfaces/IToken-payload";
|
||||||
|
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);
|
||||||
|
|
||||||
|
constructor(private readonly jwtService: JwtService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticates a WebSocket client and returns the result
|
||||||
|
*
|
||||||
|
* @param client - Socket.IO client to authenticate
|
||||||
|
* @returns Promise with authentication result
|
||||||
|
*/
|
||||||
|
async authenticateClient(client: Socket): Promise<AuthenticationResult> {
|
||||||
|
try {
|
||||||
|
const token = extractTokenFromClient(client);
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
this.logAuthenticationAttempt(client, false, "No token provided");
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: WebSocketMessage.AUTHENTICATION_REQUIRED,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await this.jwtService.verifyAsync<ITokenPayload>(token);
|
||||||
|
|
||||||
|
if (!payload?.id) {
|
||||||
|
this.logAuthenticationAttempt(client, false, "Invalid token payload");
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: WebSocketMessage.INVALID_TOKEN,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store user data in client
|
||||||
|
client.data.user = payload;
|
||||||
|
|
||||||
|
this.logAuthenticationAttempt(client, true, undefined, payload.id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
user: payload,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = this.getAuthErrorMessage(error);
|
||||||
|
this.logAuthenticationAttempt(client, false, errorMessage);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: errorMessage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emits authentication failure event to client and disconnects
|
||||||
|
*
|
||||||
|
* @param client - Socket.IO client
|
||||||
|
* @param error - Error message to send
|
||||||
|
*/
|
||||||
|
handleAuthenticationFailure(client: Socket, error: string): void {
|
||||||
|
client.emit(WEBSOCKET_EVENTS.CONNECTION_ERROR, {
|
||||||
|
message: error,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Graceful disconnect with delay to ensure message is received
|
||||||
|
setTimeout(() => {
|
||||||
|
client.disconnect(true);
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emits successful authentication event to client
|
||||||
|
*
|
||||||
|
* @param client - Socket.IO client
|
||||||
|
* @param user - Authenticated user payload
|
||||||
|
*/
|
||||||
|
emitAuthenticationSuccess(client: Socket, user: ITokenPayload): void {
|
||||||
|
client.emit(WEBSOCKET_EVENTS.CONNECTION_ESTABLISHED, {
|
||||||
|
message: WebSocketMessage.AUTHENTICATED,
|
||||||
|
userId: user.id,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates error context object for logging and monitoring
|
||||||
|
*
|
||||||
|
* @param client - Socket.IO client
|
||||||
|
* @param event - Event name (optional)
|
||||||
|
* @param data - Event data (optional)
|
||||||
|
* @returns WebSocket error context
|
||||||
|
*/
|
||||||
|
createErrorContext(client: Socket, event?: WebSocketEvent, data?: unknown): WebSocketErrorContext {
|
||||||
|
return {
|
||||||
|
clientId: client.id,
|
||||||
|
userId: client.data?.user?.id,
|
||||||
|
sessionId: client.data?.sessionId,
|
||||||
|
event,
|
||||||
|
data,
|
||||||
|
timestamp: new Date(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates if client is authenticated
|
||||||
|
*
|
||||||
|
* @param client - Socket.IO client to validate
|
||||||
|
* @returns True if client has valid user data
|
||||||
|
*/
|
||||||
|
isClientAuthenticated(client: Socket): boolean {
|
||||||
|
return !!client.data?.user?.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets user ID from authenticated client
|
||||||
|
*
|
||||||
|
* @param client - Socket.IO client
|
||||||
|
* @returns User ID or undefined if not authenticated
|
||||||
|
*/
|
||||||
|
getUserId(client: Socket): string | undefined {
|
||||||
|
return client.data?.user?.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logs authentication attempts with structured data
|
||||||
|
*/
|
||||||
|
private logAuthenticationAttempt(client: Socket, success: boolean, error?: string, userId?: string): void {
|
||||||
|
const logData = {
|
||||||
|
clientId: client.id,
|
||||||
|
clientIP: client.handshake.address,
|
||||||
|
success,
|
||||||
|
userId,
|
||||||
|
error,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
this.logger.log(`WebSocket authentication successful`, logData);
|
||||||
|
} else {
|
||||||
|
this.logger.warn(`WebSocket authentication failed`, logData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps JWT errors to user-friendly messages
|
||||||
|
*/
|
||||||
|
private getAuthErrorMessage(error: unknown): string {
|
||||||
|
if (error instanceof TokenExpiredError) {
|
||||||
|
return WebSocketMessage.TOKEN_EXPIRED;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof JsonWebTokenError) {
|
||||||
|
return WebSocketMessage.INVALID_TOKEN;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof NotBeforeError) {
|
||||||
|
return WebSocketMessage.INVALID_TOKEN;
|
||||||
|
}
|
||||||
|
|
||||||
|
return WebSocketMessage.INVALID_TOKEN;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,6 +32,11 @@ export class ListMessagesQueryDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
previous?: string;
|
previous?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: "Unseen messages only" })
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
unseen?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UpdateMessageDto {
|
export class UpdateMessageDto {
|
||||||
|
|||||||
@@ -1,199 +0,0 @@
|
|||||||
import { Injectable, Logger } from "@nestjs/common";
|
|
||||||
import { firstValueFrom } from "rxjs";
|
|
||||||
|
|
||||||
import { MailServerService } from "./services/mail-server.service";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Example service showing how to use the Mail Server Gateway
|
|
||||||
* in other modules of your NestJS application
|
|
||||||
*/
|
|
||||||
@Injectable()
|
|
||||||
export class MailServerGatewayExample {
|
|
||||||
private readonly logger = new Logger(MailServerGatewayExample.name);
|
|
||||||
|
|
||||||
constructor(private readonly mailServerService: MailServerService) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Example: Create a complete email account
|
|
||||||
*/
|
|
||||||
async createUserAccount(username: string, password: string, email?: string) {
|
|
||||||
try {
|
|
||||||
const result = await firstValueFrom(
|
|
||||||
this.mailServerService.createEmailAccount({
|
|
||||||
username,
|
|
||||||
password,
|
|
||||||
email,
|
|
||||||
name: username,
|
|
||||||
quota: 1024 * 1024 * 1024, // 1GB
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
this.logger.log(`Created user account: ${result.user.id}`);
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to create user account: ${(error as Error).message}`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Example: Send an email through the gateway
|
|
||||||
*/
|
|
||||||
async sendWelcomeEmail(userId: string, recipientEmail: string, recipientName: string) {
|
|
||||||
try {
|
|
||||||
const result = await firstValueFrom(
|
|
||||||
this.mailServerService.sendMessage(userId, {
|
|
||||||
from: {
|
|
||||||
name: "Support Team",
|
|
||||||
address: "support@yourcompany.com",
|
|
||||||
},
|
|
||||||
to: [
|
|
||||||
{
|
|
||||||
name: recipientName,
|
|
||||||
address: recipientEmail,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
subject: "Welcome to Our Service!",
|
|
||||||
text: `Hello ${recipientName},\n\nWelcome to our email service!`,
|
|
||||||
html: `<h1>Hello ${recipientName}!</h1><p>Welcome to our email service!</p>`,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
this.logger.log(`Welcome email sent: ${result.id}`);
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to send welcome email: ${error instanceof Error ? error.message : String(error)}`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Example: Setup 2FA for a user
|
|
||||||
*/
|
|
||||||
async enable2FAForUser(userId: string) {
|
|
||||||
try {
|
|
||||||
const result = await firstValueFrom(this.mailServerService.setup2FAForUser(userId, "Your Company"));
|
|
||||||
|
|
||||||
this.logger.log(`2FA setup completed for user: ${userId}`);
|
|
||||||
return {
|
|
||||||
qrCode: result.qrcode,
|
|
||||||
backupCodes: result.backupCodes,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to setup 2FA: ${error instanceof Error ? error.message : String(error)}`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Example: Get user's recent emails
|
|
||||||
*/
|
|
||||||
async getUserRecentEmails(userId: string, mailboxId: string) {
|
|
||||||
try {
|
|
||||||
const messages = await firstValueFrom(this.mailServerService.getRecentMessages(userId, mailboxId, 10));
|
|
||||||
|
|
||||||
this.logger.log(`Retrieved ${messages.length} recent messages for user: ${userId}`);
|
|
||||||
return messages;
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to get recent emails: ${error instanceof Error ? error.message : String(error)}`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Example: Search user's emails
|
|
||||||
*/
|
|
||||||
async searchUserEmails(userId: string, searchQuery: string) {
|
|
||||||
try {
|
|
||||||
const results = await firstValueFrom(this.mailServerService.searchAllMessages(userId, searchQuery, 50));
|
|
||||||
|
|
||||||
this.logger.log(`Found ${results.length} matching messages for query: ${searchQuery}`);
|
|
||||||
return results;
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to search emails: ${error instanceof Error ? error.message : String(error)}`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Example: Get system health status
|
|
||||||
*/
|
|
||||||
async getSystemHealth() {
|
|
||||||
try {
|
|
||||||
const status = await firstValueFrom(this.mailServerService.getSystemStatus());
|
|
||||||
|
|
||||||
this.logger.log("Retrieved system status");
|
|
||||||
return status;
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to get system status: ${error instanceof Error ? error.message : String(error)}`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Example: Direct access to specific services
|
|
||||||
*/
|
|
||||||
async directServiceAccess() {
|
|
||||||
try {
|
|
||||||
// Direct access to users service
|
|
||||||
const users = await firstValueFrom(this.mailServerService.users.listUsers({ limit: 10 }));
|
|
||||||
|
|
||||||
// Direct access to health service
|
|
||||||
const health = await firstValueFrom(this.mailServerService.health.getHealthStatus());
|
|
||||||
|
|
||||||
// Direct access to storage service
|
|
||||||
const storageInfo = await firstValueFrom(this.mailServerService.storage.getStorageInfo());
|
|
||||||
|
|
||||||
// Direct access to DKIM service
|
|
||||||
const dkimKeys = await firstValueFrom(this.mailServerService.dkim.listDKIMKeys());
|
|
||||||
|
|
||||||
return {
|
|
||||||
users: users.results,
|
|
||||||
health,
|
|
||||||
storage: storageInfo,
|
|
||||||
dkimKeys: dkimKeys.results,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to access services directly: ${error instanceof Error ? error.message : String(error)}`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Example: Setup DKIM for a domain
|
|
||||||
*/
|
|
||||||
async setupDKIMForDomain(domain: string) {
|
|
||||||
try {
|
|
||||||
const result = await firstValueFrom(this.mailServerService.createDKIMForDomain(domain));
|
|
||||||
|
|
||||||
this.logger.log(`DKIM key created for domain: ${domain}`);
|
|
||||||
this.logger.log(`Add this DNS TXT record: ${result.dnsTxt.name} = ${result.dnsTxt.value}`);
|
|
||||||
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to setup DKIM: ${error instanceof Error ? error.message : String(error)}`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Example: Create webhook for message events
|
|
||||||
*/
|
|
||||||
async setupMessageWebhook(webhookUrl: string, userId?: string) {
|
|
||||||
try {
|
|
||||||
const result = await firstValueFrom(
|
|
||||||
this.mailServerService.webhooks.createWebhook({
|
|
||||||
type: ["messageNew", "messageDeleted"],
|
|
||||||
url: webhookUrl,
|
|
||||||
user: userId,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
this.logger.log(`Webhook created: ${result.id}`);
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to create webhook: ${error instanceof Error ? error.message : String(error)}`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Injectable, Logger } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
import { Observable, catchError, map, switchMap, throwError } from "rxjs";
|
|
||||||
|
|
||||||
import { WildDuckAddressesService } from "./wildduck-addresses.service";
|
import { WildDuckAddressesService } from "./wildduck-addresses.service";
|
||||||
import { WildDuckApplicationPasswordsService } from "./wildduck-application-passwords.service";
|
import { WildDuckApplicationPasswordsService } from "./wildduck-application-passwords.service";
|
||||||
@@ -24,8 +23,6 @@ import { DANAK_IMAPS_SERVER } from "../../../common/constants";
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MailServerService {
|
export class MailServerService {
|
||||||
private readonly logger = new Logger(MailServerService.name);
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly usersService: WildDuckUsersService,
|
private readonly usersService: WildDuckUsersService,
|
||||||
private readonly messagesService: WildDuckMessagesService,
|
private readonly messagesService: WildDuckMessagesService,
|
||||||
@@ -136,229 +133,4 @@ export class MailServerService {
|
|||||||
get twoFactor() {
|
get twoFactor() {
|
||||||
return this.twoFactorService;
|
return this.twoFactorService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* High-level method to create a complete email account
|
|
||||||
*/
|
|
||||||
createEmailAccount(data: { username: string; password: string; email?: string; name?: string; quota?: number }): Observable<{
|
|
||||||
user: { id: string };
|
|
||||||
address?: { id: string };
|
|
||||||
}> {
|
|
||||||
this.logger.log(`Creating email account for user: ${data.username}`);
|
|
||||||
|
|
||||||
return this.usersService
|
|
||||||
.createUser({
|
|
||||||
username: data.username,
|
|
||||||
password: data.password,
|
|
||||||
address: data.email,
|
|
||||||
name: data.name,
|
|
||||||
quota: data.quota,
|
|
||||||
})
|
|
||||||
.pipe(
|
|
||||||
map((userResult) => {
|
|
||||||
this.logger.log(`User created with ID: ${userResult.id}`);
|
|
||||||
return { user: { id: userResult.id } };
|
|
||||||
}),
|
|
||||||
catchError((error) => {
|
|
||||||
this.logger.error(`Failed to create email account: ${error.message}`);
|
|
||||||
return throwError(() => error);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* High-level method to get user's full mailbox structure with message counts
|
|
||||||
*/
|
|
||||||
getUserMailboxes(userId: string): Observable<any> {
|
|
||||||
this.logger.log(`Getting mailboxes for user: ${userId}`);
|
|
||||||
|
|
||||||
return this.mailboxesService.listMailboxes(userId, { counters: true }).pipe(
|
|
||||||
map((result) => {
|
|
||||||
this.logger.log(`Found ${result.results.length} mailboxes for user: ${userId}`);
|
|
||||||
return result.results;
|
|
||||||
}),
|
|
||||||
catchError((error) => {
|
|
||||||
this.logger.error(`Failed to get mailboxes: ${error.message}`);
|
|
||||||
return throwError(() => error);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* High-level method to authenticate user and get account info
|
|
||||||
*/
|
|
||||||
authenticateAndGetUser(
|
|
||||||
username: string,
|
|
||||||
password: string,
|
|
||||||
): Observable<{
|
|
||||||
auth: any;
|
|
||||||
user: any;
|
|
||||||
}> {
|
|
||||||
this.logger.log(`Authenticating user: ${username}`);
|
|
||||||
|
|
||||||
return this.authService.authenticate({ username, password }).pipe(
|
|
||||||
switchMap((authResult) => {
|
|
||||||
if (!authResult.success) {
|
|
||||||
throw new Error("Authentication failed");
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log(`User authenticated: ${authResult.id}`);
|
|
||||||
|
|
||||||
// Get user details after successful authentication
|
|
||||||
return this.usersService.getUser(authResult.id).pipe(
|
|
||||||
map((userDetails) => ({
|
|
||||||
auth: authResult,
|
|
||||||
user: userDetails,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
catchError((error) => {
|
|
||||||
this.logger.error(`Authentication failed: ${error.message}`);
|
|
||||||
return throwError(() => error);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* High-level method to send a message
|
|
||||||
*/
|
|
||||||
sendMessage(
|
|
||||||
userId: string,
|
|
||||||
messageData: {
|
|
||||||
from: { name?: string; address: string };
|
|
||||||
to: Array<{ name?: string; address: string }>;
|
|
||||||
subject: string;
|
|
||||||
text?: string;
|
|
||||||
html?: string;
|
|
||||||
},
|
|
||||||
): Observable<{ id: string; queueId: string }> {
|
|
||||||
this.logger.log(`Sending message for user: ${userId}`);
|
|
||||||
|
|
||||||
return this.submissionService.submitMessage(userId, messageData).pipe(
|
|
||||||
map((result) => {
|
|
||||||
this.logger.log(`Message submitted with ID: ${result.message.id}`);
|
|
||||||
return { id: result.message.id, queueId: result.message.queueId };
|
|
||||||
}),
|
|
||||||
catchError((error) => {
|
|
||||||
this.logger.error(`Failed to send message: ${error.message}`);
|
|
||||||
return throwError(() => error);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* High-level method to get user's recent messages
|
|
||||||
*/
|
|
||||||
getRecentMessages(userId: string, mailboxId: string, limit: number = 20): Observable<any[]> {
|
|
||||||
this.logger.log(`Getting recent messages for user: ${userId}`);
|
|
||||||
|
|
||||||
return this.messagesService.listMessages(userId, mailboxId, { limit, order: "desc" }).pipe(
|
|
||||||
map((result) => {
|
|
||||||
this.logger.log(`Found ${result.results.length} messages`);
|
|
||||||
return result.results;
|
|
||||||
}),
|
|
||||||
catchError((error) => {
|
|
||||||
this.logger.error(`Failed to get messages: ${error.message}`);
|
|
||||||
return throwError(() => error);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* High-level method to search across all user's messages
|
|
||||||
*/
|
|
||||||
searchAllMessages(userId: string, searchQuery: string, limit: number = 50): Observable<any[]> {
|
|
||||||
this.logger.log(`Searching messages for user: ${userId}, query: ${searchQuery}`);
|
|
||||||
|
|
||||||
return this.messagesService.searchMessages(userId, { query: searchQuery, limit }).pipe(
|
|
||||||
map((result) => {
|
|
||||||
this.logger.log(`Found ${result.results.length} matching messages`);
|
|
||||||
return result.results;
|
|
||||||
}),
|
|
||||||
catchError((error) => {
|
|
||||||
this.logger.error(`Search failed: ${error.message}`);
|
|
||||||
return throwError(() => error);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* High-level method to check server health and get system status
|
|
||||||
*/
|
|
||||||
getSystemStatus(): Observable<{
|
|
||||||
health: any;
|
|
||||||
storage: any;
|
|
||||||
}> {
|
|
||||||
this.logger.log("Getting system status");
|
|
||||||
|
|
||||||
return this.healthService.getHealthStatus().pipe(
|
|
||||||
switchMap((healthStatus) => {
|
|
||||||
return this.storageService.getStorageInfo().pipe(
|
|
||||||
map((storageInfo) => ({
|
|
||||||
health: healthStatus,
|
|
||||||
storage: storageInfo,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
catchError((error) => {
|
|
||||||
this.logger.error(`Failed to get system status: ${error.message}`);
|
|
||||||
return throwError(() => error);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* High-level method to setup complete 2FA for user
|
|
||||||
*/
|
|
||||||
setup2FAForUser(
|
|
||||||
userId: string,
|
|
||||||
issuer?: string,
|
|
||||||
): Observable<{
|
|
||||||
qrcode: string;
|
|
||||||
backupCodes: string[];
|
|
||||||
}> {
|
|
||||||
this.logger.log(`Setting up 2FA for user: ${userId}`);
|
|
||||||
|
|
||||||
return this.twoFactorService.setupTOTP(userId, { issuer }).pipe(
|
|
||||||
switchMap((totpResult) => {
|
|
||||||
return this.twoFactorService.generateBackupCodes(userId).pipe(
|
|
||||||
map((backupResult) => ({
|
|
||||||
qrcode: totpResult.qrcode,
|
|
||||||
backupCodes: backupResult.codes,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
catchError((error) => {
|
|
||||||
this.logger.error(`Failed to setup 2FA: ${error.message}`);
|
|
||||||
return throwError(() => error);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* High-level method to create DKIM key and get DNS settings
|
|
||||||
*/
|
|
||||||
createDKIMForDomain(
|
|
||||||
domain: string,
|
|
||||||
selector: string = "default",
|
|
||||||
): Observable<{
|
|
||||||
id: string;
|
|
||||||
dnsTxt: { name: string; value: string };
|
|
||||||
}> {
|
|
||||||
this.logger.log(`Creating DKIM key for domain: ${domain}`);
|
|
||||||
|
|
||||||
return this.dkimService.createDKIMKey({ domain, selector, description: `DKIM key for ${domain}` }).pipe(
|
|
||||||
map((result) => {
|
|
||||||
this.logger.log(`DKIM key created for domain: ${domain}`);
|
|
||||||
return {
|
|
||||||
id: result.id,
|
|
||||||
dnsTxt: result.dnsTxt,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
catchError((error) => {
|
|
||||||
this.logger.error(`Failed to create DKIM key: ${error.message}`);
|
|
||||||
return throwError(() => error);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,31 +4,24 @@ import { ConfigService } from "@nestjs/config";
|
|||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import { Observable, catchError, map, throwError } from "rxjs";
|
import { Observable, catchError, map, throwError } from "rxjs";
|
||||||
|
|
||||||
|
import { MailServerException } from "../../../core/exceptions/mail-server.exceptions";
|
||||||
import { WildDuckErrorResponse, WildDuckSuccessResponse } from "../interfaces/wildduck-response.interface";
|
import { WildDuckErrorResponse, WildDuckSuccessResponse } from "../interfaces/wildduck-response.interface";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class WildDuckBaseService {
|
export class WildDuckBaseService {
|
||||||
protected readonly logger = new Logger(this.constructor.name);
|
protected readonly logger = new Logger(this.constructor.name);
|
||||||
protected readonly baseUrl: string;
|
protected readonly baseURL: string;
|
||||||
protected readonly accessToken?: string;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
protected readonly httpService: HttpService,
|
protected readonly httpService: HttpService,
|
||||||
protected readonly configService: ConfigService,
|
protected readonly configService: ConfigService,
|
||||||
) {
|
) {
|
||||||
this.baseUrl = this.configService.get<string>("WILDDUCK_BASE_URL", "http://localhost:8080");
|
this.baseURL = this.configService.getOrThrow<string>("WILDDUCK_BASE_URL");
|
||||||
this.accessToken = this.configService.get<string>("WILDDUCK_ACCESS_TOKEN");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected buildUrl(path: string, params?: Record<string, any>): string {
|
protected buildUrl(path: string, params?: Record<string, unknown>): string {
|
||||||
const url = new URL(path, this.baseUrl);
|
const url = new URL(path, this.baseURL);
|
||||||
|
|
||||||
// Add access token if configured
|
|
||||||
if (this.accessToken) {
|
|
||||||
url.searchParams.set("accessToken", this.accessToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add other parameters
|
|
||||||
if (params) {
|
if (params) {
|
||||||
Object.entries(params).forEach(([key, value]) => {
|
Object.entries(params).forEach(([key, value]) => {
|
||||||
if (value !== undefined && value !== null) {
|
if (value !== undefined && value !== null) {
|
||||||
@@ -48,12 +41,10 @@ export class WildDuckBaseService {
|
|||||||
const data = response.data;
|
const data = response.data;
|
||||||
|
|
||||||
if ("success" in data && data.success === false) {
|
if ("success" in data && data.success === false) {
|
||||||
throw new Error(data.error || "WildDuck API error");
|
throw new MailServerException(data.error || "WildDuck API error");
|
||||||
}
|
}
|
||||||
|
|
||||||
if ("success" in data && data.success === true) {
|
if ("success" in data && data.success === true) {
|
||||||
// Return the entire response data for paginated responses
|
|
||||||
// or extract specific data field for simple responses
|
|
||||||
return data as T;
|
return data as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,7 +54,7 @@ export class WildDuckBaseService {
|
|||||||
this.logger.error(`WildDuck API error: ${error.message}`, error.stack);
|
this.logger.error(`WildDuck API error: ${error.message}`, error.stack);
|
||||||
|
|
||||||
if (error.response?.data?.error) {
|
if (error.response?.data?.error) {
|
||||||
return throwError(() => new Error(error.response.data.error));
|
return throwError(() => new MailServerException(error.response.data.error));
|
||||||
}
|
}
|
||||||
|
|
||||||
return throwError(() => error);
|
return throwError(() => error);
|
||||||
@@ -73,6 +64,10 @@ export class WildDuckBaseService {
|
|||||||
|
|
||||||
protected get<T>(path: string, params?: Record<string, any>): Observable<T> {
|
protected get<T>(path: string, params?: Record<string, any>): Observable<T> {
|
||||||
const url = this.buildUrl(path, params);
|
const url = this.buildUrl(path, params);
|
||||||
|
this.logger.debug(`WildDuck GET: ${url}`);
|
||||||
|
if (params) {
|
||||||
|
this.logger.debug(`WildDuck GET Params:`, params);
|
||||||
|
}
|
||||||
return this.handleResponse<T>(this.httpService.get(url));
|
return this.handleResponse<T>(this.httpService.get(url));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { Socket } from "socket.io";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts authentication token from WebSocket client handshake
|
||||||
|
* Supports multiple token sources for flexibility
|
||||||
|
*
|
||||||
|
* @param client - Socket.IO client instance
|
||||||
|
* @returns Authentication token string or undefined if not found
|
||||||
|
*/
|
||||||
|
export function extractTokenFromClient(client: Socket): string | undefined {
|
||||||
|
// Priority order: auth.token > query.token > headers.authorization
|
||||||
|
const authToken = client.handshake?.auth?.token;
|
||||||
|
if (authToken) {
|
||||||
|
return authToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryToken = client.handshake?.query?.token;
|
||||||
|
if (queryToken && typeof queryToken === "string") {
|
||||||
|
return queryToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authHeader = client.handshake?.headers?.authorization;
|
||||||
|
if (authHeader && typeof authHeader === "string") {
|
||||||
|
// Remove 'Bearer ' prefix if present
|
||||||
|
return authHeader.replace(/^Bearer\s+/i, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user