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()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Max(50)
|
||||
@Max(100)
|
||||
@Type(() => Number)
|
||||
@ApiPropertyOptional({ type: "number", required: false })
|
||||
limit?: number;
|
||||
|
||||
@@ -218,7 +218,8 @@ export const enum EmailMessage {
|
||||
BULK_ACTION_PARTIALLY_COMPLETED = "عملیات گروهی تا حدی انجام شد",
|
||||
BULK_ACTION_FAILED = "عملیات گروهی با خطا مواجه شد",
|
||||
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 {
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { HttpModuleAsyncOptions } from "@nestjs/axios";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
|
||||
export const wildduckConfig = () => ({
|
||||
export const wildduckConfig = (): HttpModuleAsyncOptions => ({
|
||||
inject: [ConfigService],
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
baseUrl: configService.getOrThrow<string>("WILDDUCK_BASE_URL"),
|
||||
accessToken: configService.getOrThrow<string>("WILDDUCK_ACCESS_TOKEN"),
|
||||
baseURL: configService.getOrThrow<string>("WILDDUCK_BASE_URL"),
|
||||
timeout: configService.getOrThrow<number>("WILDDUCK_TIMEOUT"),
|
||||
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;
|
||||
|
||||
const { protocol, hostname, url } = request;
|
||||
const baseUrl = url.split("?")[0];
|
||||
const baseURL = url.split("?")[0];
|
||||
const queryParams = new URLSearchParams({
|
||||
page: page.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[];
|
||||
|
||||
@IsOptional()
|
||||
// @IsOptional()
|
||||
@IsNotEmpty({ message: EmailMessage.SUBJECT_REQUIRED })
|
||||
@IsString({ message: EmailMessage.SUBJECT_MUST_BE_STRING })
|
||||
@ApiPropertyOptional({ description: "Message subject. If not then resolved from Reference message (optional)", example: "Test email" })
|
||||
subject?: string;
|
||||
subject: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString({ message: EmailMessage.TEXT_CONTENT_MUST_BE_STRING })
|
||||
|
||||
@@ -2,7 +2,9 @@ export const WEBSOCKET_EVENTS = {
|
||||
// Connection Events
|
||||
CONNECTION_ESTABLISHED: "connection_established",
|
||||
CONNECTION_ERROR: "connection_error",
|
||||
|
||||
CONNECTION_CLOSED: "connection_closed",
|
||||
USER_LEFT: "user_left",
|
||||
UNAUTHORIZED: "unauthorized",
|
||||
// Email Events - Server to Client
|
||||
NEW_EMAIL: "new_email",
|
||||
EMAIL_READ: "email_read",
|
||||
@@ -34,3 +36,10 @@ export const WEBSOCKET_NAMESPACES = {
|
||||
export type WebSocketEvent = (typeof WEBSOCKET_EVENTS)[keyof typeof WEBSOCKET_EVENTS];
|
||||
export type WebSocketRoom = string;
|
||||
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 { BullModule } from "@nestjs/bullmq";
|
||||
import { Module } from "@nestjs/common";
|
||||
import { JwtModule } from "@nestjs/jwt";
|
||||
|
||||
// import { EmailGatewayController } from "./controllers/email-gateway.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 { 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 { MailboxResolverService } from "./services/mailbox-resolver.service";
|
||||
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 { User } from "../users/entities/user.entity";
|
||||
import { UsersModule } from "../users/users.module";
|
||||
import { EMAIL_QUEUE_CONSTANTS } from "./constants/email-events.constant";
|
||||
import { WebSocketAuthService } from "./services/websocket-auth.service";
|
||||
|
||||
@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],
|
||||
providers: [EmailService, MailboxResolverService, EmailHeadersService],
|
||||
exports: [EmailService, MailboxResolverService, EmailHeadersService],
|
||||
providers: [
|
||||
EmailService,
|
||||
MailboxResolverService,
|
||||
EmailHeadersService,
|
||||
EmailGateway,
|
||||
EmailMonitoringService,
|
||||
EmailMonitoringSchedulerService,
|
||||
EmailNotificationService,
|
||||
EmailMonitoringProcessor,
|
||||
WebSocketAuthService,
|
||||
],
|
||||
exports: [EmailService, MailboxResolverService, EmailHeadersService, EmailGateway, EmailMonitoringService, EmailNotificationService],
|
||||
})
|
||||
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 {
|
||||
messageId: number;
|
||||
// Base interface with common properties
|
||||
export interface BaseEventPayload {
|
||||
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: {
|
||||
name?: string;
|
||||
address: string;
|
||||
@@ -12,64 +32,46 @@ export interface EmailPayload {
|
||||
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 type EmailStatusPayload = BaseMessageMailboxEventPayload;
|
||||
|
||||
export interface EmailMovePayload {
|
||||
messageId: number;
|
||||
userId: string;
|
||||
export interface EmailMovePayload extends BaseMessageEventPayload {
|
||||
fromMailboxId: string;
|
||||
toMailboxId: string;
|
||||
fromMailboxName: string;
|
||||
toMailboxName: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface EmailDeletePayload {
|
||||
messageId: number;
|
||||
userId: string;
|
||||
mailboxId: string;
|
||||
timestamp: string;
|
||||
}
|
||||
export type EmailDeletePayload = BaseMessageMailboxEventPayload;
|
||||
|
||||
export interface EmailSentPayload {
|
||||
messageId: number;
|
||||
userId: string;
|
||||
export interface EmailSentPayload extends BaseMessageMailboxEventPayload {
|
||||
from: {
|
||||
name?: string;
|
||||
address: string;
|
||||
};
|
||||
to: Array<{
|
||||
name?: string;
|
||||
address: string;
|
||||
}>;
|
||||
subject: string;
|
||||
timestamp: string;
|
||||
mailboxId: string;
|
||||
hasAttachments: boolean;
|
||||
mailboxName: string;
|
||||
isRead: boolean;
|
||||
}
|
||||
|
||||
export interface MailboxUpdatePayload {
|
||||
userId: string;
|
||||
mailboxId: string;
|
||||
export interface MailboxUpdatePayload extends BaseMailboxEventPayload {
|
||||
mailboxName: string;
|
||||
unreadCount: number;
|
||||
totalCount: number;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface UnreadCountPayload {
|
||||
userId: string;
|
||||
export interface UnreadCountPayload extends BaseEventPayload {
|
||||
totalUnread: number;
|
||||
mailboxCounts: Record<string, number>;
|
||||
timestamp: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface EmailWebSocketEvents {
|
||||
@@ -93,3 +95,39 @@ export interface EmailWebSocketEvents {
|
||||
ping: { 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 { 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 {
|
||||
userId: string;
|
||||
sessionId?: string;
|
||||
connectedAt?: Date;
|
||||
lastActivity?: Date;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket authentication result
|
||||
* WebSocket error context for logging and debugging
|
||||
*/
|
||||
export interface WebSocketAuthResult {
|
||||
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 {
|
||||
export interface WebSocketErrorContext {
|
||||
clientId: string;
|
||||
clientIP: string;
|
||||
userAgent?: string;
|
||||
timestamp: string;
|
||||
userId?: string;
|
||||
sessionId?: string;
|
||||
event?: string;
|
||||
data?: unknown;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket session information
|
||||
* WebSocket event response structure
|
||||
*/
|
||||
export interface WebSocketSession {
|
||||
id: string;
|
||||
userId: string;
|
||||
createdAt: Date;
|
||||
lastActivity: Date;
|
||||
clientInfo: {
|
||||
userAgent: string;
|
||||
ip: string;
|
||||
};
|
||||
isActive: boolean;
|
||||
export interface WebSocketResponse<T = unknown> {
|
||||
status: "success" | "error";
|
||||
message?: string;
|
||||
data?: T;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket room subscription information
|
||||
* Authentication result for WebSocket connections
|
||||
*/
|
||||
export interface RoomSubscription {
|
||||
roomId: string;
|
||||
userId: string;
|
||||
subscribedAt: Date;
|
||||
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;
|
||||
export interface AuthenticationResult {
|
||||
success: boolean;
|
||||
user?: ITokenPayload;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@@ -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 { EmailHeadersService } from "./email-headers.service";
|
||||
import { EmailNotificationService } from "./email-notification.service";
|
||||
import { MailboxResolverService } from "./mailbox-resolver.service";
|
||||
import { EmailMessage } from "../../../common/enums/message.enum";
|
||||
import { MailServerService } from "../../mail-server/services/mail-server.service";
|
||||
@@ -21,6 +22,7 @@ export class EmailService {
|
||||
constructor(
|
||||
private readonly mailServerService: MailServerService,
|
||||
private readonly mailboxResolverService: MailboxResolverService,
|
||||
private readonly emailNotificationService: EmailNotificationService,
|
||||
private readonly templateProcessorService: TemplateProcessorService,
|
||||
private readonly userRepository: UserRepository,
|
||||
private readonly emailHeadersService: EmailHeadersService,
|
||||
@@ -66,6 +68,17 @@ export class EmailService {
|
||||
|
||||
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 {
|
||||
message: EmailMessage.EMAIL_SENDING_SUCCESS,
|
||||
messageId: response.message.id,
|
||||
@@ -101,15 +114,35 @@ export class EmailService {
|
||||
this.logger.log(`Searching messages for user ${userId} with query: ${query.q}`);
|
||||
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.mailServerService.messages.searchMessages(userId, { query: query.q || "", limit: query.limit, page: query.page }),
|
||||
);
|
||||
// Transform query parameters for WildDuck API compatibility
|
||||
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 {
|
||||
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) {
|
||||
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);
|
||||
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 {
|
||||
message: EmailMessage.MESSAGES_LISTED_SUCCESSFULLY,
|
||||
results,
|
||||
count,
|
||||
results: response.results,
|
||||
count: response.total,
|
||||
page: response.page,
|
||||
previousCursor: response.previousCursor,
|
||||
nextCursor: response.nextCursor,
|
||||
paginate: true,
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -201,6 +241,12 @@ export class EmailService {
|
||||
|
||||
this.logger.log(`Message ${messageId} marked as seen successfully in ${messageLocation.mailboxName}`);
|
||||
|
||||
await this.emailNotificationService.notifyEmailRead({
|
||||
userId,
|
||||
messageId,
|
||||
mailboxId: messageLocation.mailboxId,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: EmailMessage.MESSAGE_MARKED_AS_SEEN_SUCCESSFULLY,
|
||||
@@ -218,11 +264,17 @@ export class EmailService {
|
||||
async getSentMessages(userId: string, query: MessageListQueryDto) {
|
||||
try {
|
||||
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 {
|
||||
sentMails,
|
||||
count,
|
||||
sentMails: response.results,
|
||||
count: response.total,
|
||||
page: response.page,
|
||||
previousCursor: response.previousCursor,
|
||||
nextCursor: response.nextCursor,
|
||||
paginate: true,
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -235,10 +287,17 @@ export class EmailService {
|
||||
async getTrashMessages(userId: string, query: MessageListQueryDto) {
|
||||
try {
|
||||
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 {
|
||||
trashMails,
|
||||
count,
|
||||
trashMails: response.results,
|
||||
count: response.total,
|
||||
page: response.page,
|
||||
previousCursor: response.previousCursor,
|
||||
nextCursor: response.nextCursor,
|
||||
paginate: true,
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -251,11 +310,17 @@ export class EmailService {
|
||||
async getJunkMessages(userId: string, query: MessageListQueryDto) {
|
||||
try {
|
||||
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 {
|
||||
junkMails,
|
||||
count,
|
||||
junkMails: response.results,
|
||||
count: response.total,
|
||||
page: response.page,
|
||||
previousCursor: response.previousCursor,
|
||||
nextCursor: response.nextCursor,
|
||||
paginate: true,
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -268,13 +333,17 @@ export class EmailService {
|
||||
async getArchivedMessages(userId: string, query: MessageListQueryDto) {
|
||||
try {
|
||||
const archiveMailboxId = await this.mailboxResolverService.getArchiveMailboxId(userId);
|
||||
const { results: archiveMails, total: count } = await firstValueFrom(
|
||||
this.mailServerService.messages.listMessages(userId, archiveMailboxId, query),
|
||||
);
|
||||
const wildDuckQuery = this.transformPaginationQuery(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 {
|
||||
archiveMails,
|
||||
count,
|
||||
archiveMails: response.results,
|
||||
count: response.total,
|
||||
page: response.page,
|
||||
previousCursor: response.previousCursor,
|
||||
nextCursor: response.nextCursor,
|
||||
paginate: true,
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -287,17 +356,21 @@ export class EmailService {
|
||||
async getFavoriteMessages(userId: string, query: MessageListQueryDto) {
|
||||
try {
|
||||
const favoriteMailboxId = await this.mailboxResolverService.getFavoriteMailboxId(userId);
|
||||
const { results: favoriteMails, total: count } = await firstValueFrom(
|
||||
this.mailServerService.messages.listMessages(userId, favoriteMailboxId, query),
|
||||
);
|
||||
const wildDuckQuery = this.transformPaginationQuery(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 {
|
||||
favoriteMails,
|
||||
count,
|
||||
favoriteMails: response.results,
|
||||
count: response.total,
|
||||
page: response.page,
|
||||
previousCursor: response.previousCursor,
|
||||
nextCursor: response.nextCursor,
|
||||
paginate: true,
|
||||
};
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
@@ -307,13 +380,17 @@ export class EmailService {
|
||||
async getDraftsMessages(userId: string, query: MessageListQueryDto) {
|
||||
try {
|
||||
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 {
|
||||
draftsMail,
|
||||
count,
|
||||
draftsMail: response.results,
|
||||
count: response.total,
|
||||
page: response.page,
|
||||
previousCursor: response.previousCursor,
|
||||
nextCursor: response.nextCursor,
|
||||
paginate: true,
|
||||
};
|
||||
} 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> {
|
||||
try {
|
||||
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()
|
||||
@IsString()
|
||||
previous?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "Unseen messages only" })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
unseen?: boolean;
|
||||
}
|
||||
|
||||
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 { Observable, catchError, map, switchMap, throwError } from "rxjs";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { WildDuckAddressesService } from "./wildduck-addresses.service";
|
||||
import { WildDuckApplicationPasswordsService } from "./wildduck-application-passwords.service";
|
||||
@@ -24,8 +23,6 @@ import { DANAK_IMAPS_SERVER } from "../../../common/constants";
|
||||
|
||||
@Injectable()
|
||||
export class MailServerService {
|
||||
private readonly logger = new Logger(MailServerService.name);
|
||||
|
||||
constructor(
|
||||
private readonly usersService: WildDuckUsersService,
|
||||
private readonly messagesService: WildDuckMessagesService,
|
||||
@@ -136,229 +133,4 @@ export class MailServerService {
|
||||
get twoFactor() {
|
||||
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 { Observable, catchError, map, throwError } from "rxjs";
|
||||
|
||||
import { MailServerException } from "../../../core/exceptions/mail-server.exceptions";
|
||||
import { WildDuckErrorResponse, WildDuckSuccessResponse } from "../interfaces/wildduck-response.interface";
|
||||
|
||||
@Injectable()
|
||||
export class WildDuckBaseService {
|
||||
protected readonly logger = new Logger(this.constructor.name);
|
||||
protected readonly baseUrl: string;
|
||||
protected readonly accessToken?: string;
|
||||
protected readonly baseURL: string;
|
||||
|
||||
constructor(
|
||||
protected readonly httpService: HttpService,
|
||||
protected readonly configService: ConfigService,
|
||||
) {
|
||||
this.baseUrl = this.configService.get<string>("WILDDUCK_BASE_URL", "http://localhost:8080");
|
||||
this.accessToken = this.configService.get<string>("WILDDUCK_ACCESS_TOKEN");
|
||||
this.baseURL = this.configService.getOrThrow<string>("WILDDUCK_BASE_URL");
|
||||
}
|
||||
|
||||
protected buildUrl(path: string, params?: Record<string, any>): string {
|
||||
const url = new URL(path, this.baseUrl);
|
||||
protected buildUrl(path: string, params?: Record<string, unknown>): string {
|
||||
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) {
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
@@ -48,12 +41,10 @@ export class WildDuckBaseService {
|
||||
const data = response.data;
|
||||
|
||||
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) {
|
||||
// Return the entire response data for paginated responses
|
||||
// or extract specific data field for simple responses
|
||||
return data as T;
|
||||
}
|
||||
|
||||
@@ -63,7 +54,7 @@ export class WildDuckBaseService {
|
||||
this.logger.error(`WildDuck API error: ${error.message}`, error.stack);
|
||||
|
||||
if (error.response?.data?.error) {
|
||||
return throwError(() => new Error(error.response.data.error));
|
||||
return throwError(() => new MailServerException(error.response.data.error));
|
||||
}
|
||||
|
||||
return throwError(() => error);
|
||||
@@ -73,6 +64,10 @@ export class WildDuckBaseService {
|
||||
|
||||
protected get<T>(path: string, params?: Record<string, any>): Observable<T> {
|
||||
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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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