From ec04538fa1b19abbba093d332e7d7369eb99f8c3 Mon Sep 17 00:00:00 2001 From: laxman h Date: Tue, 14 Apr 2026 15:10:45 +0530 Subject: [PATCH] approval from email feature added --- package-lock.json | 180 ++++++++++++ package.json | 1 + src/config/gmail.config.ts | 14 + src/controllers/gmailWebhook.controller.ts | 53 ++++ .../approvalRequest.template.ts | 91 ++----- src/emailtemplates/helpers.ts | 132 +++++++-- .../multiApproverRequest.template.ts | 33 ++- src/emailtemplates/types.ts | 3 + .../20260413-create-incoming-emails.ts | 88 ++++++ src/models/IncomingEmail.ts | 119 ++++++++ src/routes/gmail.routes.ts | 17 ++ src/routes/index.ts | 2 + src/scripts/auto-setup.ts | 2 + src/scripts/test-email-approval.ts | 83 ++++++ src/services/emailNotification.service.ts | 2 + src/services/gmail.service.ts | 257 ++++++++++++++++++ src/utils/gmailParser.ts | 65 +++++ 17 files changed, 1043 insertions(+), 99 deletions(-) create mode 100644 src/config/gmail.config.ts create mode 100644 src/controllers/gmailWebhook.controller.ts create mode 100644 src/migrations/20260413-create-incoming-emails.ts create mode 100644 src/models/IncomingEmail.ts create mode 100644 src/routes/gmail.routes.ts create mode 100644 src/scripts/test-email-approval.ts create mode 100644 src/services/gmail.service.ts create mode 100644 src/utils/gmailParser.ts diff --git a/package-lock.json b/package-lock.json index 5c6e497..fd516b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", @@ -7823,6 +7824,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 +13018,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", diff --git a/package.json b/package.json index 6e6728b..ce44ae6 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/config/gmail.config.ts b/src/config/gmail.config.ts new file mode 100644 index 0000000..7e0bd2e --- /dev/null +++ b/src/config/gmail.config.ts @@ -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', +}; diff --git a/src/controllers/gmailWebhook.controller.ts b/src/controllers/gmailWebhook.controller.ts new file mode 100644 index 0000000..59a12e0 --- /dev/null +++ b/src/controllers/gmailWebhook.controller.ts @@ -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(); diff --git a/src/emailtemplates/approvalRequest.template.ts b/src/emailtemplates/approvalRequest.template.ts index 3b5f144..fdd8d23 100644 --- a/src/emailtemplates/approvalRequest.template.ts +++ b/src/emailtemplates/approvalRequest.template.ts @@ -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 ` @@ -25,9 +25,9 @@ export function getApprovalRequestEmail(data: ApprovalRequestData): string { ${getEmailHeader(getBrandedHeader({ - title: 'Approval Request', - ...HeaderStyles.info - }))} + title: 'Approval Request', + ...HeaderStyles.info + }))} @@ -40,67 +40,15 @@ export function getApprovalRequestEmail(data: ApprovalRequestData): string { ${data.initiatorName} has submitted a request that requires your approval.

- - - - - -
-

Request Details

- - - - - - - ${data.requestTitle ? ` - - - - - ` : ''} - - - - - - - - - - - - - - - - -
- Request ID: - - ${data.requestId} -
- Title: - - ${data.requestTitle} -
- Initiator: - - ${data.initiatorName} -
- Submitted On: - - ${data.requestDate} -
- Time: - - ${data.requestTime} -
- Request Type: - - ${data.requestType} -
-
+ +
+

Request Summary:

