diff --git a/package.json b/package.json index 1eeb5bc2..aaef5458 100755 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "license": "BUSL-1.1", "dependencies": { "@aws-sdk/client-s3": "^3.637.0", + "@aws-sdk/client-ses": "^3.1054.0", "@aws-sdk/client-textract": "^3.873.0", "@aws-sdk/s3-request-presigner": "^3.828.0", "@elasticemail/elasticemail-client": "^4.0.23", diff --git a/src/index.ts b/src/index.ts index 9a63c27d..36fe1d14 100755 --- a/src/index.ts +++ b/src/index.ts @@ -208,6 +208,9 @@ export * from './services/scheduled-jobs/scheduler.service'; export * from './jobs/rabbitmq/api-email-publisher.service' export * from './jobs/rabbitmq/api-email-queue-options' export * from './jobs/rabbitmq/api-email-subscriber.service' +export * from './jobs/rabbitmq/amazon-ses-email-publisher.service' +export * from './jobs/rabbitmq/amazon-ses-email-queue-options' +export * from './jobs/rabbitmq/amazon-ses-email-subscriber.service' export { SmtpEmailQueuePublisherRabbitmq, SmtpEmailQueuePublisherRabbitmq as EmailQueuePublisher } from './jobs/rabbitmq/smtp-email-publisher.service' // alias for backward compatibility export * from './jobs/rabbitmq/smtp-email-queue-options' export { SmtpEmailQueueSubscriberRabbitmq, SmtpEmailQueueSubscriberRabbitmq as EmailQueueSubscriber } from './jobs/rabbitmq/smtp-email-subscriber.service' // alias for backward compatibility @@ -224,6 +227,9 @@ export * from './jobs/rabbitmq/msg91-whatsapp-subscriber.service' export * from './jobs/redis/api-email-publisher-redis.service' export * from './jobs/redis/api-email-queue-options-redis' export * from './jobs/redis/api-email-subscriber-redis.service' +export * from './jobs/redis/amazon-ses-email-publisher-redis.service' +export * from './jobs/redis/amazon-ses-email-queue-options-redis' +export * from './jobs/redis/amazon-ses-email-subscriber-redis.service' export * from './jobs/redis/chatter-queue-publisher-redis.service' export * from './jobs/redis/chatter-queue-options-redis' export * from './jobs/redis/chatter-queue-subscriber-redis.service' @@ -284,6 +290,7 @@ export * from './services/textract.service' export * from './services/hashing.service' export * from './services/list-of-values.service' export * from './services/mail/elastic-email.service' +export * from './services/mail/amazon-ses.service' export * from './services/mail/smtp-email.service' export * from './services/media-storage-provider-metadata.service' export * from './services/media.service' diff --git a/src/jobs/database/amazon-ses-email-publisher-database.service.ts b/src/jobs/database/amazon-ses-email-publisher-database.service.ts new file mode 100644 index 00000000..7fff5cee --- /dev/null +++ b/src/jobs/database/amazon-ses-email-publisher-database.service.ts @@ -0,0 +1,22 @@ +import { Injectable } from "@nestjs/common"; +import { QueuesModuleOptions } from "src/interfaces"; +import { MqMessageQueueService } from "src/services/mq-message-queue.service"; +import { MqMessageService } from "src/services/mq-message.service"; +import { DatabasePublisher } from "src/services/queues/database-publisher.service"; +import mailQueueOptions from "./amazon-ses-email-queue-options-database"; + +@Injectable() +export class AmazonSesEmailQueuePublisherDatabase extends DatabasePublisher { + constructor( + protected readonly mqMessageService: MqMessageService, + protected readonly mqMessageQueueService: MqMessageQueueService, + ) { + super(mqMessageService, mqMessageQueueService); + } + + options(): QueuesModuleOptions { + return { + ...mailQueueOptions, + }; + } +} diff --git a/src/jobs/database/amazon-ses-email-queue-options-database.ts b/src/jobs/database/amazon-ses-email-queue-options-database.ts new file mode 100644 index 00000000..f690108d --- /dev/null +++ b/src/jobs/database/amazon-ses-email-queue-options-database.ts @@ -0,0 +1,9 @@ +import { BrokerType } from "src/interfaces"; + +const MAIL_QUEUE_NAME = "solid_amazon_ses_email_db_queue_v1"; + +export default { + name: MAIL_QUEUE_NAME, + type: BrokerType.Database, + queueName: MAIL_QUEUE_NAME, +}; diff --git a/src/jobs/database/amazon-ses-email-subscriber-database.service.ts b/src/jobs/database/amazon-ses-email-subscriber-database.service.ts new file mode 100644 index 00000000..aa309598 --- /dev/null +++ b/src/jobs/database/amazon-ses-email-subscriber-database.service.ts @@ -0,0 +1,31 @@ +import { Injectable } from "@nestjs/common"; +import { QueuesModuleOptions } from "src/interfaces"; +import { QueueMessage } from "src/interfaces/mq"; +import { AmazonSESService } from "src/services/mail/amazon-ses.service"; +import { MqMessageQueueService } from "src/services/mq-message-queue.service"; +import { MqMessageService } from "src/services/mq-message.service"; +import { PollerService } from "src/services/poller.service"; +import { DatabaseSubscriber } from "src/services/queues/database-subscriber.service"; +import mailQueueOptions from "./amazon-ses-email-queue-options-database"; + +@Injectable() +export class AmazonSesEmailQueueSubscriberDatabase extends DatabaseSubscriber { + constructor( + private readonly emailService: AmazonSESService, + readonly mqMessageService: MqMessageService, + readonly mqMessageQueueService: MqMessageQueueService, + readonly poller: PollerService, + ) { + super(mqMessageService, mqMessageQueueService, poller); + } + + options(): QueuesModuleOptions { + return { + ...mailQueueOptions, + }; + } + + subscribe(message: QueueMessage) { + return this.emailService.sendEmailSynchronously(message); + } +} diff --git a/src/jobs/rabbitmq/amazon-ses-email-publisher.service.ts b/src/jobs/rabbitmq/amazon-ses-email-publisher.service.ts new file mode 100644 index 00000000..80948025 --- /dev/null +++ b/src/jobs/rabbitmq/amazon-ses-email-publisher.service.ts @@ -0,0 +1,22 @@ +import { Injectable } from "@nestjs/common"; +import { MqMessageQueueService } from "src/services/mq-message-queue.service"; +import { MqMessageService } from "src/services/mq-message.service"; +import { RabbitMqPublisher } from "src/services/queues/rabbitmq-publisher.service"; +import { QueuesModuleOptions } from "src/interfaces"; +import mailQueueOptions from "./amazon-ses-email-queue-options"; + +@Injectable() +export class AmazonSesEmailQueuePublisherRabbitmq extends RabbitMqPublisher { + constructor( + protected readonly mqMessageService: MqMessageService, + protected readonly mqMessageQueueService: MqMessageQueueService, + ) { + super(mqMessageService, mqMessageQueueService); + } + + options(): QueuesModuleOptions { + return { + ...mailQueueOptions, + }; + } +} diff --git a/src/jobs/rabbitmq/amazon-ses-email-queue-options.ts b/src/jobs/rabbitmq/amazon-ses-email-queue-options.ts new file mode 100644 index 00000000..05d57366 --- /dev/null +++ b/src/jobs/rabbitmq/amazon-ses-email-queue-options.ts @@ -0,0 +1,9 @@ +import { BrokerType } from "../../interfaces"; + +const MAIL_QUEUE_NAME = "solid_amazon_ses_email_queue_v1"; + +export default { + name: MAIL_QUEUE_NAME, + type: BrokerType.RabbitMQ, + queueName: MAIL_QUEUE_NAME, +}; diff --git a/src/jobs/rabbitmq/amazon-ses-email-subscriber.service.ts b/src/jobs/rabbitmq/amazon-ses-email-subscriber.service.ts new file mode 100644 index 00000000..3594a012 --- /dev/null +++ b/src/jobs/rabbitmq/amazon-ses-email-subscriber.service.ts @@ -0,0 +1,29 @@ +import { Injectable } from "@nestjs/common"; +import { QueueMessage } from "src/interfaces/mq"; +import { MqMessageQueueService } from "src/services/mq-message-queue.service"; +import { MqMessageService } from "src/services/mq-message.service"; +import { RabbitMqSubscriber } from "src/services/queues/rabbitmq-subscriber.service"; +import { QueuesModuleOptions } from "src/interfaces"; +import { AmazonSESService } from "src/services/mail/amazon-ses.service"; +import mailQueueOptions from "./amazon-ses-email-queue-options"; + +@Injectable() +export class AmazonSesEmailQueueSubscriberRabbitmq extends RabbitMqSubscriber { + constructor( + private readonly emailService: AmazonSESService, + readonly mqMessageService: MqMessageService, + readonly mqMessageQueueService: MqMessageQueueService, + ) { + super(mqMessageService, mqMessageQueueService); + } + + options(): QueuesModuleOptions { + return { + ...mailQueueOptions, + }; + } + + subscribe(message: QueueMessage) { + return this.emailService.sendEmailSynchronously(message); + } +} diff --git a/src/jobs/redis/amazon-ses-email-publisher-redis.service.ts b/src/jobs/redis/amazon-ses-email-publisher-redis.service.ts new file mode 100644 index 00000000..b7172e87 --- /dev/null +++ b/src/jobs/redis/amazon-ses-email-publisher-redis.service.ts @@ -0,0 +1,22 @@ +import { Injectable } from "@nestjs/common"; +import { QueuesModuleOptions } from "src/interfaces"; +import { MqMessageQueueService } from "src/services/mq-message-queue.service"; +import { MqMessageService } from "src/services/mq-message.service"; +import { RedisPublisher } from "src/services/queues/redis-publisher.service"; +import mailQueueOptions from "./amazon-ses-email-queue-options-redis"; + +@Injectable() +export class AmazonSesEmailQueuePublisherRedis extends RedisPublisher { + constructor( + protected readonly mqMessageService: MqMessageService, + protected readonly mqMessageQueueService: MqMessageQueueService, + ) { + super(mqMessageService, mqMessageQueueService); + } + + options(): QueuesModuleOptions { + return { + ...mailQueueOptions, + }; + } +} diff --git a/src/jobs/redis/amazon-ses-email-queue-options-redis.ts b/src/jobs/redis/amazon-ses-email-queue-options-redis.ts new file mode 100644 index 00000000..ed446ec9 --- /dev/null +++ b/src/jobs/redis/amazon-ses-email-queue-options-redis.ts @@ -0,0 +1,9 @@ +import { BrokerType } from "../../interfaces"; + +const QUEUE_NAME = "solid_amazon_ses_email_queue_redis_v1"; + +export default { + name: QUEUE_NAME, + type: BrokerType.Redis, + queueName: QUEUE_NAME, +}; diff --git a/src/jobs/redis/amazon-ses-email-subscriber-redis.service.ts b/src/jobs/redis/amazon-ses-email-subscriber-redis.service.ts new file mode 100644 index 00000000..a4e3ac59 --- /dev/null +++ b/src/jobs/redis/amazon-ses-email-subscriber-redis.service.ts @@ -0,0 +1,29 @@ +import { Injectable } from "@nestjs/common"; +import { QueuesModuleOptions } from "src/interfaces"; +import { QueueMessage } from "src/interfaces/mq"; +import { AmazonSESService } from "src/services/mail/amazon-ses.service"; +import { MqMessageQueueService } from "src/services/mq-message-queue.service"; +import { MqMessageService } from "src/services/mq-message.service"; +import { RedisSubscriber } from "src/services/queues/redis-subscriber.service"; +import mailQueueOptions from "./amazon-ses-email-queue-options-redis"; + +@Injectable() +export class AmazonSesEmailQueueSubscriberRedis extends RedisSubscriber { + constructor( + private readonly emailService: AmazonSESService, + readonly mqMessageService: MqMessageService, + readonly mqMessageQueueService: MqMessageQueueService, + ) { + super(mqMessageService, mqMessageQueueService); + } + + options(): QueuesModuleOptions { + return { + ...mailQueueOptions, + }; + } + + subscribe(message: QueueMessage) { + return this.emailService.sendEmailSynchronously(message); + } +} diff --git a/src/services/mail/amazon-ses.service.ts b/src/services/mail/amazon-ses.service.ts new file mode 100644 index 00000000..f7d76a42 --- /dev/null +++ b/src/services/mail/amazon-ses.service.ts @@ -0,0 +1,210 @@ +import { + Content, + Destination, + Message, + SendEmailCommand, + SESClient, +} from "@aws-sdk/client-ses"; + +import { SettingService } from "../setting.service"; +import { Injectable, Logger } from "@nestjs/common"; +import { IMail, MailAttachment, MailAttachmentWrapper } from "src/interfaces"; +import { QueueMessage } from "src/interfaces/mq"; +import { MailProvider } from "src/decorators/mail-provider.decorator"; +import { EmailTemplateService } from "../email-template.service"; +import Handlebars from "handlebars"; +import { SolidCoreSetting } from "../settings/default-settings-provider.service"; +import { PublisherFactory } from "../queues/publisher-factory.service"; + +@Injectable() +@MailProvider() +export class AmazonSESService implements IMail { + private readonly logger = new Logger(AmazonSESService.name); + private getSESClient(): SESClient { + return new SESClient({ + region: + this.settingService.getConfigValue("awsSesRegion"), + + credentials: { + accessKeyId: + this.settingService.getConfigValue( + "awsSesAccessKeyId", + ), + + secretAccessKey: this.settingService.getConfigValue( + "awsSesSecretAccessKey", + ), + }, + }); + } + + constructor( + private readonly publisherFactory: PublisherFactory, + private readonly emailTemplateService: EmailTemplateService, + private readonly settingService: SettingService, + ) {} + + async sendEmailUsingTemplate( + to: string, + templateName: string, + templateParams: any, + shouldQueueEmails = false, + wrapperAttachments?: MailAttachmentWrapper[], + attachments?: MailAttachment[], + parentEntity?: any, + parentEntityId?: any, + cc?: string[], + bcc?: string[], + from?: string, + ): Promise { + const emailTemplate = + await this.emailTemplateService.findOneByName(templateName); + if (!emailTemplate) { + throw new Error(`Invalid template name ${templateName}`); + } + + const bodyTemplate = Handlebars.compile(emailTemplate.body); + const body = bodyTemplate(templateParams); + + const subjectTemplate = Handlebars.compile(emailTemplate.subject); + const subject = subjectTemplate(templateParams); + + return this.sendEmail( + to, + subject, + body, + shouldQueueEmails, + wrapperAttachments, + attachments, + parentEntity, + parentEntityId, + cc, + bcc, + from, + ); + } + + async sendEmail( + to: string, + subject: string, + body: string, + shouldQueueEmails = false, + wrapperAttachments?: MailAttachmentWrapper[], + attachments?: MailAttachment[], + _parentEntity?: any, + _parentEntityId?: any, + cc?: string[], + bcc?: string[], + from?: string, + ): Promise { + const message: QueueMessage = { + payload: { + from: + from || + this.settingService.getConfigValue("sesMailFrom") || + this.settingService.getConfigValue("smtpMailFrom"), + to, + subject, + body, + cc, + bcc, + wrapperAttachments, + attachments, + }, + parentEntity: _parentEntity, + parentEntityId: _parentEntityId, + }; + + if (shouldQueueEmails === true) { + return this.sendEmailAsynchronously(message); + } else if ( + shouldQueueEmails === false && + this.settingService.getConfigValue("shouldQueueEmails") === + true + ) { + return this.sendEmailAsynchronously(message); + } + + return this.sendEmailSynchronously(message); + } + + async sendEmailAsynchronously(message: QueueMessage): Promise { + const { to, subject } = message.payload; + this.logger.debug(`Queueing SES email to ${to} with subject ${subject}`); + return this.publisherFactory.publish(message, "AmazonSesEmailQueuePublisher"); + } + + async sendEmailSynchronously(message: QueueMessage): Promise { + const { from, to, subject, body, cc, bcc, attachments, wrapperAttachments } = + message.payload; + + if ( + (attachments && attachments.length > 0) || + (wrapperAttachments && wrapperAttachments.length > 0) + ) { + this.logger.warn( + "SES basic implementation currently ignores attachments. Email will be sent without attachments.", + ); + } + + if (!from || !to || !subject || !body) { + this.logger.error( + "Required SES email fields are missing. Ensure from/to/subject/body are present.", + ); + return; + } + + const command = new SendEmailCommand({ + Source: from, + Destination: this.buildDestination(to, cc, bcc), + Message: this.buildMessage(subject, body), + }); + + try { + const sesClient = this.getSESClient(); + + const response = await sesClient.send(command); + + this.logger.log( + `SES email sent successfully to ${to}. MessageId=${response.MessageId}`, + ); + + return response; + } catch (error) { + this.logger.error(`Failed to send SES email to ${to}`, error); + + throw error; + } + } + + private buildDestination( + to: string, + cc?: string[], + bcc?: string[], + ): Destination { + return { + ToAddresses: [to], + CcAddresses: cc?.filter(Boolean), + BccAddresses: bcc?.filter(Boolean), + }; + } + + private buildMessage(subject: string, body: string): Message { + return { + Subject: { + Data: subject, + Charset: "UTF-8", + }, + Body: { + Html: this.toContent(body), + }, + }; + } + + private toContent(value: string): Content { + return { + Data: value, + Charset: "UTF-8", + }; + } +} diff --git a/src/services/settings/default-settings-provider.service.ts b/src/services/settings/default-settings-provider.service.ts index cf8bcdd9..eb489e0a 100644 --- a/src/services/settings/default-settings-provider.service.ts +++ b/src/services/settings/default-settings-provider.service.ts @@ -469,6 +469,7 @@ const getSolidCoreSettings = (isProd: boolean) => options: [ { label: "SMTP Email Service", value: "SMTPEMailService" }, { label: "Elastic Email Service", value: "ElasticEmailService" }, + { label: "Amazon SES Service", value: "AmazonSESService" }, ], }, { @@ -1161,6 +1162,32 @@ const getSolidCoreSettings = (isProd: boolean) => sortOrder: 30, controlType: "shortText", }, + + // amazon-ses-providers + { + moduleName: "solid-core", + key: "awsSesAccessKeyId", + value: process.env.AWS_SES_ACCESS_KEY_ID, + level: SettingLevel.SystemEnv, + }, + { + moduleName: "solid-core", + key: "awsSesSecretAccessKey", + value: process.env.AWS_SES_SECRET_ACCESS_KEY, + level: SettingLevel.SystemEnv, + }, + { + moduleName: "solid-core", + key: "awsSesRegion", + value: process.env.AWS_SES_REGION, + level: SettingLevel.SystemEnv, + }, + { + moduleName: "solid-core", + key: "sesMailFrom", + value: process.env.SES_MAIL_FROM, + level: SettingLevel.SystemEnv, + }, ] as const satisfies SettingDefinition[]; // 2. diff --git a/src/solid-core.module.ts b/src/solid-core.module.ts index 8ba290ae..1a1852f6 100755 --- a/src/solid-core.module.ts +++ b/src/solid-core.module.ts @@ -102,6 +102,8 @@ import { Msg91SmsQueuePublisher } from './jobs/rabbitmq/msg91-sms-publisher.serv import { Msg91SmsQueueSubscriber } from './jobs/rabbitmq/msg91-sms-subscriber.service'; import { SmtpEmailQueuePublisherRabbitmq } from './jobs/rabbitmq/smtp-email-publisher.service'; import { SmtpEmailQueueSubscriberRabbitmq } from './jobs/rabbitmq/smtp-email-subscriber.service'; +import { AmazonSesEmailQueuePublisherRabbitmq } from './jobs/rabbitmq/amazon-ses-email-publisher.service'; +import { AmazonSesEmailQueueSubscriberRabbitmq } from './jobs/rabbitmq/amazon-ses-email-subscriber.service'; import { TestQueuePublisher } from './jobs/rabbitmq/test-queue-publisher.service'; import { TestQueueSubscriber } from './jobs/rabbitmq/test-queue-subscriber.service'; import { ChatterQueuePublisherRabbitmq } from './jobs/rabbitmq/chatter-queue-publisher.service'; @@ -124,6 +126,8 @@ import { Msg91WhatsappQueuePublisherRedis } from './jobs/redis/msg91-whatsapp-pu import { Msg91WhatsappQueueSubscriberRedis } from './jobs/redis/msg91-whatsapp-subscriber-redis.service'; import { SmtpEmailQueuePublisherRedis } from './jobs/redis/smtp-email-publisher-redis.service'; import { SmtpEmailQueueSubscriberRedis } from './jobs/redis/smtp-email-subscriber-redis.service'; +import { AmazonSesEmailQueuePublisherRedis } from './jobs/redis/amazon-ses-email-publisher-redis.service'; +import { AmazonSesEmailQueueSubscriberRedis } from './jobs/redis/amazon-ses-email-subscriber-redis.service'; import { Three60WhatsappQueuePublisherRedis } from './jobs/redis/three60-whatsapp-publisher-redis.service'; import { Three60WhatsappQueueSubscriberRedis } from './jobs/redis/three60-whatsapp-subscriber-redis.service'; import { TriggerMcpClientPublisherRedis } from './jobs/redis/trigger-mcp-client-publisher-redis.service'; @@ -148,6 +152,7 @@ import { } from './services/file'; import { HashingService } from './services/hashing.service'; import { ElasticEmailService } from './services/mail/elastic-email.service'; +import { AmazonSESService } from './services/mail/amazon-ses.service'; import { SMTPEMailService } from './services/mail/smtp-email.service'; import { MenuItemMetadataService } from './services/menu-item-metadata.service'; import { MqMessageQueueService } from './services/mq-message-queue.service'; @@ -237,6 +242,8 @@ import { Msg91SmsQueuePublisherDatabase } from './jobs/database/msg91-sms-publis import { Msg91SmsQueueSubscriberDatabase } from './jobs/database/msg91-sms-subscriber-database.service'; import { SmtpEmailQueuePublisherDatabase } from './jobs/database/smtp-email-publisher-database.service'; import { SmtpEmailQueueSubscriberDatabase } from './jobs/database/smtp-email-subscriber-database.service'; +import { AmazonSesEmailQueuePublisherDatabase } from './jobs/database/amazon-ses-email-publisher-database.service'; +import { AmazonSesEmailQueueSubscriberDatabase } from './jobs/database/amazon-ses-email-subscriber-database.service'; import { TwilioSmsQueuePublisherDatabase } from './jobs/database/twilio-sms-publisher-database.service'; import { TwilioSmsQueueSubscriberDatabase } from './jobs/database/twilio-sms-subscriber-database.service'; @@ -589,6 +596,7 @@ import { Entity } from 'typeorm'; IngestMetadataService, SMTPEMailService, ElasticEmailService, + AmazonSESService, Msg91SMSService, Msg91OTPService, Msg91WhatsappService, @@ -607,8 +615,12 @@ import { Entity } from 'typeorm'; SmtpEmailQueuePublisherRabbitmq, SmtpEmailQueueSubscriberRabbitmq, + AmazonSesEmailQueuePublisherRabbitmq, + AmazonSesEmailQueueSubscriberRabbitmq, SmtpEmailQueuePublisherDatabase, SmtpEmailQueueSubscriberDatabase, + AmazonSesEmailQueuePublisherDatabase, + AmazonSesEmailQueueSubscriberDatabase, ApiEmailQueuePublisher, ApiEmailQueueSubscriber, ApiEmailQueuePublisherDatabase, @@ -683,6 +695,8 @@ import { Entity } from 'typeorm'; Msg91WhatsappQueueSubscriberRedis, SmtpEmailQueuePublisherRedis, SmtpEmailQueueSubscriberRedis, + AmazonSesEmailQueuePublisherRedis, + AmazonSesEmailQueueSubscriberRedis, Three60WhatsappQueuePublisherRedis, Three60WhatsappQueueSubscriberRedis, TriggerMcpClientPublisherRedis,