diff --git a/WEB_PUSH_SETUP.md b/WEB_PUSH_SETUP.md
new file mode 100644
index 0000000..0361cdd
--- /dev/null
+++ b/WEB_PUSH_SETUP.md
@@ -0,0 +1,579 @@
+# Web Push Notifications Setup Guide
+
+This guide explains how to implement and use **Web Push notifications** in the Dmail API. Web Push allows you to send notifications directly to users' browsers, even when your web app is not open.
+
+## Overview
+
+The Web Push notification system sends real-time notifications to users' browsers when new emails arrive. It uses the standard **Web Push API** supported by all modern browsers and works with service workers.
+
+## Architecture
+
+```
+Email Received → EmailMonitoringService → NotificationQueue → NewEmailHandler → PushNotificationHandler → Browser Push Service → User's Browser
+```
+
+## Setup Instructions
+
+### 1. Generate VAPID Keys
+
+VAPID (Voluntary Application Server Identification) keys are required for Web Push notifications:
+
+```bash
+# Generate VAPID keys using Node.js
+node -e "
+const webpush = require('web-push');
+const vapidKeys = webpush.generateVAPIDKeys();
+console.log('Public Key:', vapidKeys.publicKey);
+console.log('Private Key:', vapidKeys.privateKey);
+"
+```
+
+### 2. Configure Environment Variables
+
+Add these environment variables to your `.env` file:
+
+```bash
+# Web Push Notifications Configuration
+PUSH_NOTIFICATIONS_ENABLED=true
+VAPID_PUBLIC_KEY=your_generated_public_key_here
+VAPID_PRIVATE_KEY=your_generated_private_key_here
+VAPID_SUBJECT=mailto:admin@yourdomain.com
+
+# Optional: Push notification settings
+PUSH_TTL=86400
+PUSH_URGENCY=normal
+PUSH_RETRY_ATTEMPTS=3
+PUSH_RETRY_DELAY=5000
+
+# Frontend URL for notification actions
+FRONTEND_URL=https://mail.yourdomain.com
+```
+
+### 3. Install Dependencies
+
+```bash
+# Install web-push library
+npm install web-push
+# or
+pnpm add web-push
+```
+
+### 4. Run Database Migration
+
+```bash
+# Apply the database migration for web push notification fields
+psql -d your_database -f database/migrations/add_push_notifications_to_users.sql
+```
+
+### 5. Update App Module
+
+Add the push notifications config to your `app.module.ts`:
+
+```typescript
+import { pushNotificationsConfig } from "./configs/push-notifications.config";
+
+@Module({
+ imports: [
+ ConfigModule.forRoot({
+ load: [
+ // ... other configs
+ pushNotificationsConfig,
+ ],
+ }),
+ // ... other modules
+ ],
+})
+export class AppModule {}
+```
+
+## Frontend Implementation
+
+### 1. Service Worker Setup
+
+Create `public/sw.js` in your frontend project:
+
+```javascript
+// Service Worker for handling push notifications
+self.addEventListener("push", function (event) {
+ if (event.data) {
+ const data = event.data.json();
+
+ const options = {
+ body: data.body,
+ icon: data.icon || "/assets/email-icon.png",
+ badge: data.badge || "/assets/email-badge.png",
+ tag: data.tag || "email-notification",
+ requireInteraction: data.requireInteraction || false,
+ actions: data.actions || [],
+ data: data.data || {},
+ };
+
+ event.waitUntil(self.registration.showNotification(data.title, options));
+ }
+});
+
+// Handle notification clicks
+self.addEventListener("notificationclick", function (event) {
+ event.notification.close();
+
+ if (event.action === "view") {
+ // Open the email in the app
+ event.waitUntil(clients.openWindow(event.notification.data.url || "/"));
+ } else if (event.action === "dismiss") {
+ // Just close the notification
+ return;
+ } else {
+ // Default action - open the app
+ event.waitUntil(clients.openWindow(event.notification.data.url || "/"));
+ }
+});
+```
+
+### 2. Client-side JavaScript
+
+Add this to your main JavaScript file:
+
+```javascript
+class WebPushManager {
+ constructor(apiBaseUrl, getAuthToken) {
+ this.apiBaseUrl = apiBaseUrl;
+ this.getAuthToken = getAuthToken;
+ this.vapidPublicKey = null;
+ }
+
+ // Initialize and register service worker
+ async init() {
+ if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
+ console.warn("Push messaging is not supported");
+ return false;
+ }
+
+ try {
+ // Register service worker
+ const registration = await navigator.serviceWorker.register("/sw.js");
+ console.log("Service Worker registered:", registration);
+
+ // Get VAPID public key
+ await this.getVAPIDKey();
+
+ return true;
+ } catch (error) {
+ console.error("Service Worker registration failed:", error);
+ return false;
+ }
+ }
+
+ // Get VAPID public key from server
+ async getVAPIDKey() {
+ try {
+ const response = await fetch(`${this.apiBaseUrl}/users/push-notifications/vapid-key`, {
+ headers: {
+ Authorization: `Bearer ${this.getAuthToken()}`,
+ },
+ });
+
+ if (response.ok) {
+ const data = await response.json();
+ this.vapidPublicKey = data.vapidPublicKey;
+ return this.vapidPublicKey;
+ }
+ } catch (error) {
+ console.error("Failed to get VAPID key:", error);
+ }
+ return null;
+ }
+
+ // Request permission and subscribe to push notifications
+ async subscribeToPush() {
+ if (!this.vapidPublicKey) {
+ await this.getVAPIDKey();
+ }
+
+ if (!this.vapidPublicKey) {
+ throw new Error("VAPID public key not available");
+ }
+
+ // Request permission
+ const permission = await Notification.requestPermission();
+ if (permission !== "granted") {
+ throw new Error("Push notification permission denied");
+ }
+
+ // Get service worker registration
+ const registration = await navigator.serviceWorker.ready;
+
+ try {
+ // Subscribe to push notifications
+ const subscription = await registration.pushManager.subscribe({
+ userVisibleOnly: true,
+ applicationServerKey: this.urlBase64ToUint8Array(this.vapidPublicKey),
+ });
+
+ // Send subscription to server
+ await this.sendSubscriptionToServer(subscription);
+
+ console.log("Successfully subscribed to push notifications");
+ return subscription;
+ } catch (error) {
+ console.error("Failed to subscribe to push notifications:", error);
+ throw error;
+ }
+ }
+
+ // Send subscription to server
+ async sendSubscriptionToServer(subscription) {
+ const response = await fetch(`${this.apiBaseUrl}/users/push-subscriptions`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${this.getAuthToken()}`,
+ },
+ body: JSON.stringify({
+ subscription: subscription.toJSON(),
+ deviceType: "web",
+ deviceName: navigator.userAgent.slice(0, 50),
+ }),
+ });
+
+ if (!response.ok) {
+ throw new Error("Failed to send subscription to server");
+ }
+
+ return response.json();
+ }
+
+ // Test push notification
+ async testNotification() {
+ const response = await fetch(`${this.apiBaseUrl}/users/push-notifications/test`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${this.getAuthToken()}`,
+ },
+ body: JSON.stringify({
+ message: "Test notification from Dmail Web App",
+ }),
+ });
+
+ if (!response.ok) {
+ throw new Error("Failed to send test notification");
+ }
+
+ return response.json();
+ }
+
+ // Check if user is subscribed
+ async isSubscribed() {
+ if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
+ return false;
+ }
+
+ const registration = await navigator.serviceWorker.ready;
+ const subscription = await registration.pushManager.getSubscription();
+ return !!subscription;
+ }
+
+ // Unsubscribe from push notifications
+ async unsubscribe() {
+ const registration = await navigator.serviceWorker.ready;
+ const subscription = await registration.pushManager.getSubscription();
+
+ if (subscription) {
+ // Unsubscribe from browser
+ await subscription.unsubscribe();
+
+ // Remove from server
+ await fetch(`${this.apiBaseUrl}/users/push-subscriptions`, {
+ method: "DELETE",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${this.getAuthToken()}`,
+ },
+ body: JSON.stringify({
+ endpoint: subscription.endpoint,
+ }),
+ });
+
+ console.log("Successfully unsubscribed from push notifications");
+ }
+ }
+
+ // Utility function to convert VAPID key
+ urlBase64ToUint8Array(base64String) {
+ const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
+ const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
+
+ const rawData = window.atob(base64);
+ const outputArray = new Uint8Array(rawData.length);
+
+ for (let i = 0; i < rawData.length; ++i) {
+ outputArray[i] = rawData.charCodeAt(i);
+ }
+ return outputArray;
+ }
+}
+
+// Usage example
+const pushManager = new WebPushManager("https://api.yourdomain.com", () => localStorage.getItem("authToken"));
+
+// Initialize on page load
+pushManager.init().then((success) => {
+ if (success) {
+ console.log("Web Push initialized successfully");
+ }
+});
+
+// Export for use in your app
+window.webPushManager = pushManager;
+```
+
+### 3. User Interface Integration
+
+Add UI components to your app:
+
+```html
+
+
+
+
📬 Enable Email Notifications
+
Get instant notifications when new emails arrive, even when the app is closed.
+
+
+
+
+
+
+
+```
+
+## API Endpoints
+
+### Push Subscription Management
+
+- `POST /users/push-subscriptions` - Register a new push subscription
+- `PATCH /users/push-subscriptions` - Update push subscriptions
+- `DELETE /users/push-subscriptions` - Remove a push subscription
+
+### Push Notification Settings
+
+- `PATCH /users/push-notifications/settings` - Enable/disable push notifications
+- `GET /users/push-notifications/info` - Get push notification info and settings
+- `POST /users/push-notifications/test` - Send a test notification
+- `GET /users/push-notifications/vapid-key` - Get VAPID public key
+
+## Usage Examples
+
+### Register Push Subscription
+
+```bash
+curl -X POST https://api.yourdomain.com/users/push-subscriptions \
+ -H "Authorization: Bearer YOUR_JWT_TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "subscription": {
+ "endpoint": "https://fcm.googleapis.com/fcm/send/...",
+ "keys": {
+ "p256dh": "BNbxxx...",
+ "auth": "abc123..."
+ }
+ },
+ "deviceType": "web",
+ "deviceName": "Chrome on MacBook Pro"
+ }'
+```
+
+### Test Push Notification
+
+```bash
+curl -X POST https://api.yourdomain.com/users/push-notifications/test \
+ -H "Authorization: Bearer YOUR_JWT_TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "message": "Test notification from Dmail API"
+ }'
+```
+
+### Get VAPID Public Key
+
+```bash
+curl -X GET https://api.yourdomain.com/users/push-notifications/vapid-key \
+ -H "Authorization: Bearer YOUR_JWT_TOKEN"
+```
+
+## How It Works
+
+1. **Service Worker Registration**: Your web app registers a service worker that can receive push messages
+2. **User Permission**: The user grants permission for notifications
+3. **Subscription Creation**: The browser creates a push subscription with a unique endpoint
+4. **Server Registration**: The subscription is sent to your API and stored in the database
+5. **Email Detection**: When new emails arrive, the EmailMonitoringService detects them
+6. **Notification Processing**: The NewEmailHandler triggers the PushNotificationHandler
+7. **Push Message**: The server sends a push message to the browser's push service
+8. **Notification Display**: The service worker receives the message and shows the notification
+
+## Browser Support
+
+Web Push is supported by:
+
+- ✅ Chrome 50+
+- ✅ Firefox 44+
+- ✅ Safari 16.0+
+- ✅ Edge 17+
+- ✅ Opera 37+
+
+## Notification Flow
+
+```mermaid
+sequenceDiagram
+ participant U as User's Browser
+ participant SW as Service Worker
+ participant API as Dmail API
+ participant PS as Push Service
+ participant E as Email System
+
+ U->>SW: Register Service Worker
+ U->>API: Request VAPID key
+ API->>U: Return VAPID public key
+ U->>U: Request notification permission
+ U->>PS: Create push subscription
+ PS->>U: Return subscription object
+ U->>API: Register subscription
+ API->>API: Store subscription
+
+ E->>API: New email detected
+ API->>PS: Send push message
+ PS->>SW: Deliver push message
+ SW->>U: Show notification
+```
+
+## Security Considerations
+
+1. **VAPID Keys**: Keep your private VAPID key secure and never expose it to the client
+2. **Subscription Validation**: Always validate subscription objects before storing them
+3. **Rate Limiting**: Implement rate limiting to prevent notification spam
+4. **Data Privacy**: Be mindful of what data you include in push notifications
+5. **User Consent**: Always request explicit user permission before subscribing to push notifications
+
+## Troubleshooting
+
+### Common Issues
+
+1. **Notifications not received**:
+ - Check if the user has granted notification permission
+ - Verify the service worker is registered correctly
+ - Ensure VAPID keys are properly configured
+ - Check browser console for errors
+
+2. **VAPID key errors**:
+ - Ensure VAPID keys are correctly generated and configured
+ - Verify the VAPID subject is a valid mailto: or https: URL
+
+3. **Service worker issues**:
+ - Check if the service worker file is accessible
+ - Verify the service worker scope includes your web app
+ - Ensure HTTPS is used (required for service workers)
+
+### Debug Commands
+
+```bash
+# Test VAPID key configuration
+curl -X GET https://api.yourdomain.com/users/push-notifications/vapid-key \
+ -H "Authorization: Bearer YOUR_TOKEN"
+
+# Check user's notification settings
+curl -X GET https://api.yourdomain.com/users/push-notifications/info \
+ -H "Authorization: Bearer YOUR_TOKEN"
+
+# Send test notification
+curl -X POST https://api.yourdomain.com/users/push-notifications/test \
+ -H "Authorization: Bearer YOUR_TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{"message": "Test"}'
+```
+
+## Monitoring and Analytics
+
+- **Subscription Management**: Track active subscriptions per user
+- **Delivery Success**: Monitor push notification delivery rates
+- **User Engagement**: Track notification click-through rates
+- **Error Handling**: Log and monitor push notification failures
+
+## Performance Considerations
+
+- **Payload Size**: Keep push notification payloads small (max 4KB)
+- **Batch Processing**: Process multiple subscriptions efficiently
+- **TTL Settings**: Configure appropriate Time-To-Live for notifications
+- **Cleanup**: Regularly remove invalid/expired subscriptions
+
+## Future Enhancements
+
+- **Rich Notifications**: Add images and action buttons
+- **Notification Categories**: Different notification types for different email categories
+- **User Preferences**: Granular control over notification timing and content
+- **Analytics Dashboard**: Web interface for monitoring push notification metrics
+- **A/B Testing**: Test different notification strategies and content
+
+## Support
+
+For issues and questions:
+
+1. Check browser developer console for errors
+2. Verify service worker registration and subscription
+3. Test VAPID key configuration
+4. Review application logs for error messages
diff --git a/database/migrations/add_push_notifications_to_users.sql b/database/migrations/add_push_notifications_to_users.sql
new file mode 100644
index 0000000..c4e71d4
--- /dev/null
+++ b/database/migrations/add_push_notifications_to_users.sql
@@ -0,0 +1,23 @@
+-- Add web push notification fields to users table
+ALTER TABLE users
+ADD COLUMN push_tokens JSONB DEFAULT NULL,
+ADD COLUMN push_notifications_enabled BOOLEAN DEFAULT true,
+ADD COLUMN last_push_notification_at TIMESTAMPTZ DEFAULT NULL;
+
+-- Add indexes for better performance
+CREATE INDEX IF NOT EXISTS idx_users_push_notifications_enabled ON users (push_notifications_enabled)
+WHERE
+ push_notifications_enabled = true
+ AND deleted_at IS NULL;
+
+CREATE INDEX IF NOT EXISTS idx_users_push_tokens ON users USING GIN (push_tokens)
+WHERE
+ push_tokens IS NOT NULL
+ AND deleted_at IS NULL;
+
+-- Add comment for documentation
+COMMENT ON COLUMN users.push_tokens IS 'JSON array of web push subscription objects containing endpoint and keys';
+
+COMMENT ON COLUMN users.push_notifications_enabled IS 'Whether web push notifications are enabled for this user';
+
+COMMENT ON COLUMN users.last_push_notification_at IS 'Timestamp of the last push notification sent to this user';
\ No newline at end of file
diff --git a/package.json b/package.json
index ccffaf4..eb5b737 100644
--- a/package.json
+++ b/package.json
@@ -72,7 +72,8 @@
"rxjs": "^7.8.2",
"slugify": "^1.6.6",
"socket.io": "^4.8.1",
- "uuid": "^11.1.0"
+ "uuid": "^11.1.0",
+ "web-push": "^3.6.7"
},
"devDependencies": {
"@commitlint/cli": "^19.8.1",
@@ -90,6 +91,7 @@
"@types/nodemailer": "^6.4.17",
"@types/passport-jwt": "^4.0.1",
"@types/supertest": "^6.0.3",
+ "@types/web-push": "^3.6.4",
"@typescript-eslint/eslint-plugin": "^8.35.1",
"@typescript-eslint/parser": "^8.35.1",
"eslint": "^9.30.1",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b5e6548..c62085e 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -137,6 +137,9 @@ importers:
uuid:
specifier: ^11.1.0
version: 11.1.0
+ web-push:
+ specifier: ^3.6.7
+ version: 3.6.7
devDependencies:
'@commitlint/cli':
specifier: ^19.8.1
@@ -180,6 +183,9 @@ importers:
'@types/supertest':
specifier: ^6.0.3
version: 6.0.3
+ '@types/web-push':
+ specifier: ^3.6.4
+ version: 3.6.4
'@typescript-eslint/eslint-plugin':
specifier: ^8.35.1
version: 8.35.1(@typescript-eslint/parser@8.35.1(eslint@9.30.1(jiti@2.4.2))(typescript@5.8.3))(eslint@9.30.1(jiti@2.4.2))(typescript@5.8.3)
@@ -2037,6 +2043,9 @@ packages:
'@types/validator@13.15.2':
resolution: {integrity: sha512-y7pa/oEJJ4iGYBxOpfAKn5b9+xuihvzDVnC/OSvlVnGxVg0pOqmjiMafiJ1KVNQEaPZf9HsEp5icEwGg8uIe5Q==}
+ '@types/web-push@3.6.4':
+ resolution: {integrity: sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==}
+
'@types/yargs-parser@21.0.3':
resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==}
@@ -2329,6 +2338,10 @@ packages:
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
engines: {node: '>= 6.0.0'}
+ agent-base@7.1.4:
+ resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
+ engines: {node: '>= 14'}
+
ajv-formats@2.1.1:
resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==}
peerDependencies:
@@ -2465,6 +2478,9 @@ packages:
asap@2.0.6:
resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==}
+ asn1.js@5.4.1:
+ resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==}
+
assert-never@1.4.0:
resolution: {integrity: sha512-5oJg84os6NMQNl27T9LnZkvvqzvAnHu03ShCnoj6bsJwS7L8AO4lf+C/XjK/nvzEqQB744moC6V128RucQd1jA==}
@@ -2556,6 +2572,9 @@ packages:
bl@4.1.0:
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
+ bn.js@4.12.2:
+ resolution: {integrity: sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==}
+
boolbase@1.0.0:
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
@@ -3839,10 +3858,18 @@ packages:
resolution: {integrity: sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==}
engines: {node: '>=10.19.0'}
+ http_ece@1.2.0:
+ resolution: {integrity: sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==}
+ engines: {node: '>=16'}
+
https-proxy-agent@5.0.1:
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
engines: {node: '>= 6'}
+ https-proxy-agent@7.0.6:
+ resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
+ engines: {node: '>= 14'}
+
human-signals@2.1.0:
resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==}
engines: {node: '>=10.17.0'}
@@ -4368,9 +4395,15 @@ packages:
jwa@1.4.2:
resolution: {integrity: sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==}
+ jwa@2.0.1:
+ resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==}
+
jws@3.2.2:
resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==}
+ jws@4.0.0:
+ resolution: {integrity: sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==}
+
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
@@ -4672,6 +4705,9 @@ packages:
resolution: {integrity: sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+ minimalistic-assert@1.0.1:
+ resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==}
+
minimatch@10.0.3:
resolution: {integrity: sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==}
engines: {node: 20 || >=22}
@@ -6151,6 +6187,11 @@ packages:
wcwidth@1.0.1:
resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==}
+ web-push@3.6.7:
+ resolution: {integrity: sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==}
+ engines: {node: '>= 16'}
+ hasBin: true
+
web-resource-inliner@6.0.1:
resolution: {integrity: sha512-kfqDxt5dTB1JhqsCUQVFDj0rmY+4HLwGQIsLPbyrsN9y9WV/1oFDSx3BQ4GfCv9X+jVeQ7rouTqwK53rA/7t8A==}
engines: {node: '>=10.0.0'}
@@ -8786,6 +8827,10 @@ snapshots:
'@types/validator@13.15.2': {}
+ '@types/web-push@3.6.4':
+ dependencies:
+ '@types/node': 22.16.0
+
'@types/yargs-parser@21.0.3': {}
'@types/yargs@17.0.33':
@@ -9127,6 +9172,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ agent-base@7.1.4: {}
+
ajv-formats@2.1.1(ajv@8.17.1):
optionalDependencies:
ajv: 8.17.1
@@ -9270,6 +9317,13 @@ snapshots:
asap@2.0.6: {}
+ asn1.js@5.4.1:
+ dependencies:
+ bn.js: 4.12.2
+ inherits: 2.0.4
+ minimalistic-assert: 1.0.1
+ safer-buffer: 2.1.2
+
assert-never@1.4.0:
optional: true
@@ -9397,6 +9451,8 @@ snapshots:
inherits: 2.0.4
readable-stream: 3.6.2
+ bn.js@4.12.2: {}
+
boolbase@1.0.0:
optional: true
@@ -10921,6 +10977,8 @@ snapshots:
quick-lru: 5.1.1
resolve-alpn: 1.2.1
+ http_ece@1.2.0: {}
+
https-proxy-agent@5.0.1:
dependencies:
agent-base: 6.0.2
@@ -10928,6 +10986,13 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ https-proxy-agent@7.0.6:
+ dependencies:
+ agent-base: 7.1.4
+ debug: 4.4.1
+ transitivePeerDependencies:
+ - supports-color
+
human-signals@2.1.0: {}
human-signals@5.0.0: {}
@@ -11651,11 +11716,22 @@ snapshots:
ecdsa-sig-formatter: 1.0.11
safe-buffer: 5.2.1
+ jwa@2.0.1:
+ dependencies:
+ buffer-equal-constant-time: 1.0.1
+ ecdsa-sig-formatter: 1.0.11
+ safe-buffer: 5.2.1
+
jws@3.2.2:
dependencies:
jwa: 1.4.2
safe-buffer: 5.2.1
+ jws@4.0.0:
+ dependencies:
+ jwa: 2.0.1
+ safe-buffer: 5.2.1
+
keyv@4.5.4:
dependencies:
json-buffer: 3.0.1
@@ -11928,6 +12004,8 @@ snapshots:
mimic-response@4.0.0: {}
+ minimalistic-assert@1.0.1: {}
+
minimatch@10.0.3:
dependencies:
'@isaacs/brace-expansion': 5.0.0
@@ -13716,6 +13794,16 @@ snapshots:
dependencies:
defaults: 1.0.4
+ web-push@3.6.7:
+ dependencies:
+ asn1.js: 5.4.1
+ http_ece: 1.2.0
+ https-proxy-agent: 7.0.6
+ jws: 4.0.0
+ minimist: 1.2.8
+ transitivePeerDependencies:
+ - supports-color
+
web-resource-inliner@6.0.1:
dependencies:
ansi-colors: 4.1.3
diff --git a/src/configs/push-notifications.config.ts b/src/configs/push-notifications.config.ts
new file mode 100644
index 0000000..7bfe6b8
--- /dev/null
+++ b/src/configs/push-notifications.config.ts
@@ -0,0 +1,23 @@
+import { ConfigService } from "@nestjs/config";
+import { ConfigType, registerAs } from "@nestjs/config";
+
+export const pushNotificationsConfig = registerAs("pushNotifications", () => ({
+ webPush: {
+ vapidPublicKey: process.env.VAPID_PUBLIC_KEY || "",
+ vapidPrivateKey: process.env.VAPID_PRIVATE_KEY || "",
+ vapidSubject: process.env.VAPID_SUBJECT || "mailto:admin@danakcorp.com",
+ },
+ enabled: process.env.PUSH_NOTIFICATIONS_ENABLED === "true",
+ retryAttempts: parseInt(process.env.PUSH_RETRY_ATTEMPTS || "3", 10),
+ retryDelay: parseInt(process.env.PUSH_RETRY_DELAY || "5000", 10),
+ ttl: parseInt(process.env.PUSH_TTL || "86400", 10), // 24 hours
+ urgency: process.env.PUSH_URGENCY || "normal", // low, normal, high
+}));
+
+export type PushNotificationsConfigType = ConfigType;
+
+export const pushNotificationsConfigFactory = () => ({
+ imports: [],
+ useFactory: (configService: ConfigService) => configService.get("pushNotifications"),
+ inject: [ConfigService],
+});
diff --git a/src/modules/email/services/email-monitoring.service.ts b/src/modules/email/services/email-monitoring.service.ts
index df80128..d4bfb52 100644
--- a/src/modules/email/services/email-monitoring.service.ts
+++ b/src/modules/email/services/email-monitoring.service.ts
@@ -7,6 +7,7 @@ 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 { NotificationQueue } from "../../notifications/queue/notification.queue";
import { User } from "../../users/entities/user.entity";
import { EMAIL_QUEUE_CONSTANTS } from "../constants/email-events.constant";
import { EmailGateway } from "../gateways/email.gateway";
@@ -23,6 +24,7 @@ export class EmailMonitoringService implements OnModuleInit {
private readonly emailGateway: EmailGateway,
private readonly mailboxResolver: MailboxResolverService,
private readonly mailServerService: MailServerService,
+ private readonly notificationQueue: NotificationQueue,
private readonly em: EntityManager,
) {}
@@ -219,6 +221,11 @@ export class EmailMonitoringService implements OnModuleInit {
timestamp: message.timestamp,
};
+ await this.notificationQueue.addNewEmailNotification(userId, {
+ fromEmail: message.from[0].address,
+ fromName: message.from[0].name,
+ });
+
await this.emailGateway.notifyNewEmail(userId, emailPayload);
} catch (error) {
this.logger.error(`Error processing new email message for user ${userId}:`, error);
diff --git a/src/modules/notifications/handlers/new-email.handler.ts b/src/modules/notifications/handlers/new-email.handler.ts
index 25a5ec1..3554a7a 100644
--- a/src/modules/notifications/handlers/new-email.handler.ts
+++ b/src/modules/notifications/handlers/new-email.handler.ts
@@ -2,6 +2,7 @@ import { EntityManager } from "@mikro-orm/postgresql";
import { Injectable, Logger } from "@nestjs/common";
import { BaseNotificationHandler } from "./base-notification.handler";
+import { PushNotificationHandler } from "./push-notification.handler";
import { INewEmailNotificationData } from "../interfaces/ISendNotificationData";
import { NotificationsService } from "../services/notifications.service";
@@ -9,12 +10,26 @@ import { NotificationsService } from "../services/notifications.service";
export class NewEmailHandler extends BaseNotificationHandler {
protected readonly logger = new Logger(NewEmailHandler.name);
- constructor(private readonly notificationsService: NotificationsService) {
+ constructor(
+ private readonly notificationsService: NotificationsService,
+ private readonly pushNotificationHandler: PushNotificationHandler,
+ ) {
super();
}
protected async handle(recipientId: string, data: INewEmailNotificationData, em: EntityManager): Promise {
- await this.notificationsService.createNewEmailNotification(recipientId, data, em);
+ try {
+ // Create in-app notification
+ await this.notificationsService.createNewEmailNotification(recipientId, data, em);
+
+ // Also send push notification
+ await this.pushNotificationHandler.processWithTransaction(recipientId, data, em);
+
+ this.logger.log(`New email notifications (in-app + push) processed for user: ${recipientId}`);
+ } catch (error) {
+ this.logger.error(`Error processing new email notifications for user ${recipientId}:`, error);
+ throw error;
+ }
}
protected getHandlerName(): string {
diff --git a/src/modules/notifications/handlers/push-notification.handler.ts b/src/modules/notifications/handlers/push-notification.handler.ts
new file mode 100644
index 0000000..ee519ba
--- /dev/null
+++ b/src/modules/notifications/handlers/push-notification.handler.ts
@@ -0,0 +1,75 @@
+import { EntityManager } from "@mikro-orm/postgresql";
+import { Injectable, Logger } from "@nestjs/common";
+
+import { BaseNotificationHandler } from "./base-notification.handler";
+import { User } from "../../users/entities/user.entity";
+import { INewEmailNotificationData } from "../interfaces/ISendNotificationData";
+import { PushNotificationService, WebPushSubscription } from "../services/push-notification.service";
+
+@Injectable()
+export class PushNotificationHandler extends BaseNotificationHandler {
+ protected readonly logger = new Logger(PushNotificationHandler.name);
+
+ constructor(private readonly pushNotificationService: PushNotificationService) {
+ super();
+ }
+
+ protected async handle(recipientId: string, data: INewEmailNotificationData, em: EntityManager): Promise {
+ try {
+ // Get user with push notification settings
+ const user = await em.findOne(User, {
+ id: recipientId,
+ deletedAt: null,
+ });
+
+ if (!user) {
+ this.logger.warn(`User not found: ${recipientId}`);
+ return;
+ }
+
+ // Check if push notifications are enabled for this user
+ if (!user.pushNotificationsEnabled || !user.pushTokens || user.pushTokens.length === 0) {
+ this.logger.debug(`Push notifications not enabled or no subscriptions for user: ${recipientId}`);
+ return;
+ }
+
+ // Check if push notification service is enabled
+ if (!this.pushNotificationService.isEnabled()) {
+ this.logger.debug("Push notification service is disabled");
+ return;
+ }
+
+ // Convert stored subscriptions to WebPushSubscription format
+ const subscriptions: WebPushSubscription[] = user.pushTokens.map((sub) => ({
+ endpoint: sub.endpoint,
+ keys: sub.keys,
+ }));
+
+ // Send push notification using the data from INewEmailNotificationData
+ const success = await this.pushNotificationService.sendNewEmailNotification(
+ subscriptions,
+ "New Email", // Default subject - can be enhanced with more email data
+ data.fromName || "Unknown Sender",
+ data.fromEmail,
+ user.id,
+ );
+
+ if (success) {
+ // Update last push notification timestamp
+ user.lastPushNotificationAt = new Date();
+ await em.flush();
+
+ this.logger.log(`Push notification sent successfully for user: ${recipientId}`);
+ } else {
+ this.logger.warn(`Failed to send push notification for user: ${recipientId}`);
+ }
+ } catch (error) {
+ this.logger.error(`Error in push notification handler for user ${recipientId}:`, error);
+ throw error;
+ }
+ }
+
+ protected getHandlerName(): string {
+ return "PushNotification";
+ }
+}
diff --git a/src/modules/notifications/interfaces/ISendNotificationData.ts b/src/modules/notifications/interfaces/ISendNotificationData.ts
index 24483bd..91d31ac 100755
--- a/src/modules/notifications/interfaces/ISendNotificationData.ts
+++ b/src/modules/notifications/interfaces/ISendNotificationData.ts
@@ -13,5 +13,6 @@ export interface IGenericNotificationData extends IBaseNotificationData {
}
export interface INewEmailNotificationData extends IBaseNotificationData {
- newEmail: string;
+ fromEmail: string;
+ fromName: string;
}
diff --git a/src/modules/notifications/notifications.module.ts b/src/modules/notifications/notifications.module.ts
index 1de017c..4e9de95 100755
--- a/src/modules/notifications/notifications.module.ts
+++ b/src/modules/notifications/notifications.module.ts
@@ -8,10 +8,13 @@ import { Notification } from "./entities/notification.entity";
import { ChangePasswordHandler } from "./handlers/change-password.handler";
import { NewEmailHandler } from "./handlers/new-email.handler";
import { NotificationHandlerFactory } from "./handlers/notification-handler.factory";
+import { PushNotificationHandler } from "./handlers/push-notification.handler";
import { NotificationController } from "./notifications.controller";
import { NotificationProcessor } from "./queue/notification.processor";
import { NotificationQueue } from "./queue/notification.queue";
import { NotificationsService } from "./services/notifications.service";
+import { PushNotificationService } from "./services/push-notification.service";
+import { pushNotificationsConfig } from "../../configs/push-notifications.config";
import { NotificationSetting } from "../settings/entities/notification-setting.entity";
import { SettingModule } from "../settings/settings.module";
import { UtilsModule } from "../utils/utils.module";
@@ -19,7 +22,7 @@ import { UserLoginHandler } from "./handlers/user-login.handler";
@Module({
imports: [
- ConfigModule,
+ ConfigModule.forFeature(pushNotificationsConfig),
MikroOrmModule.forFeature([Notification, NotificationSetting]),
BullModule.registerQueue({
name: NOTIFICATION.QUEUE_NAME,
@@ -41,13 +44,15 @@ import { UserLoginHandler } from "./handlers/user-login.handler";
NotificationsService,
NotificationQueue,
NotificationProcessor,
+ PushNotificationService,
// notification handlers
NotificationHandlerFactory,
UserLoginHandler,
NewEmailHandler,
ChangePasswordHandler,
+ PushNotificationHandler,
],
controllers: [NotificationController],
- exports: [NotificationsService, NotificationQueue],
+ exports: [NotificationsService, NotificationQueue, PushNotificationService],
})
export class NotificationModule {}
diff --git a/src/modules/notifications/services/push-notification.service.ts b/src/modules/notifications/services/push-notification.service.ts
new file mode 100644
index 0000000..6ba9e32
--- /dev/null
+++ b/src/modules/notifications/services/push-notification.service.ts
@@ -0,0 +1,277 @@
+import { Injectable, Logger } from "@nestjs/common";
+import { ConfigService } from "@nestjs/config";
+import * as webpush from "web-push";
+
+import { PushNotificationsConfigType } from "../../../configs/push-notifications.config";
+
+export interface WebPushSubscription {
+ endpoint: string;
+ keys: {
+ p256dh: string;
+ auth: string;
+ };
+}
+
+export interface WebPushNotificationPayload {
+ title: string;
+ body: string;
+ icon?: string;
+ badge?: string;
+ image?: string;
+ data?: any;
+ actions?: Array<{
+ action: string;
+ title: string;
+ icon?: string;
+ }>;
+ tag?: string;
+ requireInteraction?: boolean;
+ silent?: boolean;
+ vibrate?: number[];
+ timestamp?: number;
+}
+
+export interface BulkWebPushPayload extends WebPushNotificationPayload {
+ subscriptions: WebPushSubscription[];
+}
+
+@Injectable()
+export class PushNotificationService {
+ private readonly logger = new Logger(PushNotificationService.name);
+ private readonly config: PushNotificationsConfigType;
+
+ constructor(private readonly configService: ConfigService) {
+ this.config = this.configService.get("pushNotifications")!;
+
+ // Configure web-push with VAPID keys
+ if (this.config.webPush.vapidPublicKey && this.config.webPush.vapidPrivateKey) {
+ webpush.setVapidDetails(this.config.webPush.vapidSubject, this.config.webPush.vapidPublicKey, this.config.webPush.vapidPrivateKey);
+ this.logger.log("Web Push service initialized with VAPID keys");
+ } else {
+ this.logger.warn("Web Push service initialized without VAPID keys - push notifications will not work");
+ }
+ }
+
+ /**
+ * Send push notification to a single subscription
+ */
+ async sendPushNotification(subscription: WebPushSubscription, payload: WebPushNotificationPayload, userId?: string): Promise {
+ if (!this.config.enabled) {
+ this.logger.debug("Push notifications are disabled");
+ return false;
+ }
+
+ if (!this.isConfigured()) {
+ this.logger.warn("Web Push is not properly configured - missing VAPID keys");
+ return false;
+ }
+
+ try {
+ const options = {
+ TTL: this.config.ttl,
+ urgency: this.config.urgency as webpush.Urgency,
+ headers: {},
+ };
+
+ const payloadString = JSON.stringify({
+ ...payload,
+ timestamp: payload.timestamp || Date.now(),
+ });
+
+ await webpush.sendNotification(subscription, payloadString, options);
+
+ this.logger.log(`Push notification sent successfully${userId ? ` to user: ${userId}` : ""}`);
+ return true;
+ } catch (error) {
+ this.logger.error(`Failed to send push notification${userId ? ` to user ${userId}` : ""}:`, error);
+
+ // Handle specific web push errors
+ if (error instanceof Error && "statusCode" in error) {
+ const webPushError = error as any;
+ if (webPushError.statusCode === 410 || webPushError.statusCode === 404) {
+ this.logger.warn("Subscription is no longer valid - should be removed from database");
+ }
+ }
+
+ return false;
+ }
+ }
+
+ /**
+ * Send push notifications to multiple subscriptions
+ */
+ async sendBulkPushNotifications(bulkPayload: BulkWebPushPayload): Promise<{
+ successful: number;
+ failed: number;
+ invalidSubscriptions: WebPushSubscription[];
+ }> {
+ const results = {
+ successful: 0,
+ failed: 0,
+ invalidSubscriptions: [] as WebPushSubscription[],
+ };
+
+ const promises = bulkPayload.subscriptions.map(async (subscription) => {
+ try {
+ const success = await this.sendPushNotification(subscription, bulkPayload);
+ if (success) {
+ results.successful++;
+ } else {
+ results.failed++;
+ }
+ } catch (error) {
+ results.failed++;
+ if (error instanceof Error && "statusCode" in error) {
+ const webPushError = error as any;
+ if (webPushError.statusCode === 410 || webPushError.statusCode === 404) {
+ results.invalidSubscriptions.push(subscription);
+ }
+ }
+ }
+ });
+
+ await Promise.all(promises);
+
+ this.logger.log(
+ `Bulk push notifications completed. Successful: ${results.successful}, Failed: ${results.failed}, Invalid: ${results.invalidSubscriptions.length}`,
+ );
+
+ return results;
+ }
+
+ /**
+ * Send new email notification
+ */
+ async sendNewEmailNotification(
+ subscriptions: WebPushSubscription[],
+ emailSubject: string,
+ senderName: string,
+ senderEmail: string,
+ userId?: string,
+ ): Promise {
+ const truncatedSubject = emailSubject.length > 50 ? emailSubject.substring(0, 47) + "..." : emailSubject;
+
+ const payload: WebPushNotificationPayload = {
+ title: "📧 New Email",
+ body: `From: ${senderName || senderEmail}\nSubject: ${truncatedSubject}`,
+ icon: `${process.env.FRONTEND_URL || "https://mail.danakcorp.com"}/assets/email-icon.png`,
+ badge: `${process.env.FRONTEND_URL || "https://mail.danakcorp.com"}/assets/email-badge.png`,
+ tag: "new-email",
+ requireInteraction: true,
+ data: {
+ type: "new-email",
+ userId,
+ sender: senderEmail,
+ subject: emailSubject,
+ url: `${process.env.FRONTEND_URL || "https://mail.danakcorp.com"}/inbox`,
+ },
+ actions: [
+ {
+ action: "view",
+ title: "View Email",
+ icon: `${process.env.FRONTEND_URL || "https://mail.danakcorp.com"}/assets/view-icon.png`,
+ },
+ {
+ action: "dismiss",
+ title: "Dismiss",
+ },
+ ],
+ vibrate: [200, 100, 200],
+ };
+
+ if (subscriptions.length === 1) {
+ return this.sendPushNotification(subscriptions[0], payload, userId);
+ } else {
+ const results = await this.sendBulkPushNotifications({
+ ...payload,
+ subscriptions,
+ });
+ return results.successful > 0;
+ }
+ }
+
+ /**
+ * Send test notification
+ */
+ async sendTestNotification(subscriptions: WebPushSubscription[], userId?: string): Promise {
+ const payload: WebPushNotificationPayload = {
+ title: "🧪 Test Notification",
+ body: "Your web push notifications are working correctly!",
+ icon: `${process.env.FRONTEND_URL || "https://mail.danakcorp.com"}/assets/test-icon.png`,
+ tag: "test",
+ data: {
+ type: "test",
+ userId,
+ url: `${process.env.FRONTEND_URL || "https://mail.danakcorp.com"}`,
+ },
+ };
+
+ if (subscriptions.length === 1) {
+ return this.sendPushNotification(subscriptions[0], payload, userId);
+ } else {
+ const results = await this.sendBulkPushNotifications({
+ ...payload,
+ subscriptions,
+ });
+ return results.successful > 0;
+ }
+ }
+
+ /**
+ * Generate VAPID keys (for initial setup)
+ */
+ generateVAPIDKeys(): { publicKey: string; privateKey: string } {
+ const vapidKeys = webpush.generateVAPIDKeys();
+ return {
+ publicKey: vapidKeys.publicKey,
+ privateKey: vapidKeys.privateKey,
+ };
+ }
+
+ /**
+ * Get VAPID public key for client-side subscription
+ */
+ getVAPIDPublicKey(): string {
+ return this.config.webPush.vapidPublicKey;
+ }
+
+ /**
+ * Check if push notifications are enabled and configured
+ */
+ isEnabled(): boolean {
+ return this.config.enabled && this.isConfigured();
+ }
+
+ /**
+ * Check if VAPID keys are properly configured
+ */
+ isConfigured(): boolean {
+ return !!(this.config.webPush.vapidPublicKey && this.config.webPush.vapidPrivateKey);
+ }
+
+ /**
+ * Validate a web push subscription object
+ */
+ validateSubscription(subscription: any): subscription is WebPushSubscription {
+ return (
+ subscription &&
+ typeof subscription.endpoint === "string" &&
+ subscription.keys &&
+ typeof subscription.keys.p256dh === "string" &&
+ typeof subscription.keys.auth === "string"
+ );
+ }
+
+ /**
+ * Get service configuration info
+ */
+ getServiceInfo() {
+ return {
+ enabled: this.isEnabled(),
+ configured: this.isConfigured(),
+ vapidPublicKey: this.config.webPush.vapidPublicKey,
+ ttl: this.config.ttl,
+ urgency: this.config.urgency,
+ };
+ }
+}
diff --git a/src/modules/settings/enums/notif-settings.enum.ts b/src/modules/settings/enums/notif-settings.enum.ts
index 27159d2..a87a5e6 100755
--- a/src/modules/settings/enums/notif-settings.enum.ts
+++ b/src/modules/settings/enums/notif-settings.enum.ts
@@ -1,15 +1,18 @@
export enum NotifCategory {
ACCOUNT = "ACCOUNT",
+ PUSH = "PUSH",
}
export enum NotifType {
USER_LOGIN = "USER_LOGIN",
NEW_EMAIL = "NEW_EMAIL",
CHANGE_PASSWORD = "CHANGE_PASSWORD",
+ PUSH_NEW_EMAIL = "PUSH_NEW_EMAIL",
}
export const NotifDescriptions: Record = {
[NotifType.USER_LOGIN]: { fa: "ورود به ناحیه کاربری", category: NotifCategory.ACCOUNT },
[NotifType.NEW_EMAIL]: { fa: "ایمیل جدید", category: NotifCategory.ACCOUNT },
[NotifType.CHANGE_PASSWORD]: { fa: "تغییر رمز عبور", category: NotifCategory.ACCOUNT },
+ [NotifType.PUSH_NEW_EMAIL]: { fa: "اعلان موبایل ایمیل جدید", category: NotifCategory.PUSH },
};
diff --git a/src/modules/users/DTO/push-notification.dto.ts b/src/modules/users/DTO/push-notification.dto.ts
new file mode 100644
index 0000000..3ba2ef4
--- /dev/null
+++ b/src/modules/users/DTO/push-notification.dto.ts
@@ -0,0 +1,112 @@
+import { ApiProperty } from "@nestjs/swagger";
+import { Type } from "class-transformer";
+import { ArrayMaxSize, ArrayMinSize, IsArray, IsBoolean, IsNotEmpty, IsObject, IsOptional, IsString, ValidateNested } from "class-validator";
+
+export class WebPushKeysDto {
+ @ApiProperty({
+ description: "p256dh key for web push subscription",
+ example: "BNbxxx...",
+ })
+ @IsNotEmpty()
+ @IsString()
+ p256dh: string;
+
+ @ApiProperty({
+ description: "auth key for web push subscription",
+ example: "abc123...",
+ })
+ @IsNotEmpty()
+ @IsString()
+ auth: string;
+}
+
+export class WebPushSubscriptionDto {
+ @ApiProperty({
+ description: "Push subscription endpoint URL",
+ example: "https://fcm.googleapis.com/fcm/send/...",
+ })
+ @IsNotEmpty()
+ @IsString()
+ endpoint: string;
+
+ @ApiProperty({
+ description: "Subscription keys",
+ type: WebPushKeysDto,
+ })
+ @ValidateNested()
+ @Type(() => WebPushKeysDto)
+ @IsObject()
+ keys: WebPushKeysDto;
+}
+
+export class RegisterPushSubscriptionDto {
+ @ApiProperty({
+ description: "Web push subscription object",
+ type: WebPushSubscriptionDto,
+ })
+ @ValidateNested()
+ @Type(() => WebPushSubscriptionDto)
+ @IsObject()
+ subscription: WebPushSubscriptionDto;
+
+ @ApiProperty({
+ description: "Device type or platform",
+ example: "desktop",
+ required: false,
+ })
+ @IsOptional()
+ @IsString()
+ deviceType?: string;
+
+ @ApiProperty({
+ description: "Device name or identifier",
+ example: "Chrome on MacBook Pro",
+ required: false,
+ })
+ @IsOptional()
+ @IsString()
+ deviceName?: string;
+}
+
+export class UpdatePushSubscriptionsDto {
+ @ApiProperty({
+ description: "Array of web push subscriptions",
+ type: [WebPushSubscriptionDto],
+ })
+ @IsArray()
+ @ArrayMinSize(0)
+ @ArrayMaxSize(10) // Limit to 10 subscriptions per user
+ @ValidateNested({ each: true })
+ @Type(() => WebPushSubscriptionDto)
+ subscriptions: WebPushSubscriptionDto[];
+}
+
+export class UpdatePushNotificationSettingsDto {
+ @ApiProperty({
+ description: "Enable or disable push notifications",
+ example: true,
+ })
+ @IsBoolean()
+ enabled: boolean;
+}
+
+export class TestPushNotificationDto {
+ @ApiProperty({
+ description: "Test message to send",
+ example: "Test notification from Dmail",
+ required: false,
+ })
+ @IsOptional()
+ @IsString()
+ message?: string;
+}
+
+export class RemovePushSubscriptionDto {
+ @ApiProperty({
+ description: "Endpoint URL of the subscription to remove",
+ example: "https://fcm.googleapis.com/fcm/send/...",
+ })
+ @IsNotEmpty()
+ @IsString()
+ endpoint: string;
+}
diff --git a/src/modules/users/entities/user.entity.ts b/src/modules/users/entities/user.entity.ts
index b790230..528f68c 100644
--- a/src/modules/users/entities/user.entity.ts
+++ b/src/modules/users/entities/user.entity.ts
@@ -8,6 +8,18 @@ import { Domain } from "../../domains/entities/domain.entity";
import { Template } from "../../templates/entities/template.entity";
import { UserRepository } from "../repositories/user.repository";
+export interface WebPushSubscriptionData {
+ endpoint: string;
+ keys: {
+ p256dh: string;
+ auth: string;
+ };
+ deviceType?: string;
+ deviceName?: string;
+ registeredAt?: Date;
+ updatedAt?: Date;
+}
+
@Entity({ repository: () => UserRepository })
@Unique({ properties: ["userName", "domain"], name: "unique_user_name_domain", options: { where: "deleted_at is null" } })
export class User extends BaseEntity {
@@ -51,6 +63,16 @@ export class User extends BaseEntity {
@Property({ default: true, nullable: false })
isActive: boolean & Opt;
+ // Push notification fields - now stores web push subscription objects
+ @Property({ type: "json", nullable: true })
+ pushTokens?: WebPushSubscriptionData[];
+
+ @Property({ type: "boolean", default: true })
+ pushNotificationsEnabled: boolean & Opt;
+
+ @Property({ type: "timestamptz", nullable: true })
+ lastPushNotificationAt?: Date;
+
//=========================
@OneToMany(() => RefreshToken, (token) => token.user)
diff --git a/src/modules/users/services/users.service.ts b/src/modules/users/services/users.service.ts
index e5e14dd..49bb49d 100644
--- a/src/modules/users/services/users.service.ts
+++ b/src/modules/users/services/users.service.ts
@@ -1,20 +1,37 @@
import { EntityManager } from "@mikro-orm/postgresql";
-import { BadRequestException, Injectable, Logger } from "@nestjs/common";
+import { BadRequestException, Injectable, Logger, NotFoundException } from "@nestjs/common";
import { firstValueFrom } from "rxjs";
-import { BusinessMessage, DomainMessage, MailServerMessage, RoleMessage, TemplateMessage, UserMessage } from "../../../common/enums/message.enum";
+import {
+ BusinessMessage,
+ CommonMessage,
+ DomainMessage,
+ MailServerMessage,
+ RoleMessage,
+ TemplateMessage,
+ UserMessage,
+} from "../../../common/enums/message.enum";
import { QUOTA_CONSTANTS } from "../../businesses/constant";
import { Business } from "../../businesses/entities/business.entity";
import { DomainStatus } from "../../domains/enums/domain-status.enum";
import { DomainAutomationService } from "../../domains/services/domain-automation.service";
import { DomainsService } from "../../domains/services/domains.service";
+import { MailboxResolverService } from "../../email/services/mailbox-resolver.service";
import { UpdateUserDto } from "../../mail-server/DTO/update-user.dto";
import { MailServerService } from "../../mail-server/services/mail-server.service";
+import { NotificationQueue } from "../../notifications/queue/notification.queue";
+import { PushNotificationService } from "../../notifications/services/push-notification.service";
import { QuotaSyncService } from "../../quota-sync/services/quota-sync.service";
import { UserSettingsService } from "../../settings/services/user-settings.service";
import { Template } from "../../templates/entities/template.entity";
import { PasswordService } from "../../utils/services/password.service";
import { CreateEmailUserDto } from "../DTO/create-email-user.dto";
+import {
+ RegisterPushSubscriptionDto,
+ TestPushNotificationDto,
+ UpdatePushNotificationSettingsDto,
+ UpdatePushSubscriptionsDto,
+} from "../DTO/push-notification.dto";
import { UpdateEmailUserDto } from "../DTO/update-email-user.dto";
import { UpdateUserProfileDto } from "../DTO/update-user-profile.dto";
import { UserListQueryDto } from "../DTO/user-list-query.dto";
@@ -36,6 +53,9 @@ export class UsersService {
private readonly domainAutomationService: DomainAutomationService,
private readonly quotaSyncService: QuotaSyncService,
private readonly userSettingsService: UserSettingsService,
+ private readonly mailboxResolver: MailboxResolverService,
+ private readonly notificationQueue: NotificationQueue,
+ private readonly pushNotificationService: PushNotificationService,
) {}
async getUserEmailIdFromId(userId: string) {
@@ -475,4 +495,240 @@ export class UsersService {
this.logger.log(`Updated business quota for ${business.name}: used=${business.usedQuota}, remaining=${business.remainingQuota}`);
}
+
+ // Push Notification Methods
+ async registerPushSubscription(userId: string, registerPushSubscriptionDto: RegisterPushSubscriptionDto) {
+ try {
+ const user = await this.userRepository.findOne({ id: userId, deletedAt: null });
+ if (!user) {
+ throw new NotFoundException(UserMessage.USER_NOT_FOUND);
+ }
+
+ // Validate the subscription
+ if (!this.pushNotificationService.validateSubscription(registerPushSubscriptionDto.subscription)) {
+ throw new BadRequestException("Invalid push subscription format");
+ }
+
+ // Initialize pushTokens array if it doesn't exist (now stores subscriptions)
+ if (!user.pushTokens) {
+ user.pushTokens = [];
+ }
+
+ // Check if subscription endpoint already exists
+ const existingIndex = user.pushTokens.findIndex((sub: any) => sub.endpoint === registerPushSubscriptionDto.subscription.endpoint);
+
+ if (existingIndex !== -1) {
+ // Update existing subscription
+ user.pushTokens[existingIndex] = {
+ ...registerPushSubscriptionDto.subscription,
+ deviceType: registerPushSubscriptionDto.deviceType,
+ deviceName: registerPushSubscriptionDto.deviceName,
+ registeredAt: new Date(),
+ };
+ } else {
+ // Add new subscription (limit to 10 subscriptions per user)
+ if (user.pushTokens.length >= 10) {
+ // Remove oldest subscription
+ user.pushTokens.shift();
+ }
+
+ user.pushTokens.push({
+ ...registerPushSubscriptionDto.subscription,
+ deviceType: registerPushSubscriptionDto.deviceType,
+ deviceName: registerPushSubscriptionDto.deviceName,
+ registeredAt: new Date(),
+ });
+ }
+
+ await this.em.flush();
+
+ this.logger.log(`Push subscription registered for user ${userId}: ${registerPushSubscriptionDto.deviceType || "unknown device"}`);
+
+ return {
+ message: "Subscription registered successfully",
+ subscriptionCount: user.pushTokens.length,
+ endpoint: registerPushSubscriptionDto.subscription.endpoint,
+ };
+ } catch (error) {
+ this.logger.error(`Error registering push subscription for user ${userId}:`, error);
+ throw error;
+ }
+ }
+
+ async updatePushSubscriptions(userId: string, updatePushSubscriptionsDto: UpdatePushSubscriptionsDto) {
+ try {
+ const user = await this.userRepository.findOne({ id: userId, deletedAt: null });
+ if (!user) {
+ throw new NotFoundException(UserMessage.USER_NOT_FOUND);
+ }
+
+ // Validate all subscriptions
+ for (const subscription of updatePushSubscriptionsDto.subscriptions) {
+ if (!this.pushNotificationService.validateSubscription(subscription)) {
+ throw new BadRequestException(`Invalid subscription format for endpoint: ${(subscription as any).endpoint}`);
+ }
+ }
+
+ user.pushTokens = updatePushSubscriptionsDto.subscriptions.map((sub) => ({
+ ...sub,
+ updatedAt: new Date(),
+ }));
+ await this.em.flush();
+
+ this.logger.log(`Push subscriptions updated for user ${userId}: ${updatePushSubscriptionsDto.subscriptions.length} subscriptions`);
+
+ return {
+ message: CommonMessage.UPDATE_SUCCESS,
+ subscriptionCount: user.pushTokens.length,
+ };
+ } catch (error) {
+ this.logger.error(`Error updating push subscriptions for user ${userId}:`, error);
+ throw error;
+ }
+ }
+
+ async removePushSubscription(userId: string, endpoint: string) {
+ try {
+ const user = await this.userRepository.findOne({ id: userId, deletedAt: null });
+ if (!user) {
+ throw new NotFoundException(UserMessage.USER_NOT_FOUND);
+ }
+
+ if (!user.pushTokens || user.pushTokens.length === 0) {
+ throw new BadRequestException("No subscriptions found");
+ }
+
+ const subscriptions = user.pushTokens;
+ const initialCount = subscriptions.length;
+ user.pushTokens = subscriptions.filter((sub) => sub.endpoint !== endpoint);
+
+ if (user.pushTokens.length === initialCount) {
+ throw new BadRequestException("Subscription not found");
+ }
+
+ await this.em.flush();
+
+ this.logger.log(`Push subscription removed for user ${userId}`);
+
+ return {
+ message: "Subscription removed successfully",
+ subscriptionCount: user.pushTokens.length,
+ };
+ } catch (error) {
+ this.logger.error(`Error removing push subscription for user ${userId}:`, error);
+ throw error;
+ }
+ }
+
+ async updatePushNotificationSettings(userId: string, settingsDto: UpdatePushNotificationSettingsDto) {
+ try {
+ const user = await this.userRepository.findOne({ id: userId, deletedAt: null });
+ if (!user) {
+ throw new NotFoundException(UserMessage.USER_NOT_FOUND);
+ }
+
+ user.pushNotificationsEnabled = settingsDto.enabled;
+ await this.em.flush();
+
+ this.logger.log(`Push notification settings updated for user ${userId}: ${settingsDto.enabled ? "enabled" : "disabled"}`);
+
+ return {
+ message: CommonMessage.UPDATE_SUCCESS,
+ pushNotificationsEnabled: user.pushNotificationsEnabled,
+ };
+ } catch (error) {
+ this.logger.error(`Error updating push notification settings for user ${userId}:`, error);
+ throw error;
+ }
+ }
+
+ async testPushNotification(userId: string, _testDto: TestPushNotificationDto) {
+ try {
+ const user = await this.userRepository.findOne({ id: userId, deletedAt: null });
+ if (!user) {
+ throw new NotFoundException(UserMessage.USER_NOT_FOUND);
+ }
+
+ if (!user.pushNotificationsEnabled) {
+ throw new BadRequestException("Push notifications are disabled for this user");
+ }
+
+ if (!user.pushTokens || user.pushTokens.length === 0) {
+ throw new BadRequestException("No push subscriptions registered for this user");
+ }
+
+ if (!this.pushNotificationService.isEnabled()) {
+ throw new BadRequestException("Push notification service is disabled");
+ }
+
+ // Convert stored subscriptions to proper format
+ const subscriptions = user.pushTokens.map((sub) => ({
+ endpoint: sub.endpoint,
+ keys: sub.keys,
+ }));
+
+ const success = await this.pushNotificationService.sendTestNotification(subscriptions, user.id);
+
+ if (!success) {
+ throw new BadRequestException("Failed to send test notification");
+ }
+
+ this.logger.log(`Test push notification sent for user ${userId}`);
+
+ return {
+ message: "Test notification sent successfully",
+ subscriptionCount: subscriptions.length,
+ serviceInfo: this.pushNotificationService.getServiceInfo(),
+ };
+ } catch (error) {
+ this.logger.error(`Error sending test push notification for user ${userId}:`, error);
+ throw error;
+ }
+ }
+
+ async getPushNotificationInfo(userId: string) {
+ try {
+ const user = await this.userRepository.findOne({ id: userId, deletedAt: null });
+ if (!user) {
+ throw new NotFoundException(UserMessage.USER_NOT_FOUND);
+ }
+
+ return {
+ pushNotificationsEnabled: user.pushNotificationsEnabled,
+ subscriptionCount: user.pushTokens?.length || 0,
+ lastPushNotificationAt: user.lastPushNotificationAt,
+ serviceEnabled: this.pushNotificationService.isEnabled(),
+ vapidPublicKey: this.pushNotificationService.getVAPIDPublicKey(),
+ serviceInfo: this.pushNotificationService.getServiceInfo(),
+ setup: {
+ instructions: "Register your browser for push notifications using the service worker",
+ documentation: {
+ webPushAPI: "https://developer.mozilla.org/en-US/docs/Web/API/Push_API",
+ serviceWorkers: "https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API",
+ },
+ },
+ };
+ } catch (error) {
+ this.logger.error(`Error getting push notification info for user ${userId}:`, error);
+ throw error;
+ }
+ }
+
+ async getVAPIDPublicKey() {
+ try {
+ const vapidPublicKey = this.pushNotificationService.getVAPIDPublicKey();
+
+ if (!vapidPublicKey) {
+ throw new BadRequestException("VAPID public key is not configured");
+ }
+
+ return {
+ vapidPublicKey,
+ serviceInfo: this.pushNotificationService.getServiceInfo(),
+ };
+ } catch (error) {
+ this.logger.error("Error getting VAPID public key:", error);
+ throw error;
+ }
+ }
}
diff --git a/src/modules/users/users.controller.ts b/src/modules/users/users.controller.ts
index 470b616..dced7fa 100644
--- a/src/modules/users/users.controller.ts
+++ b/src/modules/users/users.controller.ts
@@ -2,6 +2,12 @@ import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseIntercepto
import { ApiOperation, ApiResponse } from "@nestjs/swagger";
import { CreateEmailUserDto } from "./DTO/create-email-user.dto";
+import {
+ RegisterPushSubscriptionDto,
+ TestPushNotificationDto,
+ UpdatePushNotificationSettingsDto,
+ UpdatePushSubscriptionsDto,
+} from "./DTO/push-notification.dto";
import { UpdateEmailUserDto } from "./DTO/update-email-user.dto";
import { UpdateUserProfileDto } from "./DTO/update-user-profile.dto";
import { UserListQueryDto } from "./DTO/user-list-query.dto";
@@ -39,6 +45,56 @@ export class UsersController {
return this.usersService.updateProfile(userId, updateMeDto);
}
+ // Push Notification Endpoints
+ @Post("push-subscriptions")
+ @ApiOperation({ summary: "Register web push subscription" })
+ @ApiResponse({ status: 201, description: "Push subscription registered successfully" })
+ registerPushSubscription(@UserDec("id") userId: string, @Body() registerPushSubscriptionDto: RegisterPushSubscriptionDto) {
+ return this.usersService.registerPushSubscription(userId, registerPushSubscriptionDto);
+ }
+
+ @Patch("push-subscriptions")
+ @ApiOperation({ summary: "Update web push subscriptions" })
+ @ApiResponse({ status: 200, description: "Push subscriptions updated successfully" })
+ updatePushSubscriptions(@UserDec("id") userId: string, @Body() updatePushSubscriptionsDto: UpdatePushSubscriptionsDto) {
+ return this.usersService.updatePushSubscriptions(userId, updatePushSubscriptionsDto);
+ }
+
+ @Delete("push-subscriptions")
+ @ApiOperation({ summary: "Remove web push subscription by endpoint" })
+ @ApiResponse({ status: 200, description: "Push subscription removed successfully" })
+ removePushSubscription(@UserDec("id") userId: string, @Body() body: { endpoint: string }) {
+ return this.usersService.removePushSubscription(userId, body.endpoint);
+ }
+
+ @Patch("push-notifications/settings")
+ @ApiOperation({ summary: "Update push notification settings" })
+ @ApiResponse({ status: 200, description: "Push notification settings updated successfully" })
+ updatePushNotificationSettings(@UserDec("id") userId: string, @Body() settingsDto: UpdatePushNotificationSettingsDto) {
+ return this.usersService.updatePushNotificationSettings(userId, settingsDto);
+ }
+
+ @Post("push-notifications/test")
+ @ApiOperation({ summary: "Test web push notification" })
+ @ApiResponse({ status: 200, description: "Test notification sent successfully" })
+ testPushNotification(@UserDec("id") userId: string, @Body() testDto: TestPushNotificationDto) {
+ return this.usersService.testPushNotification(userId, testDto);
+ }
+
+ @Get("push-notifications/info")
+ @ApiOperation({ summary: "Get web push notification info and settings" })
+ @ApiResponse({ status: 200, description: "Push notification info retrieved successfully" })
+ getPushNotificationInfo(@UserDec("id") userId: string) {
+ return this.usersService.getPushNotificationInfo(userId);
+ }
+
+ @Get("push-notifications/vapid-key")
+ @ApiOperation({ summary: "Get VAPID public key for client-side subscription" })
+ @ApiResponse({ status: 200, description: "VAPID public key retrieved successfully" })
+ getVAPIDPublicKey(@UserDec("id") _userId: string) {
+ return this.usersService.getVAPIDPublicKey();
+ }
+
@Post()
@UseInterceptors(BusinessInterceptor)
@ApiOperation({ summary: "Create a new email user" })
diff --git a/src/modules/users/users.module.ts b/src/modules/users/users.module.ts
index fb86359..81d1f6f 100644
--- a/src/modules/users/users.module.ts
+++ b/src/modules/users/users.module.ts
@@ -1,27 +1,29 @@
import { MikroOrmModule } from "@mikro-orm/nestjs";
import { Module } from "@nestjs/common";
-import { RefreshToken } from "./entities/refresh-token.entity";
-import { Role } from "./entities/role.entity";
import { User } from "./entities/user.entity";
import { UsersService } from "./services/users.service";
import { UsersController } from "./users.controller";
import { BusinessesModule } from "../businesses/businesses.module";
import { DomainsModule } from "../domains/domains.module";
+import { EmailModule } from "../email/email.module";
import { MailServerModule } from "../mail-server/mail-server.module";
+import { NotificationModule } from "../notifications/notifications.module";
import { QuotaSyncModule } from "../quota-sync/quota-sync.module";
import { SettingModule } from "../settings/settings.module";
import { UtilsModule } from "../utils/utils.module";
@Module({
imports: [
- MikroOrmModule.forFeature([User, RefreshToken, Role]),
+ MikroOrmModule.forFeature([User]),
UtilsModule,
MailServerModule,
DomainsModule,
- BusinessesModule,
QuotaSyncModule,
SettingModule,
+ BusinessesModule,
+ EmailModule,
+ NotificationModule,
],
controllers: [UsersController],
providers: [UsersService],