+ ${getRequestSummaryTable({ + priority: data.priority, + requestType: data.requestType, + purpose: data.requestDescription + })} +
${getCustomMessageSection(data.customMessage)} @@ -115,6 +63,17 @@ export function getApprovalRequestEmail(data: ApprovalRequestData): string { ${getPrioritySection(data.priority)} + + ${data.showActionButtons ? ` + +
+

Quick Actions

+

+ You can approve or reject this request directly from your email by clicking one of the buttons below. +

+ ${getEmailActionButtons(data.requestId, data.requestId)} +
+ ` : ''} diff --git a/src/emailtemplates/helpers.ts b/src/emailtemplates/helpers.ts index c3dd2ff..13fe272 100644 --- a/src/emailtemplates/helpers.ts +++ b/src/emailtemplates/helpers.ts @@ -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 = ``; - status = approver.date ? `Approved on ${approver.date}` : 'Approved'; - break; - case 'current': - icon = `${approver.levelNumber}`; - textColor = '#667eea'; - status = 'Pending (Your Turn)'; + statusLabel = 'Approved'; + statusColor = '#28a745'; break; case 'pending': - icon = `${approver.levelNumber}`; - status = 'Pending'; + statusLabel = 'Pending'; + statusColor = '#ffc107'; + break; + case 'current': + statusLabel = 'Pending (Action Required)'; + statusColor = '#007bff'; + bgColor = '#f0f7ff'; break; case 'awaiting': - icon = `${approver.levelNumber}`; - textColor = '#999999'; - status = 'Awaiting'; + statusLabel = 'Awaiting'; + statusColor = '#6c757d'; break; } - + return ` -
- ${icon} - ${approver.name} - ${status} -
+ + + + + + `; }).join(''); + + return ` +
Level ${approver.levelNumber}${approver.name}${statusLabel}${approver.comments || '---'}
+ + + + + + + + + + ${rows} + +
LevelNameStatusComment
+ `; +} + +/** + * 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 => ` + + ${field.label} + ${field.value} + + `).join(''); + + return ` + + + ${rows} + +
+ `; +} + +/** + * 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 ` +
+ + + + + +
+ + Approve via Email + + + + Reject via Email + +
+

+ (Clicking a button will open your email client to send a reply) +

+
+ `; } /** diff --git a/src/emailtemplates/multiApproverRequest.template.ts b/src/emailtemplates/multiApproverRequest.template.ts index ff564b5..1d4d266 100644 --- a/src/emailtemplates/multiApproverRequest.template.ts +++ b/src/emailtemplates/multiApproverRequest.template.ts @@ -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 - + +
+

Request Summary:

+ ${getRequestSummaryTable({ + priority: data.priority, + requestType: data.requestType, + purpose: data.requestDescription + })} +
+ +

Approval Chain:

- - - - -
- ${getApprovalChain(data.approversList)} -
+ ${getApprovalChainTable(data.approversList)}
@@ -123,6 +127,17 @@ export function getMultiApproverRequestEmail(data: MultiApproverRequestData): st Note: This request requires approval from all designated approvers. The process will continue to the next approver only after you approve.

+ + ${data.showActionButtons ? ` + +
+

Quick Actions

+

+ You can approve or reject this request directly from your email by clicking one of the buttons below. +

+ ${getEmailActionButtons(data.requestId, data.requestId)} +
+ ` : ''} diff --git a/src/emailtemplates/types.ts b/src/emailtemplates/types.ts index ca19972..c99ba20 100644 --- a/src/emailtemplates/types.ts +++ b/src/emailtemplates/types.ts @@ -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 { diff --git a/src/migrations/20260413-create-incoming-emails.ts b/src/migrations/20260413-create-incoming-emails.ts new file mode 100644 index 0000000..c16e00f --- /dev/null +++ b/src/migrations/20260413-create-incoming-emails.ts @@ -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'); +} diff --git a/src/models/IncomingEmail.ts b/src/models/IncomingEmail.ts new file mode 100644 index 0000000..c1fb776 --- /dev/null +++ b/src/models/IncomingEmail.ts @@ -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 {} + +class IncomingEmail extends Model 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 }; diff --git a/src/routes/gmail.routes.ts b/src/routes/gmail.routes.ts new file mode 100644 index 0000000..8e064a0 --- /dev/null +++ b/src/routes/gmail.routes.ts @@ -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; diff --git a/src/routes/index.ts b/src/routes/index.ts index 511e15c..c7cbfb3 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -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) diff --git a/src/scripts/auto-setup.ts b/src/scripts/auto-setup.ts index 81e024a..2f7e410 100644 --- a/src/scripts/auto-setup.ts +++ b/src/scripts/auto-setup.ts @@ -185,6 +185,7 @@ async function runMigrations(): Promise { 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 { { 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 diff --git a/src/scripts/test-email-approval.ts b/src/scripts/test-email-approval.ts new file mode 100644 index 0000000..6552213 --- /dev/null +++ b/src/scripts/test-email-approval.ts @@ -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(); diff --git a/src/services/emailNotification.service.ts b/src/services/emailNotification.service.ts index a03679e..ae7702c 100644 --- a/src/services/emailNotification.service.ts +++ b/src/services/emailNotification.service.ts @@ -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 diff --git a/src/services/gmail.service.ts b/src/services/gmail.service.ts new file mode 100644 index 0000000..3517fe1 --- /dev/null +++ b/src/services/gmail.service.ts @@ -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 " + 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(); diff --git a/src/utils/gmailParser.ts b/src/utils/gmailParser.ts new file mode 100644 index 0000000..c02441c --- /dev/null +++ b/src/utils/gmailParser.ts @@ -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; + } +}