diff --git a/apps/backend/package-lock.json b/apps/backend/package-lock.json index a0e2804b83..53963832cd 100644 --- a/apps/backend/package-lock.json +++ b/apps/backend/package-lock.json @@ -13,6 +13,7 @@ "@apollo/server": "^5.5.0", "@apollo/subgraph": "^2.13.2", "@as-integrations/express4": "^1.1.2", + "@azure/msal-node": "^5.2.0", "@graphql-tools/utils": "^10.1.0", "@isaacs/ttlcache": "^1.4.1", "@opentelemetry/exporter-logs-otlp-http": "^0.216.0", @@ -4158,6 +4159,28 @@ "express": "^4.0.0" } }, + "node_modules/@azure/msal-common": { + "version": "16.6.0", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.6.0.tgz", + "integrity": "sha512-FemGljX0csPlBMUE5GUan7BfRn1emeMRUhHSARhqzLN6LA9nt+MgzmAQ1xVqdLm+6plVoxsq9mS5eoyKtpPSgA==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-node": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-5.2.0.tgz", + "integrity": "sha512-b/ak8XAqpnGk1N1nsyTVV0Remp48BP3QrGQZ1uCMcvg2S8X1eSXzhHQZEae2oX276Q4KFAqCUswanDtcvIKLrw==", + "license": "MIT", + "dependencies": { + "@azure/msal-common": "16.6.0", + "jsonwebtoken": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@babel/code-frame": { "version": "7.22.13", "devOptional": true, diff --git a/apps/backend/package.json b/apps/backend/package.json index cf652a373d..9555edaf07 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -36,6 +36,7 @@ "@apollo/server": "^5.5.0", "@apollo/subgraph": "^2.13.2", "@as-integrations/express4": "^1.1.2", + "@azure/msal-node": "^5.2.0", "@graphql-tools/utils": "^10.1.0", "@isaacs/ttlcache": "^1.4.1", "@opentelemetry/exporter-logs-otlp-http": "^0.216.0", diff --git a/apps/backend/src/config/dependencyConfigELI.ts b/apps/backend/src/config/dependencyConfigELI.ts index cc79b28b05..a1dea66f40 100644 --- a/apps/backend/src/config/dependencyConfigELI.ts +++ b/apps/backend/src/config/dependencyConfigELI.ts @@ -48,7 +48,7 @@ import PostgresVisitRegistrationClaimDataSource from '../datasources/postgres/Vi import PostgresWorkflowDataSource from '../datasources/postgres/WorkflowDataSource'; import { eliEmailHandler } from '../eventHandlers/email/eliEmailHandler'; import createLoggingHandler from '../eventHandlers/logging'; -import { SMTPMailService } from '../eventHandlers/MailService/SMTP/SMTPMailService'; +import { MSGraphMailService } from '../eventHandlers/MailService/MSGraph/MSGraphMailService'; import { createListenToRabbitMQHandler, createPostToRabbitMQHandler, @@ -127,7 +127,7 @@ mapClass(Tokens.DataAccessUsersAuthorization, DataAccessUsersAuthorization); mapClass(Tokens.AssetRegistrar, EAMAssetRegistrar); -mapClass(Tokens.MailService, SMTPMailService); +mapClass(Tokens.MailService, MSGraphMailService); mapValue(Tokens.FapDataColumns, FapDataColumns); mapValue(Tokens.FapDataRow, getDataRow); diff --git a/apps/backend/src/eventHandlers/MailService/MSGraph/MSGraphMailService.ts b/apps/backend/src/eventHandlers/MailService/MSGraph/MSGraphMailService.ts new file mode 100644 index 0000000000..3211f132b4 --- /dev/null +++ b/apps/backend/src/eventHandlers/MailService/MSGraph/MSGraphMailService.ts @@ -0,0 +1,196 @@ +import { existsSync, readFileSync } from 'node:fs'; + +import * as msal from '@azure/msal-node'; +import { logger } from '@user-office-software/duo-logger'; +import { NodeMailerTransportOptions } from 'email-templates'; +import * as nodemailer from 'nodemailer'; +import { SentMessageInfo, Transport } from 'nodemailer'; +import MailMessage from 'nodemailer/lib/mailer/mail-message'; + +import { TemplateMailService } from '../TemplateMailService'; + +class MSGraphTransport implements Transport { + name: string; + version: string; + + private apiUrl: string; + private authToken: msal.AuthenticationResult | null = null; + private msalClient: msal.ConfidentialClientApplication; + private tokenPromise: Promise | null = null; + + constructor() { + this.name = 'MSGraphTransport'; + this.version = '1.0.0'; + this.apiUrl = process.env.MS_GRAPH_API_URL || ''; + + this.msalClient = new msal.ConfidentialClientApplication({ + auth: { + clientId: process.env.MS_GRAPH_API_CLIENT_ID!, + clientSecret: process.env.MS_GRAPH_API_CLIENT_SECRET!, + authority: `${process.env.MS_GRAPH_API_AUTHORITY}/${process.env.MS_GRAPH_API_TENANT_ID}`, + }, + }); + } + + protected isTokenExpired(): boolean { + if (!this.authToken?.expiresOn) { + throw new Error('Invalid token: Missing expiresOn property'); + } + + return Date.now() >= this.authToken.expiresOn.getTime(); + } + + private async getAuthToken(): Promise { + // if we already have a valid token, return it + if (this.authToken && !this.isTokenExpired()) { + return this.authToken; + } + + // cache the promise to prevent multiple simultaneous token requests + if (!this.tokenPromise) { + this.tokenPromise = (async () => { + try { + this.authToken = await this.msalClient.acquireTokenByClientCredential( + { + scopes: [`${this.apiUrl}/.default`], + } + ); + + logger.logInfo('Acquired new access token for Microsoft Graph API', { + expiresOn: this.authToken?.expiresOn, + }); + } finally { + this.tokenPromise = null; + } + })(); + } + + return this.tokenPromise; + } + + public async send( + message: MailMessage, + callback: (err: Error | null, info: unknown) => void + ) { + try { + const token = await this.getAuthToken(); + + if (!token?.accessToken) { + throw new Error( + 'Failed to acquire access token for Microsoft Graph API' + ); + } + + const { + subject, + to, + from, + html, + bcc, + attachments = [], + } = message.data || {}; + + const payload = { + message: { + subject: subject || '', + body: { + contentType: 'HTML', + content: html || '', + }, + toRecipients: [ + { + emailAddress: { + address: to, + }, + }, + ], + bccRecipients: bcc + ? [ + { + emailAddress: { + address: bcc, + }, + }, + ] + : undefined, + attachments: attachments, + }, + saveToSentItems: true, + }; + + const response = await fetch( + `${this.apiUrl}/v1.0/users/${from}/sendMail`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${token.accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + } + ); + + if (!response.ok) { + logger.logError('Unable to send email to user', { + error: response.statusText + ' : ' + response.status, + }); + + throw new Error( + `Failed to send email. Status: ${response.status} - ${response.statusText}` + ); + } + + const responseData = await response.text(); + callback(null, { + envelope: { + from: from, + to: [to], + }, + messageId: '', + accepted: [], + rejected: [], + pending: [], + response: responseData, + }); + } catch (error: any) { + logger.logError('Unable to send email to user', { + error: error, + }); + + callback(error, null); + } + } +} + +export class MSGraphMailService extends TemplateMailService { + constructor() { + super(); + } + + protected createTransport(): NodeMailerTransportOptions { + return nodemailer.createTransport(new MSGraphTransport()); + } + + protected createAttachments() { + const attachments = []; + + if (process.env.EMAIL_FOOTER_IMAGE_PATH !== undefined) { + if (existsSync(process.env.EMAIL_FOOTER_IMAGE_PATH)) { + attachments.push({ + '@odata.type': '#microsoft.graph.fileAttachment', + name: 'logo.png', + contentType: 'image/png', + contentBytes: readFileSync( + process.env.EMAIL_FOOTER_IMAGE_PATH + ).toString('base64'), + }); + } else { + logger.logWarn('Email footer image path does not exist', { + path: process.env.EMAIL_FOOTER_IMAGE_PATH, + }); + } + } + + return attachments; + } +} diff --git a/apps/backend/src/eventHandlers/MailService/SMTP/SMTPMailService.spec.ts b/apps/backend/src/eventHandlers/MailService/SMTP/SMTPMailService.spec.ts index a5a1d5e707..5d6f1ae6b6 100644 --- a/apps/backend/src/eventHandlers/MailService/SMTP/SMTPMailService.spec.ts +++ b/apps/backend/src/eventHandlers/MailService/SMTP/SMTPMailService.spec.ts @@ -1,11 +1,11 @@ import EmailTemplates from 'email-templates'; import { container } from 'tsyringe'; +import { SMTPMailService } from './SMTPMailService'; import { Tokens } from '../../../config/Tokens'; import { AdminDataSource } from '../../../datasources/AdminDataSource'; import { SettingsId } from '../../../models/Settings'; import SendMailOptions from '../MailService'; -import { SMTPMailService } from './SMTPMailService'; jest.mock('email-templates'); const mockAdminDataSource = container.resolve( diff --git a/apps/backend/src/eventHandlers/MailService/SMTP/SMTPMailService.ts b/apps/backend/src/eventHandlers/MailService/SMTP/SMTPMailService.ts index 12933b258a..7f02af9f66 100644 --- a/apps/backend/src/eventHandlers/MailService/SMTP/SMTPMailService.ts +++ b/apps/backend/src/eventHandlers/MailService/SMTP/SMTPMailService.ts @@ -1,51 +1,26 @@ -import { existsSync, readFileSync } from 'node:fs'; -import path from 'path'; +import { existsSync } from 'node:fs'; import { logger } from '@user-office-software/duo-logger'; -import EmailTemplates from 'email-templates'; +import { NodeMailerTransportOptions } from 'email-templates'; import * as nodemailer from 'nodemailer'; import { Transporter } from 'nodemailer'; import SMTPPool from 'nodemailer/lib/smtp-pool'; import SMTPTransport from 'nodemailer/lib/smtp-transport'; -import pug from 'pug'; -import { container } from 'tsyringe'; -import { Tokens } from '../../../config/Tokens'; -import { AdminDataSource } from '../../../datasources/AdminDataSource'; -import { EmailTemplateDataSource } from '../../../datasources/EmailTemplateDataSource'; -import { SettingsId } from '../../../models/Settings'; -import { isProduction } from '../../../utils/helperFunctions'; -import SendMailOptions, { MailService, SendMailResults } from '../MailService'; - -export class SMTPMailService extends MailService { - private emailTemplate: EmailTemplates; - private emailTemplateDataSource: EmailTemplateDataSource; +import { TemplateMailService } from '../TemplateMailService'; +export class SMTPMailService extends TemplateMailService { constructor() { super(); - logger.logInfo('Initializing SMTPMailService', {}); - - this.emailTemplateDataSource = container.resolve( - Tokens.EmailTemplateDataSource - ); - - const attachments = []; - - if (process.env.EMAIL_FOOTER_IMAGE_PATH !== undefined) { - if (existsSync(process.env.EMAIL_FOOTER_IMAGE_PATH)) { - attachments.push({ - filename: 'logo.png', - path: process.env.EMAIL_FOOTER_IMAGE_PATH, - cid: 'logo1', - }); - } else { - logger.logWarn('Email footer image path does not exist', { - path: process.env.EMAIL_FOOTER_IMAGE_PATH, - }); - } - } + logger.logInfo('Initializing SMTPMailService', { + host: process.env.EMAIL_AUTH_HOST, + port: process.env.EMAIL_AUTH_PORT, + user: process.env.EMAIL_AUTH_USERNAME, + }); + } + protected createTransport(): NodeMailerTransportOptions { let smtpTransport: | Transporter | Transporter; @@ -66,109 +41,7 @@ export class SMTPMailService extends MailService { }); } - this.emailTemplate = new EmailTemplates({ - message: { - from: process.env.EMAIL_SENDER, - attachments, - }, - send: true, - transport: smtpTransport, - juice: true, - juiceResources: { - webResources: { - relativeTo: path.resolve(process.env.EMAIL_TEMPLATE_PATH || ''), - }, - }, - render: (view: string) => { - return new Promise((resolve, reject) => { - const lastSlashIndex = view.lastIndexOf('/'); - const templateBody = - lastSlashIndex !== -1 ? view.substring(0, lastSlashIndex) : view; - - this.emailTemplate - .juiceResources(templateBody) - .then((html) => { - resolve(html); - }) - .catch((err) => { - reject(err); - }); - }); - }, - }); - } - - private getEmailTemplatePath(type: string, template: string): string { - return path.join( - process.env.EMAIL_TEMPLATE_PATH || '', - `${template}.${type}` - ); - } - - private async compileEmailTemplate(options: SendMailOptions): Promise<{ - subject: string; - body: string; - } | null> { - if (process.env.NODE_ENV === 'test') { - return { subject: '= ``', body: '' }; - } - - const emailTemplate = await this.emailTemplateDataSource.getEmailTemplate( - +options.content.template - ); - - if (!emailTemplate) { - logger.logError('Email template not found', { - template: options.content.template, - }); - - return null; - } - - let templateBody = ''; - let templateSubject = ''; - - if (emailTemplate.useTemplateFile) { - const templateBodyPath = - this.getEmailTemplatePath('html', emailTemplate.name) + '.pug'; - const templateSubjectPath = - this.getEmailTemplatePath('subject', emailTemplate.name) + '.pug'; - - try { - templateBody = readFileSync(templateBodyPath, 'utf-8'); - templateSubject = readFileSync(templateSubjectPath, 'utf-8'); - } catch (error) { - logger.logError('Email template file not found', { - error: error, - }); - - return null; - } - } else { - templateBody = emailTemplate.body || ''; - templateSubject = emailTemplate.subject || ''; - } - - try { - let compiledSubject = ''; - let compiledBody = ''; - compiledSubject = pug.render( - templateSubject, - options.substitution_data || {} - ); - compiledBody = pug.render(templateBody, options.substitution_data || {}); - - return { - subject: compiledSubject, - body: compiledBody, - }; - } catch (error) { - logger.logError('Error compiling email template', { - error: error, - }); - - return null; - } + return smtpTransport; } private getSmtpAuthOptions() { @@ -189,101 +62,23 @@ export class SMTPMailService extends MailService { }; } - async sendMail(options: SendMailOptions) { - const adminDataSource = container.resolve( - Tokens.AdminDataSource - ); - - const bccAddress = ( - await adminDataSource.getSetting(SettingsId.SMTP_BCC_EMAIL) - )?.settingsValue; - - const emailPromises: Promise[] = []; - - const sendMailResults: SendMailResults = { - total_rejected_recipients: 0, - total_accepted_recipients: 0, - id: Math.random().toString(36).substring(7), - }; - - if (process.env.NODE_ENV === 'test') { - sendMailResults.id = 'test'; - } - - const template = await this.compileEmailTemplate(options); - - if (!template) { - logger.logError('Email template not found', { - template: options.content.template, - }); - - return { results: sendMailResults }; - } - - if (process.env.SKIP_SMTP_EMAIL_SENDING === 'true') { - logger.logInfo('Skipping email sending', { - template: options.content.template, - }); + protected createAttachments(): any[] { + const attachments = []; - return { results: sendMailResults }; + if (process.env.EMAIL_FOOTER_IMAGE_PATH !== undefined) { + if (existsSync(process.env.EMAIL_FOOTER_IMAGE_PATH)) { + attachments.push({ + filename: 'logo.png', + path: process.env.EMAIL_FOOTER_IMAGE_PATH, + cid: 'logo1', + }); + } else { + logger.logWarn('Email footer image path does not exist', { + path: process.env.EMAIL_FOOTER_IMAGE_PATH, + }); + } } - options.recipients.forEach((participant) => { - emailPromises.push( - this.emailTemplate.send({ - message: { - ...(participant.header_to - ? { - to: { - address: isProduction - ? participant.address - : process.env.SINK_EMAIL, - name: participant.header_to, - }, - bcc: bccAddress, - subject: template.subject, - html: template.body, - } - : { - to: isProduction - ? participant.address - : process.env.SINK_EMAIL, - bcc: bccAddress, - subject: template.subject, - html: template.body, - }), - }, - }) - ); - }); - - return Promise.allSettled(emailPromises).then((results) => { - results.forEach((result) => { - if (result.status === 'rejected') { - logger.logError('Unable to send email to user', { - error: result.reason, - }); - sendMailResults.total_rejected_recipients++; - } else { - sendMailResults.total_accepted_recipients++; - } - }); - - return sendMailResults.total_rejected_recipients > 0 - ? Promise.reject({ results: sendMailResults }) - : Promise.resolve({ results: sendMailResults }); - }); - } - - async getEmailTemplates() { - const emailTemplates = - await this.emailTemplateDataSource.getEmailTemplates(); - - return { - results: emailTemplates.emailTemplates.map((template) => ({ - id: template.id.toString(), - name: template.name || '', - })), - }; + return attachments; } } diff --git a/apps/backend/src/eventHandlers/MailService/TemplateMailService.ts b/apps/backend/src/eventHandlers/MailService/TemplateMailService.ts new file mode 100644 index 0000000000..05f0638faf --- /dev/null +++ b/apps/backend/src/eventHandlers/MailService/TemplateMailService.ts @@ -0,0 +1,244 @@ +import { readFileSync } from 'node:fs'; +import path from 'path'; + +import { logger } from '@user-office-software/duo-logger'; +import EmailTemplates, { NodeMailerTransportOptions } from 'email-templates'; +import pug from 'pug'; +import { container } from 'tsyringe'; + +import SendMailOptions, { MailService, SendMailResults } from './MailService'; +import { Tokens } from '../../config/Tokens'; +import { AdminDataSource } from '../../datasources/AdminDataSource'; +import { EmailTemplateDataSource } from '../../datasources/EmailTemplateDataSource'; +import { SettingsId } from '../../models/Settings'; +import { isProduction } from '../../utils/helperFunctions'; + +export abstract class TemplateMailService extends MailService { + protected emailTemplates: EmailTemplates; + protected emailTemplateDataSource: EmailTemplateDataSource; + + constructor() { + super(); + + logger.logInfo('Initializing TemplateMailService', {}); + + this.emailTemplateDataSource = container.resolve( + Tokens.EmailTemplateDataSource + ); + } + + protected abstract createAttachments(): any[]; + protected abstract createTransport(): NodeMailerTransportOptions | undefined; + + protected createEmailTemplates() { + if (this.emailTemplates) { + return; + } + + this.emailTemplates = new EmailTemplates({ + message: { + from: process.env.EMAIL_SENDER, + attachments: this.createAttachments(), + }, + send: true, + transport: this.createTransport(), + juice: true, + juiceResources: { + webResources: { + relativeTo: path.resolve(process.env.EMAIL_TEMPLATE_PATH || ''), + }, + }, + render: (view: string) => { + return new Promise((resolve, reject) => { + const lastSlashIndex = view.lastIndexOf('/'); + const templateBody = + lastSlashIndex !== -1 ? view.substring(0, lastSlashIndex) : view; + + this.emailTemplates + .juiceResources(templateBody) + .then((html) => { + resolve(html); + }) + .catch((err) => { + reject(err); + }); + }); + }, + }); + } + + protected getEmailTemplatePath(type: string, template: string): string { + return path.join( + process.env.EMAIL_TEMPLATE_PATH || '', + `${template}.${type}` + ); + } + + protected async compileTemplate(options: SendMailOptions): Promise<{ + subject: string; + body: string; + } | null> { + if (process.env.NODE_ENV === 'test') { + return { subject: '= ``', body: '' }; + } + + const template = await this.emailTemplateDataSource.getEmailTemplate( + +options.content.template + ); + + if (!template) { + logger.logError('Email template not found', { + template: options.content.template, + }); + + return null; + } + + let templateBody = ''; + let templateSubject = ''; + + if (template.useTemplateFile) { + const templateBodyPath = + this.getEmailTemplatePath('html', template.name) + '.pug'; + const templateSubjectPath = + this.getEmailTemplatePath('subject', template.name) + '.pug'; + + try { + templateBody = readFileSync(templateBodyPath, 'utf-8'); + templateSubject = readFileSync(templateSubjectPath, 'utf-8'); + } catch (error) { + logger.logError('Email template file not found', { + error: error, + }); + + return null; + } + } else { + templateBody = template.body || ''; + templateSubject = template.subject || ''; + } + + try { + let compiledSubject = ''; + let compiledBody = ''; + compiledSubject = pug.render( + templateSubject, + options.substitution_data || {} + ); + compiledBody = pug.render(templateBody, options.substitution_data || {}); + + return { + subject: compiledSubject, + body: compiledBody, + }; + } catch (error) { + logger.logError('Error compiling email template', { + error: error, + }); + + return null; + } + } + + async sendMail( + options: SendMailOptions + ): Promise<{ results: SendMailResults }> { + const adminDataSource = container.resolve( + Tokens.AdminDataSource + ); + + const bccAddress = ( + await adminDataSource.getSetting(SettingsId.SMTP_BCC_EMAIL) + )?.settingsValue; + + const emailPromises: Promise[] = []; + + const sendMailResults: SendMailResults = { + total_rejected_recipients: 0, + total_accepted_recipients: 0, + id: Math.random().toString(36).substring(7), + }; + + if (process.env.NODE_ENV === 'test') { + sendMailResults.id = 'test'; + } + + this.createEmailTemplates(); + + const template = await this.compileTemplate(options); + + if (!template) { + logger.logError('Email template not found', { + template: options.content.template, + }); + + return { results: sendMailResults }; + } + + if (process.env.SKIP_EMAIL_SENDING === 'true') { + logger.logInfo('Skipping email sending', { + template: options.content.template, + }); + + return { results: sendMailResults }; + } + + options.recipients.forEach((participant) => { + emailPromises.push( + this.emailTemplates.send({ + message: { + ...(participant.header_to + ? { + to: { + address: isProduction + ? participant.address + : process.env.SINK_EMAIL, + name: participant.header_to, + }, + bcc: bccAddress, + subject: template.subject, + html: template.body, + } + : { + to: isProduction + ? participant.address + : process.env.SINK_EMAIL, + bcc: bccAddress, + subject: template.subject, + html: template.body, + }), + }, + }) + ); + }); + + return Promise.allSettled(emailPromises).then((results) => { + results.forEach((result) => { + if (result.status === 'rejected') { + logger.logError('Unable to send email to user', { + error: result.reason, + }); + sendMailResults.total_rejected_recipients++; + } else { + sendMailResults.total_accepted_recipients++; + } + }); + + return sendMailResults.total_rejected_recipients > 0 + ? Promise.reject({ results: sendMailResults }) + : Promise.resolve({ results: sendMailResults }); + }); + } + + async getEmailTemplates() { + const emailTemplates = + await this.emailTemplateDataSource.getEmailTemplates(); + + return { + results: emailTemplates.emailTemplates.map((template) => ({ + id: template.id.toString(), + name: template.name || '', + })), + }; + } +} diff --git a/package.json b/package.json index 5dfd6ca90d..66636fd089 100644 --- a/package.json +++ b/package.json @@ -56,4 +56,4 @@ "husky": "^9.0.10", "lint-staged": "^17.0.3" } -} +} \ No newline at end of file