Compare commits

...

2 Commits

Author SHA1 Message Date
bb1feb5538 new build added for dev sever 2026-04-14 15:24:50 +05:30
ec04538fa1 approval from email feature added 2026-04-14 15:10:45 +05:30
20 changed files with 1067 additions and 111 deletions

View File

@ -1 +1 @@
import{a as s}from"./index-B4PRp9Lp.js";import"./radix-vendor-CLtqm-Ae.js";import"./charts-vendor-CmYZJIYl.js";import"./utils-vendor-BTBPSQfW.js";import"./ui-vendor-DgwXkk2Y.js";import"./socket-vendor-TjCxX7sJ.js";import"./redux-vendor-tbZCm13o.js";import"./router-vendor-HW_ujxKo.js";async function m(n){return(await s.post(`/conclusions/${n}/generate`)).data.data}async function f(n,t){return(await s.post(`/conclusions/${n}/finalize`,{finalRemark:t})).data.data}async function d(n){var t;try{return(await s.get(`/conclusions/${n}`)).data.data}catch(o){if(((t=o.response)==null?void 0:t.status)===404)return null;throw o}}export{f as finalizeConclusion,m as generateConclusion,d as getConclusion};
import{a as s}from"./index-D4LuhKhf.js";import"./radix-vendor-CLtqm-Ae.js";import"./charts-vendor-CmYZJIYl.js";import"./utils-vendor-BTBPSQfW.js";import"./ui-vendor-DgwXkk2Y.js";import"./socket-vendor-TjCxX7sJ.js";import"./redux-vendor-tbZCm13o.js";import"./router-vendor-HW_ujxKo.js";async function m(n){return(await s.post(`/conclusions/${n}/generate`)).data.data}async function f(n,t){return(await s.post(`/conclusions/${n}/finalize`,{finalRemark:t})).data.data}async function d(n){var t;try{return(await s.get(`/conclusions/${n}`)).data.data}catch(o){if(((t=o.response)==null?void 0:t.status)===404)return null;throw o}}export{f as finalizeConclusion,m as generateConclusion,d as getConclusion};

File diff suppressed because one or more lines are too long

View File

@ -13,7 +13,7 @@
<!-- Preload essential fonts and icons -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<script type="module" crossorigin src="/assets/index-B4PRp9Lp.js"></script>
<script type="module" crossorigin src="/assets/index-D4LuhKhf.js"></script>
<link rel="modulepreload" crossorigin href="/assets/charts-vendor-CmYZJIYl.js">
<link rel="modulepreload" crossorigin href="/assets/radix-vendor-CLtqm-Ae.js">
<link rel="modulepreload" crossorigin href="/assets/utils-vendor-BTBPSQfW.js">

191
package-lock.json generated
View File

