diff --git a/functions/.env.example b/functions/.env.example index e62bb23..c19a79d 100644 --- a/functions/.env.example +++ b/functions/.env.example @@ -1,6 +1,3 @@ -MAILGUN_API_KEY=#{MAILGUN_API_KEY}# -MAILGUN_SERVER=#{MAILGUN_SERVER}# -MAILGUN_FROM_ADDRESS=#{MAILGUN_FROM_ADDRESS}# TWILIO_ACCOUNT_SID=#{TWILIO_ACCOUNT_SID}# TWILIO_AUTH_TOKEN=#{TWILIO_AUTH_TOKEN}# TWILIO_PHONE_NUMBER=#{TWILIO_PHONE_NUMBER}# diff --git a/functions/package-lock.json b/functions/package-lock.json index 149bfc8..853a3bf 100644 --- a/functions/package-lock.json +++ b/functions/package-lock.json @@ -22,7 +22,6 @@ "jspdf": "^3.0.1", "jspdf-autotable": "^5.0.2", "long": "^5.3.2", - "mailgun.js": "^10.4.0", "node-fetch": "^2.7.0", "pdfjs-dist": "^5.0.375", "pdfmake": "^0.2.20", @@ -3277,11 +3276,6 @@ "dev": true, "peer": true }, - "node_modules/base-64": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/base-64/-/base-64-1.0.0.tgz", - "integrity": "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==" - }, "node_modules/base64-arraybuffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", @@ -6405,19 +6399,6 @@ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, - "node_modules/mailgun.js": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/mailgun.js/-/mailgun.js-10.4.0.tgz", - "integrity": "sha512-YrdaZEAJwwjXGBTfZTNQ1LM7tmkdUaz2NpZEu7+zULcG4Wrlhd7cWSNZW0bxT3bP48k5N0mZWz8C2f9gc2+Geg==", - "dependencies": { - "axios": "^1.7.4", - "base-64": "^1.0.0", - "url-join": "^4.0.1" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/make-dir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", @@ -8111,11 +8092,6 @@ "querystring": "0.2.0" } }, - "node_modules/url-join": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz", - "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==" - }, "node_modules/util": { "version": "0.12.5", "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", diff --git a/functions/package.json b/functions/package.json index 0e77a8f..b29002f 100644 --- a/functions/package.json +++ b/functions/package.json @@ -29,7 +29,6 @@ "jspdf": "^3.0.1", "jspdf-autotable": "^5.0.2", "long": "^5.3.2", - "mailgun.js": "^10.4.0", "node-fetch": "^2.7.0", "pdfjs-dist": "^5.0.375", "pdfmake": "^0.2.20", diff --git a/functions/src/payments/phonepe/webhook.ts b/functions/src/payments/phonepe/webhook.ts index 2ba5136..bf09946 100644 --- a/functions/src/payments/phonepe/webhook.ts +++ b/functions/src/payments/phonepe/webhook.ts @@ -172,6 +172,7 @@ export const phonePeWebhook = onRequest({ let paymentType = orderData.metaInfo?.paymentType || 'Gym Membership'; let trainerId = orderData.metaInfo?.trainerId; let trainerData = null; + let emailCustomer = membershipData?.fields?.['email'] || membershipData?.fields?.['Email Address']; const discountPercentage = orderData.metaInfo?.discount || 0; const hasDiscount = discountPercentage > 0; @@ -242,9 +243,9 @@ export const phonePeWebhook = onRequest({ businessName: gymName, address: gymAddress, gstNumber: userData?.gstNumber, - customerName: userData?.displayName || `${membershipData?.fields?.['first-name'] || ''} ${membershipData?.fields?.['last-name'] || ''}`.trim(), - phoneNumber: membershipData?.fields?.['phone-number'] || orderData.metaInfo?.phoneNumber || '', - email: membershipData?.fields?.['email'] || '', + customerName: userData?.displayName || `${membershipData?.fields?.['first-name'] || ''} ${membershipData?.fields?.['last-name'] || ''}`.trim() || membershipData?.fields?.['First Name'] || '', + phoneNumber: membershipData?.fields?.['phone-number'] || membershipData?.fields?.['Phone Number'] || orderData.metaInfo?.phoneNumber || '', + email: membershipData?.fields?.['email'] || membershipData?.fields?.['Email Address'] || '', planName: orderData.metaInfo?.planName || subscriptionName, amount: orderData.amount, transactionId: payload.orderId, @@ -308,7 +309,7 @@ export const phonePeWebhook = onRequest({ const formattedDate = format(new Date(), 'dd/MM/yyyy'); - if (membershipData?.fields?.['email']) { + if (emailCustomer) { logger.info(`Preparing to send invoice email to customer: ${membershipData?.fields?.['email']}`); try { const emailSubject = isFreeplan @@ -342,7 +343,7 @@ export const phonePeWebhook = onRequest({ `; await sendEmailWithAttachmentUtil( - membershipData?.fields?.['email'], + emailCustomer, emailSubject, customerEmailHtml, downloadUrl, diff --git a/functions/src/utils/emailService.ts b/functions/src/utils/emailService.ts index b7f70a8..1bb4e06 100644 --- a/functions/src/utils/emailService.ts +++ b/functions/src/utils/emailService.ts @@ -1,15 +1,135 @@ -import * as os from 'os'; -import * as path from 'path'; -import * as fs from 'fs'; -import * as https from 'https'; -import { getLogger } from "../shared/config"; -import formData from 'form-data'; -import Mailgun from 'mailgun.js'; -const { convert } = require('html-to-text'); -const mailgun = new Mailgun(formData); +import { getLogger } from "../shared/config"; +import { SESClient } from "@aws-sdk/client-ses"; +import { SendEmailCommand, SendRawEmailCommand } from "@aws-sdk/client-ses"; +import * as mime from 'mime-types'; +import axios from 'axios'; + const logger = getLogger(); +interface EmailRequest { + to: string | string[]; + subject: string; + html: string; + text?: string; + from: string; + replyTo?: string; + attachments?: Attachment[]; + fileUrl?: string; + fileName?: string; +} + +interface Attachment { + filename: string; + content: string | Buffer; // Base64 encoded string or Buffer + contentType?: string; +} + +const stripHtml = (html: string): string => { + if (!html) return ''; + return html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim(); +} + +async function sendSimpleEmail(data: EmailRequest, recipients: string[]) { + const ses = new SESClient({ + region: 'ap-south-1', + credentials: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID || '', + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || '' + } + }); + + const command = new SendEmailCommand({ + Source: data.from, + Destination: { ToAddresses: recipients }, + Message: { + Subject: { Data: data.subject }, + Body: { + Html: { Data: data.html }, + Text: { Data: data.text || stripHtml(data.html) } + } + }, + ReplyToAddresses: data.replyTo ? [data.replyTo] : undefined, + }); + + const result = await ses.send(command); + return { messageId: result.MessageId }; +} + +async function sendEmailWithAttachments(data: EmailRequest, recipients: string[]) { + const ses = new SESClient({ + region: 'ap-south-1', + credentials: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID || '', + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || '' + } + }); + + const boundary = `boundary_${Math.random().toString(16).substr(2)}`; + let rawMessage = `From: ${data.from}\n`; + rawMessage += `To: ${recipients.join(', ')}\n`; + rawMessage += `Subject: ${data.subject}\n`; + rawMessage += `MIME-Version: 1.0\n`; + rawMessage += `Content-Type: multipart/mixed; boundary="${boundary}"\n\n`; + + // Add email body (multipart/alternative) + rawMessage += `--${boundary}\n`; + rawMessage += `Content-Type: multipart/alternative; boundary="alt_${boundary}"\n\n`; + + // Text part + if (data.text) { + rawMessage += `--alt_${boundary}\n`; + rawMessage += `Content-Type: text/plain; charset=UTF-8\n\n`; + rawMessage += `${data.text}\n\n`; + } + + // HTML part + rawMessage += `--alt_${boundary}\n`; + rawMessage += `Content-Type: text/html; charset=UTF-8\n\n`; + rawMessage += `${data.html}\n\n`; + + // Close alternative part + rawMessage += `--alt_${boundary}--\n\n`; + + // Add attachments + for (const attachment of data.attachments || []) { + const contentType = attachment.contentType || + mime.lookup(attachment.filename) || + 'application/octet-stream'; + + rawMessage += `--${boundary}\n`; + rawMessage += `Content-Type: ${contentType}; name="${attachment.filename}"\n`; + rawMessage += `Content-Disposition: attachment; filename="${attachment.filename}"\n`; + rawMessage += `Content-Transfer-Encoding: base64\n\n`; + + const contentBuffer = typeof attachment.content === 'string' + ? Buffer.from(attachment.content, 'base64') + : attachment.content; + + rawMessage += contentBuffer.toString('base64') + '\n\n'; + } + + // Close message + rawMessage += `--${boundary}--`; + + const command = new SendRawEmailCommand({ + RawMessage: { Data: Buffer.from(rawMessage) } + }); + + const result = await ses.send(command); + return { messageId: result.MessageId }; +} + +async function downloadFileFromUrl(url: string): Promise { + try { + const response = await axios.get(url, { responseType: 'arraybuffer' }); + return Buffer.from(response.data); + } catch (error) { + logger.error(`Error downloading file from URL: ${error}`); + throw new Error(`Failed to download file: ${error}`); + } +} + export async function sendEmailWithAttachmentUtil( toAddress: string, subject: string, @@ -18,53 +138,58 @@ export async function sendEmailWithAttachmentUtil( fileName?: string ): Promise { try { - const tempFilePath = path.join(os.tmpdir(), fileName || 'attachment.pdf'); - await new Promise((resolve, reject) => { - const file = fs.createWriteStream(tempFilePath); - https.get(fileUrl, (res) => { - res.pipe(file); - file.on('finish', () => { - file.close(); - resolve(); + logger.info(`Sending email with attachment to: ${toAddress}`); + + // Initialize data with basic fields + const data: EmailRequest = { + to: toAddress, + html: message, + subject: subject, + text: stripHtml(message), + from: process.env.SES_FROM_EMAIL || 'support@fitlien.com', + replyTo: process.env.SES_REPLY_TO_EMAIL || 'support@fitlien.com', + attachments: [] + }; + + // Handle file URL if provided + if (fileUrl && fileName) { + logger.info(`Downloading attachment from URL: ${fileUrl}`); + try { + const fileContent = await downloadFileFromUrl(fileUrl); + + // Add the downloaded file as an attachment + data.attachments!.push({ + filename: fileName, + content: fileContent, + contentType: mime.lookup(fileName) || 'application/octet-stream' }); - }).on('error', (err) => { - fs.unlink(tempFilePath, () => {}); - reject(err); - }); - }); - - try { - const client = mailgun.client({ username: 'api', key: process.env.MAILGUN_API_KEY! }); - const options = { - wordwrap: 130, - }; - const textMessage = convert(message, options); - const fileBuffer = fs.readFileSync(tempFilePath); - const attachmentFilename = fileName || path.basename(fileUrl.split('?')[0]); - - const data = { - from: process.env.MAILGUN_FROM_ADDRESS, - to: toAddress, - subject: subject, - text: textMessage, - html: message, - attachment: { - data: fileBuffer, - filename: attachmentFilename, - contentType: 'application/pdf', - } - }; - - const result = await client.messages.create(process.env.MAILGUN_SERVER!, data); - fs.unlinkSync(tempFilePath); - logger.info('Email with attachment from URL sent successfully'); - return { success: true, result }; - } catch (e) { - logger.error(`Error while sending E-mail. Error: ${e}`); - throw e; + + logger.info(`Successfully downloaded attachment: ${fileName}`); + } catch (downloadError) { + logger.error(`Failed to download attachment: ${downloadError}`); + throw new Error(`Failed to process attachment: ${downloadError}`); + } } + + if (!data.to || !data.subject || !data.html || !data.from) { + throw new Error('Missing required email fields'); + } + + logger.info(`Sending Email '${data.subject}' to '${data.to}' from '${data.from}'`); + const recipients = Array.isArray(data.to) ? data.to : [data.to]; + + let result; + if (data.attachments && data.attachments.length > 0) { + result = await sendEmailWithAttachments(data, recipients); + } else { + result = await sendSimpleEmail(data, recipients); + } + + logger.info('Email sent successfully via SES'); + return { success: true, result }; + } catch (error) { - logger.error('Error sending email with attachment from URL:', error); + logger.error('Error sending email with attachment via SES:', error); throw error; } }