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
-
-
-
- |
- Request ID:
- |
-
- ${data.requestId}
- |
-
- ${data.requestTitle ? `
-
- |
- 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}
-
+
+ | Level ${approver.levelNumber} |
+ ${approver.name} |
+ ${statusLabel} |
+ ${approver.comments || '---'} |
+
`;
}).join('');
+
+ return `
+
+
+
+ | Level |
+ Name |
+ Status |
+ Comment |
+
+
+
+ ${rows}
+
+
+ `;
+}
+
+/**
+ * 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 `
+
+ `;
+}
+
+/**
+ * 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 `
+
+
+
+ (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;
+ }
+}