@ -25,6 +25,7 @@
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"fast-xml-parser": "^5.3.3",
"googleapis": "^171.4.0",
"helmet": "^8.0.0",
"ioredis": "^5.8.2",
"jsonwebtoken": "^9.0.2",
@ -62,6 +63,7 @@
"@types/node": "^22.19.1",
"@types/passport": "^1.0.16",
"@types/passport-jwt": "^4.0.1",
"@types/pdf-parse": "^1.1.5",
"@types/pg": "^8.15.6",
"@types/sanitize-html": "^2.16.0",
"@types/supertest": "^6.0.2",
@ -4083,6 +4085,16 @@
"@types/passport": "*"
}
},
"node_modules/@types/pdf-parse": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@types/pdf-parse/-/pdf-parse-1.1.5.tgz",
"integrity": "sha512-kBfrSXsloMnUJOKi25s3+hRmkycHfLK6A09eRGqF/N8BkQoPUmaCr+q8Cli5FnfohEz/rsv82zAiPz/LXtOGhA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/pg": {
"version": "8.15.6",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.6.tgz",
@ -7823,6 +7835,179 @@
"node": ">=14"
}
},
"node_modules/googleapis": {
"version": "171.4.0",
"resolved": "https://registry.npmjs.org/googleapis/-/googleapis-171.4.0.tgz",
"integrity": "sha512-xybFL2SmmUgIifgsbsRQYRdNrSAYwxWZDmkZTGjUIaRnX5jPqR8el/cEvo6rCqh7iaZx6MfEPS/lrDgZ0bymkg==",
"license": "Apache-2.0",
"dependencies": {
"google-auth-library": "^10.2.0",
"googleapis-common": "^8.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/googleapis-common": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/googleapis-common/-/googleapis-common-8.0.1.tgz",
"integrity": "sha512-eCzNACUXPb1PW5l0ULTzMHaL/ltPRADoPgjBlT8jWsTbxkCp6siv+qKJ/1ldaybCthGwsYFYallF7u9AkU4L+A==",
"license": "Apache-2.0",
"dependencies": {
"extend": "^3.0.2",
"gaxios": "^7.0.0-rc.4",
"google-auth-library": "^10.1.0",
"qs": "^6.7.0",
"url-template": "^2.0.8"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/googleapis-common/node_modules/gaxios": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz",
"integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==",
"license": "Apache-2.0",
"dependencies": {
"extend": "^3.0.2",
"https-proxy-agent": "^7.0.1",
"node-fetch": "^3.3.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/googleapis-common/node_modules/gcp-metadata": {
"version": "8.1.2",
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz",
"integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==",
"license": "Apache-2.0",
"dependencies": {
"gaxios": "^7.0.0",
"google-logging-utils": "^1.0.0",
"json-bigint": "^1.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/googleapis-common/node_modules/google-auth-library": {
"version": "10.6.2",
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz",
"integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==",
"license": "Apache-2.0",
"dependencies": {
"base64-js": "^1.3.0",
"ecdsa-sig-formatter": "^1.0.11",
"gaxios": "^7.1.4",
"gcp-metadata": "8.1.2",
"google-logging-utils": "1.1.3",
"jws": "^4.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/googleapis-common/node_modules/google-logging-utils": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz",
"integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==",
"license": "Apache-2.0",
"engines": {
"node": ">=14"
}
},
"node_modules/googleapis-common/node_modules/node-fetch": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
"license": "MIT",
"dependencies": {
"data-uri-to-buffer": "^4.0.0",
"fetch-blob": "^3.1.4",
"formdata-polyfill": "^4.0.10"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/node-fetch"
}
},
"node_modules/googleapis/node_modules/gaxios": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz",
"integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==",
"license": "Apache-2.0",
"dependencies": {
"extend": "^3.0.2",
"https-proxy-agent": "^7.0.1",
"node-fetch": "^3.3.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/googleapis/node_modules/gcp-metadata": {
"version": "8.1.2",
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz",
"integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==",
"license": "Apache-2.0",
"dependencies": {
"gaxios": "^7.0.0",
"google-logging-utils": "^1.0.0",
"json-bigint": "^1.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/googleapis/node_modules/google-auth-library": {
"version": "10.6.2",
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz",
"integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==",
"license": "Apache-2.0",
"dependencies": {
"base64-js": "^1.3.0",
"ecdsa-sig-formatter": "^1.0.11",
"gaxios": "^7.1.4",
"gcp-metadata": "8.1.2",
"google-logging-utils": "1.1.3",
"jws": "^4.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/googleapis/node_modules/google-logging-utils": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz",
"integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==",
"license": "Apache-2.0",
"engines": {
"node": ">=14"
}
},
"node_modules/googleapis/node_modules/node-fetch": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
"license": "MIT",
"dependencies": {
"data-uri-to-buffer": "^4.0.0",
"fetch-blob": "^3.1.4",
"formdata-polyfill": "^4.0.10"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/node-fetch"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@ -12844,6 +13029,12 @@
"integrity": "sha512-p4f3TTAG6ADVF3mwbXw7hGw+QJyw5CnNGvYh5fCuQQZIiuKUswqcznyV3pGDP9j0TSmC4UvRKm8kl1QsX1diiQ==",
"license": "MIT"
},
"node_modules/url-template": {
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz",
"integrity": "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==",
"license": "BSD"
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",

View File

@ -48,6 +48,7 @@
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"fast-xml-parser": "^5.3.3",
"googleapis": "^171.4.0",
"helmet": "^8.0.0",
"ioredis": "^5.8.2",
"jsonwebtoken": "^9.0.2",
@ -85,6 +86,7 @@
"@types/node": "^22.19.1",
"@types/passport": "^1.0.16",
"@types/passport-jwt": "^4.0.1",
"@types/pdf-parse": "^1.1.5",
"@types/pg": "^8.15.6",
"@types/sanitize-html": "^2.16.0",
"@types/supertest": "^6.0.2",
@ -108,4 +110,4 @@
"node": ">=22.0.0",
"npm": ">=10.0.0"
}
}
}

View File

@ -0,0 +1,14 @@
import dotenv from 'dotenv';
dotenv.config();
export const gmailConfig = {
clientId: process.env.GOOGLE_CLIENT_ID || '',
clientSecret: process.env.GOOGLE_CLIENT_SECRET || '',
redirectUri: process.env.GOOGLE_REDIRECT_URI || 'http://localhost:5000/api/v1/gmail/oauth/callback',
pubsubTopic: process.env.GMAIL_PUBSUB_TOPIC || 'gmail-notifications',
webhookBaseUrl: process.env.PUBLIC_BASE_URL || 'http://localhost:5000',
tokenPath: process.env.GMAIL_TOKEN_PATH || './credentials/gmail-tokens.json',
serviceAccountPath: process.env.GCP_KEY_FILE || './credentials/re-platform-workflow-dealer-3d5738fcc1f9.json',
impersonateEmail: process.env.APPROVAL_MAILBOX || 'approvals@royalenfield.com',
approvalMailbox: process.env.APPROVAL_MAILBOX || 'approvals@royalenfield.com',
};

View File

@ -0,0 +1,53 @@
import { Request, Response } from 'express';
import { gmailService } from '../services/gmail.service';
import logger from '../utils/logger';
export class GmailWebhookController {
/**
* Handle Pub/Sub Push Notification
*/
async handlePubSubPush(req: Request, res: Response) {
try {
// Pub/Sub messages are base64 encoded in the "message.data" field
const message = req.body.message;
if (!message || !message.data) {
logger.warn(`[GmailWebhook] Invalid Pub/Sub message received`);
return res.status(400).send('Invalid Pub/Sub message');
}
const decodedData = JSON.parse(Buffer.from(message.data, 'base64').toString());
logger.info(`[GmailWebhook] Received push notification:`, decodedData);
// Process the notification asynchronously
// We return 200 immediately to Pub/Sub to acknowledge receipt
gmailService.processNotification(decodedData).catch(err => {
logger.error(`[GmailWebhook] Error in background processing:`, err);
});
return res.status(200).send('OK');
} catch (error) {
logger.error(`[GmailWebhook] Failed to handle push:`, error);
return res.status(500).send('Internal Server Error');
}
}
/**
* Manual trigger for setup/watch
*/
async setupWatch(_req: Request, res: Response) {
try {
const data = await gmailService.setupWatch();
return res.status(200).json({
message: 'Watch setup successful',
data
});
} catch (error: any) {
return res.status(500).json({
message: 'Failed to setup watch',
error: error.message
});
}
}
}
export const gmailWebhookController = new GmailWebhookController();

View File

@ -3,11 +3,11 @@
*/
import { ApprovalRequestData } from './types';
import { getEmailFooter, getPrioritySection, getEmailHeader, HeaderStyles, getResponsiveStyles, wrapRichText, getEmailContainerStyles, getCustomMessageSection } from './helpers';
import { getEmailFooter, getPrioritySection, getRequestSummaryTable, getEmailActionButtons, getEmailHeader, HeaderStyles, getResponsiveStyles, wrapRichText, getEmailContainerStyles, getCustomMessageSection } from './helpers';
import { getBrandedHeader } from './branding.config';
export function getApprovalRequestEmail(data: ApprovalRequestData): string {
return `
return `
<!DOCTYPE html>
<html lang="en">
<head>
@ -25,9 +25,9 @@ export function getApprovalRequestEmail(data: ApprovalRequestData): string {
<table role="presentation" class="email-container" style="${getEmailContainerStyles()}" cellpadding="0" cellspacing="0">
<!-- Header -->
${getEmailHeader(getBrandedHeader({
title: 'Approval Request',
...HeaderStyles.info
}))}
title: 'Approval Request',
...HeaderStyles.info
}))}
<!-- Content -->
<tr>
@ -40,67 +40,15 @@ export function getApprovalRequestEmail(data: ApprovalRequestData): string {
<strong style="color: #333333;">${data.initiatorName}</strong> has submitted a request that requires your approval.
</p>
<!-- Request Details Box -->
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f8f9fa; border-radius: 6px; margin-bottom: 30px;" cellpadding="0" cellspacing="0">
<tr>
<td style="padding: 25px;">
<h2 style="margin: 0 0 20px; color: #333333; font-size: 18px; font-weight: 600;">Request Details</h2>
<table role="presentation" style="width: 100%; border-collapse: collapse;" cellpadding="0" cellspacing="0">
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px; width: 140px;">
<strong>Request ID:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.requestId}
</td>
</tr>
${data.requestTitle ? `
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Title:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.requestTitle}
</td>
</tr>
` : ''}
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Initiator:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.initiatorName}
</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Submitted On:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.requestDate}
</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Time:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.requestTime}
</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Request Type:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.requestType}
</td>
</tr>
</table>
</td>
</tr>
</table>
<!-- Request Summary (FR-2.2.3) -->
<div style="margin-bottom: 30px;">
<h3 style="margin: 0 0 15px; color: #333333; font-size: 16px; font-weight: 600;">Request Summary:</h3>
${getRequestSummaryTable({
priority: data.priority,
requestType: data.requestType,
purpose: data.requestDescription
})}
</div>
<!-- Custom Message Section -->
${getCustomMessageSection(data.customMessage)}
@ -115,6 +63,17 @@ export function getApprovalRequestEmail(data: ApprovalRequestData): string {
<!-- Priority Section (dynamic) -->
${getPrioritySection(data.priority)}
${data.showActionButtons ? `
<!-- QUICK ACTIONS -->
<div style="margin-bottom: 40px; padding: 25px; border: 2px dashed #dee2e6; border-radius: 8px; background-color: #ffffff;">
<h3 style="margin: 0 0 15px; color: #333333; font-size: 18px; font-weight: 700; text-align: center; text-transform: uppercase; letter-spacing: 1px;">Quick Actions</h3>
<p style="margin: 0 0 20px; color: #666666; font-size: 14px; text-align: center;">
You can approve or reject this request directly from your email by clicking one of the buttons below.
</p>
${getEmailActionButtons(data.requestId, data.requestId)}
</div>
` : ''}
<!-- View Details Button -->
<table role="presentation" style="width: 100%; border-collapse: collapse; margin-bottom: 20px;" cellpadding="0" cellspacing="0">

View File

@ -679,42 +679,126 @@ export function getPrioritySection(priority: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITIC
}
/**
* Generate approval chain visualization
* Generate approval chain as a professional table
* Fields: Level, Approver, Status, Comments
*/
export function getApprovalChain(approvers: ApprovalChainItem[]): string {
return approvers.map(approver => {
let icon = '';
let textColor = '#333333';
let status = '';
export function getApprovalChainTable(approvers: ApprovalChainItem[]): string {
if (!approvers || approvers.length === 0) return '';
const rows = approvers.map(approver => {
let statusLabel = '';
let statusColor = '#333333';
let bgColor = '#ffffff';
switch (approver.status) {
case 'approved':
icon = `<span style="display: inline-block; width: 30px; height: 30px; background-color: #28a745; color: #ffffff; text-align: center; line-height: 30px; border-radius: 50%; font-size: 14px; margin-right: 10px; font-weight: bold;">✓</span>`;
status = approver.date ? `Approved on ${approver.date}` : 'Approved';
break;
case 'current':
icon = `<span style="display: inline-block; width: 30px; height: 30px; background-color: #667eea; color: #ffffff; text-align: center; line-height: 30px; border-radius: 50%; font-size: 14px; margin-right: 10px; font-weight: bold;">${approver.levelNumber}</span>`;
textColor = '#667eea';
status = 'Pending (Your Turn)';
statusLabel = 'Approved';
statusColor = '#28a745';
break;
case 'pending':
icon = `<span style="display: inline-block; width: 30px; height: 30px; background-color: #ffc107; color: #ffffff; text-align: center; line-height: 30px; border-radius: 50%; font-size: 14px; margin-right: 10px; font-weight: bold;">${approver.levelNumber}</span>`;
status = 'Pending';
statusLabel = 'Pending';
statusColor = '#ffc107';
break;
case 'current':
statusLabel = 'Pending (Action Required)';
statusColor = '#007bff';
bgColor = '#f0f7ff';
break;
case 'awaiting':
icon = `<span style="display: inline-block; width: 30px; height: 30px; background-color: #cccccc; color: #ffffff; text-align: center; line-height: 30px; border-radius: 50%; font-size: 14px; margin-right: 10px; font-weight: bold;">${approver.levelNumber}</span>`;
textColor = '#999999';
status = 'Awaiting';
statusLabel = 'Awaiting';
statusColor = '#6c757d';
break;
}
return `
<div style="padding: 10px 0; border-bottom: 1px solid #e9ecef;">
${icon}
<strong style="color: ${textColor};">${approver.name}</strong> - ${status}
</div>
<tr style="background-color: ${bgColor}; border-bottom: 1px solid #dee2e6;">
<td style="padding: 12px 15px; font-size: 14px; color: #333333;">Level ${approver.levelNumber}</td>
<td style="padding: 12px 15px; font-size: 14px; color: #333333;"><strong>${approver.name}</strong></td>
<td style="padding: 12px 15px; font-size: 14px; color: ${statusColor}; font-weight: 600;">${statusLabel}</td>
<td style="padding: 12px 15px; font-size: 14px; color: #666666; font-style: italic;">${approver.comments || '---'}</td>
</tr>
`;
}).join('');
return `
<table role="presentation" style="width: 100%; border-collapse: collapse; margin: 15px 0; border: 1px solid #dee2e6;" cellpadding="0" cellspacing="0">
<thead>
<tr style="background-color: #f8f9fa;">
<th style="padding: 12px 15px; text-align: left; font-size: 13px; font-weight: 600; color: #495057; border-bottom: 2px solid #dee2e6;">Level</th>
<th style="padding: 12px 15px; text-align: left; font-size: 13px; font-weight: 600; color: #495057; border-bottom: 2px solid #dee2e6;">Name</th>
<th style="padding: 12px 15px; text-align: left; font-size: 13px; font-weight: 600; color: #495057; border-bottom: 2px solid #dee2e6;">Status</th>
<th style="padding: 12px 15px; text-align: left; font-size: 13px; font-weight: 600; color: #495057; border-bottom: 2px solid #dee2e6;">Comment</th>
</tr>
</thead>
<tbody>
${rows}
</tbody>
</table>
`;
}
/**
* Generate request summary table (FR-2.2.3)
*/
export function getRequestSummaryTable(data: {
priority: string;
requestType: string;
purpose?: string;
[key: string]: any;
}): string {
const fields = [
{ label: 'Urgency', value: data.priority },
{ label: 'Request Type', value: data.requestType },
{ label: 'Purpose', value: data.purpose || 'Business Requirement' }
];
const rows = fields.map(field => `
<tr>
<td style="padding: 10px 15px; background-color: #f8f9fa; border: 1px solid #dee2e6; font-weight: 600; width: 30%; font-size: 14px;">${field.label}</td>
<td style="padding: 10px 15px; border: 1px solid #dee2e6; width: 70%; font-size: 14px;">${field.value}</td>
</tr>
`).join('');
return `
<table role="presentation" style="width: 100%; border-collapse: collapse; margin: 15px 0; border: 1px solid #dee2e6;" cellpadding="0" cellspacing="0">
<tbody>
${rows}
</tbody>
</table>
`;
}
/**
* Generate interactive approval buttons for Gmail-based workflow
*/
export function getEmailActionButtons(requestId: string, requestNumber: string): string {
const approvalEmail = process.env.APPROVAL_MAILBOX || 'approvals@royalenfield.com';
// Gmail actions usually work better with clear mailto links if not using AMP
const approveMailto = `mailto:${approvalEmail}?subject=RE: [${requestNumber}] APPROVE&body=I approve this request.%0A%0AComments: `;
const rejectMailto = `mailto:${approvalEmail}?subject=RE: [${requestNumber}] REJECT&body=I reject this request.%0A%0AReason: `;
return `
<div style="margin: 30px 0; text-align: center;">
<table role="presentation" style="margin: 0 auto;" cellpadding="0" cellspacing="0">
<tr>
<td style="padding: 0 10px;">
<a href="${approveMailto}" style="display: inline-block; padding: 12px 30px; background-color: #28a745; color: #ffffff; text-decoration: none; border-radius: 4px; font-weight: 600; font-size: 15px;">
Approve via Email
</a>
</td>
<td style="padding: 0 10px;">
<a href="${rejectMailto}" style="display: inline-block; padding: 12px 30px; background-color: #dc3545; color: #ffffff; text-decoration: none; border-radius: 4px; font-weight: 600; font-size: 15px;">
Reject via Email
</a>
</td>
</tr>
</table>
<p style="margin-top: 15px; font-size: 13px; color: #666666;">
(Clicking a button will open your email client to send a reply)
</p>
</div>
`;
}
/**

View File

@ -3,7 +3,7 @@
*/
import { MultiApproverRequestData } from './types';
import { getEmailFooter, getPrioritySection, getApprovalChain, getEmailHeader, HeaderStyles, wrapRichText, getResponsiveStyles, getEmailContainerStyles } from './helpers';
import { getEmailFooter, getPrioritySection, getApprovalChainTable, getRequestSummaryTable, getEmailActionButtons, getEmailHeader, HeaderStyles, wrapRichText, getResponsiveStyles, getEmailContainerStyles } from './helpers';
import { getBrandedHeader } from './branding.config';
export function getMultiApproverRequestEmail(data: MultiApproverRequestData): string {
@ -94,16 +94,20 @@ export function getMultiApproverRequestEmail(data: MultiApproverRequestData): st
</tr>
</table>
<!-- Approval Chain -->
<!-- Request Summary (FR-2.2.3) -->
<div style="margin-bottom: 30px;">
<h3 style="margin: 0 0 15px; color: #333333; font-size: 16px; font-weight: 600;">Request Summary:</h3>
${getRequestSummaryTable({
priority: data.priority,
requestType: data.requestType,
purpose: data.requestDescription
})}
</div>
<!-- Approval Chain Table -->
<div style="margin-bottom: 30px;">
<h3 style="margin: 0 0 15px; color: #333333; font-size: 16px; font-weight: 600;">Approval Chain:</h3>
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f8f9fa; border-radius: 6px;" cellpadding="0" cellspacing="0">
<tr>
<td style="padding: 20px;">
${getApprovalChain(data.approversList)}
</td>
</tr>
</table>
${getApprovalChainTable(data.approversList)}
</div>
<!-- Description (supports rich text HTML including tables) -->
@ -123,6 +127,17 @@ export function getMultiApproverRequestEmail(data: MultiApproverRequestData): st
<strong>Note:</strong> This request requires approval from all designated approvers. The process will continue to the next approver only after you approve.
</p>
</div>
${data.showActionButtons ? `
<!-- QUICK ACTIONS -->
<div style="margin-bottom: 40px; padding: 25px; border: 2px dashed #dee2e6; border-radius: 8px; background-color: #ffffff;">
<h3 style="margin: 0 0 15px; color: #333333; font-size: 18px; font-weight: 700; text-align: center; text-transform: uppercase; letter-spacing: 1px;">Quick Actions</h3>
<p style="margin: 0 0 20px; color: #666666; font-size: 14px; text-align: center;">
You can approve or reject this request directly from your email by clicking one of the buttons below.
</p>
${getEmailActionButtons(data.requestId, data.requestId)}
</div>
` : ''}
<!-- View Details Button -->
<table role="presentation" style="width: 100%; border-collapse: collapse; margin-bottom: 20px;" cellpadding="0" cellspacing="0">

View File

@ -48,12 +48,14 @@ export interface ApprovalRequestData extends BaseEmailData {
requestDescription: string;
priority: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL';
customMessage?: string;
showActionButtons?: boolean;
}
export interface MultiApproverRequestData extends ApprovalRequestData {
approverLevel: number;
totalApprovers: number;
approversList: ApprovalChainItem[];
showActionButtons?: boolean;
}
export interface ApprovalChainItem {
@ -61,6 +63,7 @@ export interface ApprovalChainItem {
status: 'approved' | 'pending' | 'current' | 'awaiting';
date?: string;
levelNumber: number;
comments?: string | null;
}
export interface ApprovalConfirmationData extends BaseEmailData {

View File

@ -0,0 +1,88 @@
import { QueryInterface, DataTypes } from 'sequelize';
export async function up(queryInterface: QueryInterface) {
await queryInterface.createTable('incoming_emails', {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
message_id: {
type: DataTypes.STRING,
allowNull: false,
unique: true
},
thread_id: {
type: DataTypes.STRING,
allowNull: false
},
from: {
type: DataTypes.STRING,
allowNull: false
},
to: {
type: DataTypes.STRING,
allowNull: false
},
subject: {
type: DataTypes.STRING(500),
allowNull: false
},
body: {
type: DataTypes.TEXT,
allowNull: false
},
html_body: {
type: DataTypes.TEXT,
allowNull: true
},
received_at: {
type: DataTypes.DATE,
allowNull: false
},
processed: {
type: DataTypes.BOOLEAN,
defaultValue: false
},
action_taken: {
type: DataTypes.STRING(20),
allowNull: true
},
parsed_comments: {
type: DataTypes.TEXT,
allowNull: true
},
request_id: {
type: DataTypes.UUID,
allowNull: true,
references: {
model: 'workflow_requests',
key: 'request_id'
},
onUpdate: 'CASCADE',
onDelete: 'SET NULL'
},
error: {
type: DataTypes.TEXT,
allowNull: true
},
created_at: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW
},
updated_at: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW
}
});
await queryInterface.addIndex('incoming_emails', ['message_id']);
await queryInterface.addIndex('incoming_emails', ['request_id']);
await queryInterface.addIndex('incoming_emails', ['from']);
}
export async function down(queryInterface: QueryInterface) {
await queryInterface.dropTable('incoming_emails');
}

119
src/models/IncomingEmail.ts Normal file
View File

@ -0,0 +1,119 @@
import { DataTypes, Model, Optional } from 'sequelize';
import { sequelize } from '../config/database';
interface IncomingEmailAttributes {
id: string;
messageId: string;
threadId: string;
from: string;
to: string;
subject: string;
body: string;
htmlBody?: string;
receivedAt: Date;
processed: boolean;
actionTaken?: string; // 'APPROVE', 'REJECT', 'NONE'
parsedComments?: string | null;
requestId?: string;
error?: string;
createdAt?: Date;
updatedAt?: Date;
}
interface IncomingEmailCreationAttributes extends Optional<IncomingEmailAttributes, 'id' | 'htmlBody' | 'processed' | 'actionTaken' | 'requestId' | 'error' | 'createdAt' | 'updatedAt'> {}
class IncomingEmail extends Model<IncomingEmailAttributes, IncomingEmailCreationAttributes> implements IncomingEmailAttributes {
public id!: string;
public messageId!: string;
public threadId!: string;
public from!: string;
public to!: string;
public subject!: string;
public body!: string;
public htmlBody?: string;
public receivedAt!: Date;
public processed!: boolean;
public actionTaken?: string;
public requestId?: string;
public error?: string;
public readonly createdAt!: Date;
public readonly updatedAt!: Date;
}
IncomingEmail.init(
{
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
messageId: {
type: DataTypes.STRING,
allowNull: false,
unique: true,
field: 'message_id'
},
threadId: {
type: DataTypes.STRING,
allowNull: false,
field: 'thread_id'
},
from: {
type: DataTypes.STRING,
allowNull: false
},
to: {
type: DataTypes.STRING,
allowNull: false
},
subject: {
type: DataTypes.STRING(500),
allowNull: false
},
body: {
type: DataTypes.TEXT,
allowNull: false
},
htmlBody: {
type: DataTypes.TEXT,
allowNull: true,
field: 'html_body'
},
receivedAt: {
type: DataTypes.DATE,
allowNull: false,
field: 'received_at'
},
processed: {
type: DataTypes.BOOLEAN,
defaultValue: false
},
actionTaken: {
type: DataTypes.STRING(20),
allowNull: true,
field: 'action_taken'
},
parsedComments: {
type: DataTypes.TEXT,
allowNull: true,
field: 'parsed_comments'
},
requestId: {
type: DataTypes.UUID,
allowNull: true,
field: 'request_id'
},
error: {
type: DataTypes.TEXT,
allowNull: true
}
},
{
sequelize,
tableName: 'incoming_emails',
underscored: true,
timestamps: true
}
);
export { IncomingEmail };

View File

@ -0,0 +1,17 @@
import { Router } from 'express';
import { gmailWebhookController } from '../controllers/gmailWebhook.controller';
const router = Router();
/**
* Pub/Sub Push Webhook
* Usually: POST /api/v1/gmail/webhooks/push
*/
router.post('/webhooks/push', gmailWebhookController.handlePubSubPush);
/**
* Setup Watch (Admin only or secure it)
*/
router.post('/watch/setup', gmailWebhookController.setupWatch);
export default router;

View File

@ -35,6 +35,7 @@ import antivirusRoutes from './antivirus.routes';
import dealerExternalRoutes from './dealerExternal.routes';
import form16Routes from './form16.routes';
import hsnSacCodeRoutes from './hsnSacCode.routes';
import gmailRoutes from './gmail.routes';
const router = Router();
@ -81,6 +82,7 @@ router.use('/ai', aiLimiter, aiRoutes); // 20 re
// ── External webhooks (burst-friendly) ──
router.use('/webhooks/dms', webhookLimiter, dmsWebhookRoutes); // 100 req/15min
router.use('/gmail', gmailRoutes); // Gmail Pub/Sub & OAuth routes
// ── Dealer claims (SAP/PWC rate limiting at individual route level in dealerClaim.routes.ts) ──
router.use('/dealer-claims', generalApiLimiter, dealerClaimRoutes); // 200 req/15min (SAP routes have additional stricter limits)

View File

@ -185,6 +185,7 @@ async function runMigrations(): Promise<void> {
const m68 = require('../migrations/20260325090001-ensure-pan-number-in-26as');
const m69 = require('../migrations/20260325094500-add-user-session-and-hsn-sac-codes');
const m70 = require('../migrations/20260325175000-update-credit-notes-and-add-items');
const m71 = require('../migrations/20260413-create-incoming-emails');
const migrations = [
{ name: '2025103000-create-users', module: m0 },
@ -262,6 +263,7 @@ async function runMigrations(): Promise<void> {
{ name: '20260325090001-ensure-pan-number-in-26as', module: m68 },
{ name: '20260325094500-add-user-session-and-hsn-sac-codes', module: m69 },
{ name: '20260325175000-update-credit-notes-and-add-items', module: m70 },
{ name: '20260413-create-incoming-emails', module: m71 },
];
// Dynamically import sequelize after secrets are loaded

View File

@ -0,0 +1,83 @@
import { gmailService } from '../services/gmail.service';
import { IncomingEmail } from '../models/IncomingEmail';
import { WorkflowRequest } from '../models/WorkflowRequest';
import { User } from '../models/User';
import logger from '../utils/logger';
/**
* MOCK TEST SCRIPT
* This script simulates the receipt of an email from testuser10@royalenfield.com
* to approve a specific request.
*/
async function mockEmailApproval() {
console.log('🚀 Starting Mock Email Approval Test...');
try {
// 1. Find a sample request (or use a specific request number)
const sampleRequest = await WorkflowRequest.findOne({
where: { templateType: 'CUSTOM' },
order: [['createdAt', 'DESC']]
});
if (!sampleRequest) {
console.error('❌ No CUSTOM requests found in DB to test with.');
return;
}
const requestNumber = sampleRequest.requestNumber;
console.log(`📝 Testing with Request: ${requestNumber}`);
// 2. Find/Ensure testuser10 exists
// Change this to the actual email of user 10 in your DB
const testUserEmail = 'testuser10@royalenfield.com';
const user = await User.findOne({ where: { email: testUserEmail } });
if (!user) {
console.error(`❌ User ${testUserEmail} not found. Please check existing users:`);
const someUsers = await User.findAll({ limit: 5 });
someUsers.forEach(u => console.log(` - ${u.email}`));
return;
}
// 3. Create a MOCK Incoming Email record
const mockMessageId = `mock-msg-${Date.now()}`;
const mockBody = `
APPROVE
Comments: This is a test approval from testuser10 via the mock script.
The request looks good, please proceed with the next steps.
---
Original Message:
Request ID: ${requestNumber}
`;
console.log(`📥 Creating mock email from ${testUserEmail}...`);
const incomingEmail = await IncomingEmail.create({
messageId: mockMessageId,
threadId: 'mock-thread-123',
from: `Test User 10 <${testUserEmail}>`,
to: 'approvals@royalenfield.com',
subject: `Re: Approval Request - ${requestNumber}`,
body: mockBody,
receivedAt: new Date(),
processed: false
} as any);
// 4. Manually trigger the private "applyWorkflowAction" logic
console.log(`⚙️ Processing workflow action...`);
await (gmailService as any).applyWorkflowAction(incomingEmail, {
action: 'APPROVE',
requestNumber: requestNumber,
comments: 'This is a test approval from testuser10 via the mock script.'
});
console.log('✅ Mock Approval Completed Successfully!');
console.log('Check the WorkflowRequest status in your app (should be APPROVED or moving to next level).');
} catch (error) {
console.error('❌ Test failed:', error);
} finally {
process.exit(0);
}
}
mockEmailApproval();

View File

@ -185,6 +185,7 @@ export class EmailNotificationService {
approverLevel: approverData.levelNumber,
totalApprovers: approvalChain.length,
approversList: chainData,
showActionButtons: requestData.templateType === 'CUSTOM',
viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl),
companyName: CompanyInfo.name,
customMessage
@ -215,6 +216,7 @@ export class EmailNotificationService {
priority: requestData.priority || 'MEDIUM',
requestDate: this.formatDate(requestData.createdAt),
requestTime: this.formatTime(requestData.createdAt),
showActionButtons: requestData.templateType === 'CUSTOM',
viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl),
companyName: CompanyInfo.name,
customMessage

View File

@ -0,0 +1,257 @@
import { google } from 'googleapis';
import fs from 'fs/promises';
import path from 'path';
import { gmailConfig } from '../config/gmail.config';
import { IncomingEmail } from '../models/IncomingEmail';
import { WorkflowRequest } from '../models/WorkflowRequest';
import { ApprovalLevel } from '../models/ApprovalLevel';
import { User } from '../models/User';
import { ApprovalService } from './approval.service';
import { parseEmailAction } from '../utils/gmailParser';
import { ApprovalAction } from '../types/approval.types';
import logger from '../utils/logger';
const approvalService = new ApprovalService();
export class GmailService {
private auth: any;
private gmail: any;
constructor() {
this.initAuth();
}
private initAuth() {
try {
const keyPath = path.resolve(gmailConfig.serviceAccountPath);
// Use Service Account with Domain-Wide Delegation to impersonate the approval mailbox
this.auth = new google.auth.JWT({
keyFile: keyPath,
scopes: ['https://www.googleapis.com/auth/gmail.modify', 'https://www.googleapis.com/auth/gmail.settings.basic'],
subject: gmailConfig.impersonateEmail
});
this.gmail = google.gmail({ version: 'v1', auth: this.auth });
logger.info(`[GmailService] Initialized for ${gmailConfig.impersonateEmail}`);
} catch (error) {
logger.error(`[GmailService] Failed to initialize Google Auth:`, error);
}
}
/**
* Setup Gmail Watch for Pub/Sub notifications
*/
async setupWatch() {
try {
const res = await this.gmail.users.watch({
userId: 'me',
requestBody: {
labelIds: ['INBOX'],
topicName: `projects/${process.env.GCP_PROJECT_ID}/topics/${gmailConfig.pubsubTopic}`
}
});
logger.info(`[GmailService] Watch setup successfully:`, res.data);
return res.data;
} catch (error) {
logger.error(`[GmailService] Failed to setup watch:`, error);
throw error;
}
}
/**
* Process notification from Pub/Sub
*/
async processNotification(notificationData: any) {
try {
const { emailAddress, historyId } = notificationData;
logger.info(`[GmailService] Received notification for ${emailAddress}, historyId: ${historyId}`);
// In a production environment, you might want to store the last processed historyId
// and only fetch changes since then using users.history.list.
// For simplicity in this requirement, we'll list the latest messages.
const res = await this.gmail.users.messages.list({
userId: 'me',
maxResults: 10,
q: 'label:INBOX is:unread'
});
const messages = res.data.messages || [];
for (const msg of messages) {
await this.processMessage(msg.id);
}
} catch (error) {
logger.error(`[GmailService] Error processing notification:`, error);
}
}
/**
* Fetch and process a single message
*/
private async processMessage(messageId: string) {
try {
// 1. Check if already processed
const existing = await IncomingEmail.findOne({ where: { messageId } });
if (existing && existing.processed) return;
// 2. Fetch full message
const res = await this.gmail.users.messages.get({
userId: 'me',
id: messageId,
format: 'full'
});
const message = res.data;
const headers = message.payload?.headers || [];
const from = headers.find((h: any) => h.name?.toLowerCase() === 'from')?.value || '';
const to = headers.find((h: any) => h.name?.toLowerCase() === 'to')?.value || '';
const subject = headers.find((h: any) => h.name?.toLowerCase() === 'subject')?.value || '';
// Extract body
let body = '';
if (message.payload?.parts) {
const textPart = message.payload.parts.find((p: any) => p.mimeType === 'text/plain');
if (textPart && textPart.body?.data) {
body = Buffer.from(textPart.body.data, 'base64').toString();
}
} else if (message.payload?.body?.data) {
body = Buffer.from(message.payload.body.data, 'base64').toString();
}
logger.info(`[GmailService] Processing message ${messageId} from ${from}: ${subject}`);
// 3. Save to database
const incomingEmail = await IncomingEmail.create({
messageId,
threadId: message.threadId!,
from,
to,
subject,
body,
receivedAt: new Date(parseInt(message.internalDate!)),
processed: false
} as any);
// 4. Parse Action
const { action, requestNumber, comments } = parseEmailAction(subject, body);
if (action === 'NONE' || !requestNumber) {
logger.info(`[GmailService] No action or request number found in message ${messageId}`);
await incomingEmail.update({ processed: true, actionTaken: 'NONE' });
// Mark as read anyway?
await this.markAsRead(messageId);
return;
}
// 5. Apply Workflow Logic
await this.applyWorkflowAction(incomingEmail, { action, requestNumber, comments });
// 6. Mark message as read/processed in Gmail
await this.markAsRead(messageId);
} catch (error) {
logger.error(`[GmailService] Error processing message ${messageId}:`, error);
}
}
private async applyWorkflowAction(incomingEmail: any, parsedAction: any) {
const { action, requestNumber, comments } = parsedAction;
try {
// 1. Find Request
const request = await WorkflowRequest.findOne({ where: { requestNumber } });
if (!request) {
throw new Error(`Request ${requestNumber} not found`);
}
// 2. Resolve User by Email
// Extract email from "Name <email@domain.com>"
const emailMatch = incomingEmail.from.match(/<(.+?)>/) || [null, incomingEmail.from];
const approverEmail = emailMatch[1].trim();
const user = await User.findOne({ where: { email: approverEmail } });
if (!user) {
throw new Error(`User with email ${approverEmail} not found`);
}
// 3. Find current pending level for this user and request
const currentLevel = await ApprovalLevel.findOne({
where: {
requestId: request.requestId,
approverId: user.userId,
status: 'IN_PROGRESS' // Or PENDING if it was just assigned
}
});
if (!currentLevel) {
// Broaden search to PENDING if not yet IN_PROGRESS (some implementations delay IN_PROGRESS)
const pendingLevel = await ApprovalLevel.findOne({
where: {
requestId: request.requestId,
approverId: user.userId,
status: 'PENDING'
}
});
if (!pendingLevel) {
throw new Error(`No pending/in-progress approval step found for user ${approverEmail} on request ${requestNumber}`);
}
// Use pending level
await this.executeApproval(request, pendingLevel, action, user, comments, incomingEmail);
} else {
await this.executeApproval(request, currentLevel, action, user, comments, incomingEmail);
}
} catch (error: any) {
logger.error(`[GmailService] Workflow action failed for ${requestNumber}:`, error);
await incomingEmail.update({
processed: true,
error: error.message,
actionTaken: action
});
}
}
private async executeApproval(request: any, level: any, action: string, user: any, comments: string, incomingEmail: any) {
const approvalAction: ApprovalAction = {
action: action as any,
comments: comments || `${action} via email`,
rejectionReason: action === 'REJECT' ? (comments || 'Rejected via email') : undefined
};
logger.info(`[GmailService] Executing ${action} for ${request.requestNumber} level ${level.levelNumber} by ${user.email}`);
await approvalService.approveLevel(
level.levelId,
approvalAction,
user.userId,
{ ipAddress: 'GMAIL_WEBHOOK', userAgent: 'Gmail/PubSub' }
);
await incomingEmail.update({
processed: true,
actionTaken: action,
parsedComments: comments,
requestId: request.requestId
});
logger.info(`[GmailService] ✅ Workflow ${request.requestNumber} updated successfully via email.`);
}
private async markAsRead(messageId: string) {
try {
await this.gmail.users.messages.batchModify({
userId: 'me',
requestBody: {
ids: [messageId],
removeLabelIds: ['UNREAD']
}
});
} catch (error) {
logger.error(`[GmailService] Failed to mark message ${messageId} as read:`, error);
}
}
}
export const gmailService = new GmailService();

65
src/utils/gmailParser.ts Normal file
View File

@ -0,0 +1,65 @@
import logger from './logger';
export interface EmailAction {
action: 'APPROVE' | 'REJECT' | 'NONE';
requestNumber: string | null;
comments: string | null;
}
/**
* Parse Gmail message content to extract workflow actions
*/
export function parseEmailAction(subject: string, body: string): EmailAction {
const result: EmailAction = {
action: 'NONE',
requestNumber: null,
comments: null
};
try {
// 1. Extract Request Number from Subject
// Format expected: RE: [REQ-20260413-001] APPROVE
const reqMatch = subject.match(/\[(REQ-.*?)\]/i);
if (reqMatch) {
result.requestNumber = reqMatch[1].toUpperCase();
} else {
// Try body if not in subject
const bodyReqMatch = body.match(/Request ID:\s*(REQ-[^\s\r\n]*)/i);
if (bodyReqMatch) {
result.requestNumber = bodyReqMatch[1].toUpperCase();
}
}
// 2. Extract Action from Subject or Body
const actionMatch = subject.match(/\b(APPROVE|REJECT)\b/i);
const bodyActionMatch = body.match(/^\s*(APPROVE|REJECT)\b/im); // First line action
if (actionMatch) {
result.action = actionMatch[1].toUpperCase() as any;
} else if (bodyActionMatch) {
result.action = bodyActionMatch[1].toUpperCase() as any;
}
// 3. Extract Comments
// Take everything after "Comments:" or "Reason:"
const commentsMatch = body.match(/(?:Comments|Reason):\s*([\s\S]*)/i);
if (commentsMatch) {
result.comments = commentsMatch[1].trim();
} else if (result.action !== 'NONE') {
// If no "Comments:" tag, take the rest of the body ignoring the action line
const lines = body.split('\n');
const actionLineIndex = lines.findIndex(l => l.toUpperCase().includes(result.action));
if (actionLineIndex !== -1) {
const remaining = lines.slice(actionLineIndex + 1).join('\n').trim();
if (remaining && !remaining.startsWith('---')) {
result.comments = remaining;
}
}
}
return result;
} catch (error) {
logger.error(`[GmailParser] Error parsing email:`, error);
return result;
}
}