diff --git a/libraries/grpc-sdk/src/modules/email/index.ts b/libraries/grpc-sdk/src/modules/email/index.ts index 8b319aad2..61e52cf13 100644 --- a/libraries/grpc-sdk/src/modules/email/index.ts +++ b/libraries/grpc-sdk/src/modules/email/index.ts @@ -54,4 +54,16 @@ export class Email extends ConduitModule { return res.sentMessageInfo; }); } + + resendEmail(emailRecordId: string) { + return this.client!.resendEmail({ emailRecordId }).then(res => { + return res.sentMessageInfo; + }); + } + + getEmailStatus(messageId: string) { + return this.client!.getEmailStatus({ messageId }).then(res => { + return JSON.parse(res.statusInfo); + }); + } } diff --git a/modules/email/package.json b/modules/email/package.json index 8e8648d60..94b686ba6 100644 --- a/modules/email/package.json +++ b/modules/email/package.json @@ -35,6 +35,8 @@ "@sendgrid/client": "^8.0.0", "@types/nodemailer-sendgrid": "^1.0.3", "await-to-js": "^3.0.0", + "axios": "^1.7.2", + "bullmq": "^5.4.3", "convict": "^6.2.4", "escape-string-regexp": "^4.0.0", "handlebars": "^4.7.8", diff --git a/modules/email/src/Email.ts b/modules/email/src/Email.ts index 10cf89fe9..3b3b71cbf 100644 --- a/modules/email/src/Email.ts +++ b/modules/email/src/Email.ts @@ -15,8 +15,12 @@ import { isNil } from 'lodash-es'; import { status } from '@grpc/grpc-js'; import { runMigrations } from './migrations/index.js'; import { + GetEmailStatusRequest, + GetEmailStatusResponse, RegisterTemplateRequest, RegisterTemplateResponse, + ResendEmailRequest, + ResendEmailResponse, SendEmailRequest, SendEmailResponse, } from './protoTypes/email.js'; @@ -24,6 +28,8 @@ import metricsSchema from './metrics/index.js'; import { ConfigController, ManagedModule } from '@conduitplatform/module-tools'; import { ISendEmailParams } from './interfaces/index.js'; import { fileURLToPath } from 'node:url'; +import { Queue, Worker } from 'bullmq'; +import { Cluster, Redis } from 'ioredis'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -35,6 +41,8 @@ export default class Email extends ManagedModule { functions: { registerTemplate: this.registerTemplate.bind(this), sendEmail: this.sendEmail.bind(this), + resendEmail: this.resendEmail.bind(this), + getEmailStatus: this.getEmailStatus.bind(this), }, }; protected metricsSchema = metricsSchema; @@ -43,6 +51,8 @@ export default class Email extends ManagedModule { private database: DatabaseProvider; private emailProvider: EmailProvider; private emailService: EmailService; + private redisConnection: Redis | Cluster; + private emailCleanupQueue: Queue | null = null; constructor() { super('email'); @@ -54,6 +64,7 @@ export default class Email extends ManagedModule { this.database = this.grpcSdk.database!; await this.registerSchemas(); await runMigrations(this.grpcSdk); + this.redisConnection = this.grpcSdk.redisManager.getClient(); } async preConfig(config: Config) { @@ -73,7 +84,7 @@ export default class Email extends ManagedModule { } else { if (!this.isRunning) { await this.initEmailProvider(); - this.emailService = new EmailService(this.emailProvider); + this.emailService = new EmailService(this.grpcSdk, this.emailProvider); this.adminRouter = new AdminHandlers(this.grpcServer, this.grpcSdk); this.adminRouter.setEmailService(this.emailService); this.isRunning = true; @@ -82,6 +93,14 @@ export default class Email extends ManagedModule { this.emailService.updateProvider(this.emailProvider); } this.updateHealth(HealthCheckStatus.SERVING); + + const config = ConfigController.getInstance().config as Config; + if (config.storeEmails.storage.enabled && !this.grpcSdk.isAvailable('storage')) { + ConduitGrpcSdk.Logger.warn( + 'Failed to enable email storing. Storage module not serving.', + ); + } + await this.handleEmailCleanupJob(config); } } @@ -139,6 +158,32 @@ export default class Email extends ManagedModule { return callback(null, { sentMessageInfo }); } + async resendEmail( + call: GrpcRequest, + callback: GrpcCallback, + ) { + let errorMessage: string | null = null; + const sentMessageInfo = await this.emailService + .resendEmail(call.request.emailRecordId) + .catch((e: Error) => (errorMessage = e.message)); + if (!isNil(errorMessage)) + return callback({ code: status.INTERNAL, message: errorMessage }); + return callback(null, { sentMessageInfo }); + } + + async getEmailStatus( + call: GrpcRequest, + callback: GrpcCallback, + ) { + let errorMessage: string | null = null; + const statusInfo = await this.emailService + .getEmailStatus(call.request.messageId) + .catch((e: Error) => (errorMessage = e.message)); + if (!isNil(errorMessage)) + return callback({ code: status.INTERNAL, message: errorMessage }); + return callback(null, { statusInfo: JSON.stringify(statusInfo) }); + } + protected registerSchemas(): Promise { const promises = Object.values(models).map(model => { const modelInstance = model.getInstance(this.database); @@ -158,4 +203,55 @@ export default class Email extends ManagedModule { this.emailProvider = new EmailProvider(transport, transportSettings); } + + private async handleEmailCleanupJob(config: Config) { + this.emailCleanupQueue = new Queue('email-cleanup-queue', { + connection: this.redisConnection, + }); + await this.emailCleanupQueue.drain(true); + if (!config.storeEmails.enabled || !config.storeEmails.cleanupSettings.enabled) { + await this.emailCleanupQueue.close(); + return; + } + const processorFile = path.normalize( + path.join(__dirname, 'jobs', 'cleanupStoredEmails.js'), + ); + const worker = new Worker('email-cleanup-queue', processorFile, { + connection: this.redisConnection, + removeOnComplete: { + age: 3600, + count: 1000, + }, + removeOnFail: { + age: 24 * 3600, + }, + }); + worker.on('active', job => { + ConduitGrpcSdk.Logger.info(`Stored email cleanup job ${job.id} started`); + }); + worker.on('completed', () => { + ConduitGrpcSdk.Logger.info(`Stored email cleanup completed`); + }); + worker.on('error', (error: Error) => { + ConduitGrpcSdk.Logger.error(`Stored email cleanup error:`); + ConduitGrpcSdk.Logger.error(error); + }); + + worker.on('failed', (_job, error) => { + ConduitGrpcSdk.Logger.error(`Stored email cleanup error:`); + ConduitGrpcSdk.Logger.error(error); + }); + await this.emailCleanupQueue.add( + 'cleanup', + { + limit: config.storeEmails.cleanupSettings.limit, + deleteStorageFiles: config.storeEmails.storage.enabled, + }, + { + repeat: { + every: config.storeEmails.cleanupSettings.repeat, + }, + }, + ); + } } diff --git a/modules/email/src/admin/index.ts b/modules/email/src/admin/index.ts index 129d1cad7..0a9bb142e 100644 --- a/modules/email/src/admin/index.ts +++ b/modules/email/src/admin/index.ts @@ -10,6 +10,7 @@ import { } from '@conduitplatform/grpc-sdk'; import { ConduitBoolean, + ConduitDate, ConduitJson, ConduitNumber, ConduitString, @@ -21,7 +22,7 @@ import { to } from 'await-to-js'; import { isNil } from 'lodash-es'; import { getHandleBarsValues } from '../email-provider/utils/index.js'; import { EmailService } from '../services/email.service.js'; -import { EmailTemplate } from '../models/index.js'; +import { EmailRecord, EmailTemplate } from '../models/index.js'; import { Config } from '../config/index.js'; import { Template } from '../email-provider/interfaces/Template.js'; import { TemplateDocument } from '../email-provider/interfaces/TemplateDocument.js'; @@ -364,7 +365,7 @@ export class AdminHandlers { } } - await this.emailService + const sentEmailInfo = await this.emailService .sendEmail(templateName, { body, subject, @@ -379,7 +380,55 @@ export class AdminHandlers { throw new GrpcError(status.INTERNAL, e.message); }); ConduitGrpcSdk.Metrics?.increment('emails_sent_total'); - return { message: 'Email sent' }; + return { message: sentEmailInfo.messageId ?? 'Email sent' }; + } + + async resendEmail(call: ParsedRouterRequest): Promise { + return (await this.emailService.resendEmail( + call.request.params.emailRecordId, + )) as UnparsedRouterResponse; + } + + async getEmailStatus(call: ParsedRouterRequest): Promise { + const statusInfo = await this.emailService.getEmailStatus( + call.request.params.messageId, + ); + return { statusInfo }; + } + + async getEmailRecords(call: ParsedRouterRequest): Promise { + const { + messageId, + templateId, + receiver, + sender, + cc, + replyTo, + startDate, + endDate, + skip, + limit, + sort, + } = call.request.params; + const query: Query = { + ...(messageId ? { messageId } : {}), + ...(templateId ? { templateId } : {}), + ...(receiver ? { receiver } : {}), + ...(sender ? { sender } : {}), + ...(cc ? { cc: { $in: cc } } : {}), + ...(replyTo ? { replyTo } : {}), + ...(startDate ? { createdAt: { $gte: startDate } } : {}), + ...(endDate ? { createdAt: { $lte: endDate } } : {}), + }; + const records = await EmailRecord.getInstance().findMany( + query, + undefined, + skip, + limit, + sort, + ); + const count = await EmailRecord.getInstance().countDocuments(query); + return { records, count }; } private registerAdminRoutes() { @@ -530,6 +579,59 @@ export class AdminHandlers { }), this.sendEmail.bind(this), ); + this.routingManager.route( + { + path: '/resend', + action: ConduitRouteActions.POST, + description: `Resends an email (only if stored in storage).`, + bodyParams: { + emailRecordId: ConduitString.Required, + }, + }, + new ConduitRouteReturnDefinition('Resend an email', { + message: ConduitString.Required, + }), + this.resendEmail.bind(this), + ); + this.routingManager.route( + { + path: '/status', + action: ConduitRouteActions.GET, + description: `Returns the latest status of a sent email.`, + queryParams: { + messageId: ConduitString.Required, + }, + }, + new ConduitRouteReturnDefinition('GetEmailStatus', { + statusInfo: ConduitJson.Required, + }), + this.getEmailStatus.bind(this), + ); + this.routingManager.route( + { + path: '/record', + action: ConduitRouteActions.GET, + description: `Returns records of stored sent emails.`, + queryParams: { + messageId: ConduitString.Optional, + templateId: ConduitString.Optional, + receiver: ConduitString.Optional, + sender: ConduitString.Optional, + cc: [ConduitString.Optional], + replyTo: ConduitString.Optional, + startDate: ConduitDate.Optional, + endDate: ConduitDate.Optional, + skip: ConduitNumber.Optional, + limit: ConduitNumber.Optional, + sort: ConduitString.Optional, + }, + }, + new ConduitRouteReturnDefinition('GetEmailRecords', { + records: [EmailRecord.name], + count: ConduitNumber.Required, + }), + this.getEmailRecords.bind(this), + ); this.routingManager.registerRoutes(); } } diff --git a/modules/email/src/config/config.ts b/modules/email/src/config/config.ts index c01a9a9aa..f142ca659 100644 --- a/modules/email/src/config/config.ts +++ b/modules/email/src/config/config.ts @@ -84,4 +84,45 @@ export default { }, }, }, + storeEmails: { + enabled: { + doc: 'Defines if sent email info should be stored in database', + format: 'Boolean', + default: false, + }, + storage: { + enabled: { + doc: 'Defines if email content should be stored in storage', + format: 'Boolean', + default: false, + }, + container: { + doc: 'The storage container for emails', + format: 'String', + default: 'conduit', + }, + folder: { + doc: 'The storage folder for emails', + format: 'String', + default: 'cnd-stored-emails', + }, + }, + cleanupSettings: { + enabled: { + doc: 'Settings for deleting old stored emails', + format: 'Boolean', + default: false, + }, + repeat: { + doc: 'Time in milliseconds to repeat the cleanup job', + format: 'Number', + default: 6 * 60 * 60 * 1000, + }, + limit: { + doc: 'Amount of stored emails to be deleted upon cleanup', + format: 'Number', + default: 100, + }, + }, + }, }; diff --git a/modules/email/src/email-provider/index.ts b/modules/email/src/email-provider/index.ts index 5d245a521..4fdf1201f 100644 --- a/modules/email/src/email-provider/index.ts +++ b/modules/email/src/email-provider/index.ts @@ -11,6 +11,7 @@ import { MandrillProvider } from './transports/mandrill/MandrilProvider.js'; import { SendgridProvider } from './transports/sendgrid/SendgridProvider.js'; import { SmtpProvider } from './transports/smtp/SmtpProvider.js'; import { ConfigController } from '@conduitplatform/module-tools'; +import { Indexable } from '@conduitplatform/grpc-sdk'; export class EmailProvider { _transport?: EmailProviderClass; @@ -90,4 +91,11 @@ export class EmailProvider { } return this._transport.sendEmail(email.getMailObject()); } + + getEmailStatus(messageId: string): Promise { + if (!this._transport) { + throw new Error('Email transport not initialized!'); + } + return this._transport.getEmailStatus(messageId); + } } diff --git a/modules/email/src/email-provider/models/EmailProviderClass.ts b/modules/email/src/email-provider/models/EmailProviderClass.ts index 075ec2749..069f885c0 100644 --- a/modules/email/src/email-provider/models/EmailProviderClass.ts +++ b/modules/email/src/email-provider/models/EmailProviderClass.ts @@ -4,6 +4,8 @@ import { DeleteEmailTemplate } from '../interfaces/DeleteEmailTemplate.js'; import { Template } from '../interfaces/Template.js'; import { UpdateEmailTemplate } from '../interfaces/UpdateEmailTemplate.js'; import { EmailBuilderClass } from './EmailBuilderClass.js'; +import { Indexable } from '@conduitplatform/grpc-sdk'; +import { SentMessageInfo } from 'nodemailer'; export abstract class EmailProviderClass { _transport?: Mail; @@ -24,6 +26,10 @@ export abstract class EmailProviderClass { abstract deleteTemplate(id: string): Promise; + abstract getEmailStatus(messageId: string): Promise; + + abstract getMessageId(info: SentMessageInfo): string | undefined; + sendEmail(mailOptions: Mail.Options) { return this._transport?.sendMail(mailOptions); } diff --git a/modules/email/src/email-provider/transports/mailgun/MailgunProvider.ts b/modules/email/src/email-provider/transports/mailgun/MailgunProvider.ts index 8a4644f5d..0a034bf4c 100644 --- a/modules/email/src/email-provider/transports/mailgun/MailgunProvider.ts +++ b/modules/email/src/email-provider/transports/mailgun/MailgunProvider.ts @@ -1,5 +1,5 @@ import { to } from 'await-to-js'; -import { createTransport } from 'nodemailer'; +import { createTransport, SentMessageInfo } from 'nodemailer'; import { Options } from 'nodemailer/lib/mailer'; import { CreateEmailTemplate } from '../../interfaces/CreateEmailTemplate.js'; import { Template } from '../../interfaces/Template.js'; @@ -13,6 +13,7 @@ import { MailgunMailBuilder } from './mailgunMailBuilder.js'; import mailgun, { Mailgun } from 'mailgun-js'; import { DeleteEmailTemplate } from '../../interfaces/DeleteEmailTemplate.js'; import { MailgunTemplate } from '../../interfaces/mailgun/MailgunTemplate.js'; +import { Indexable } from '@conduitplatform/grpc-sdk'; export class MailgunProvider extends EmailProviderClass { protected _mailgunSdk: Mailgun; @@ -123,4 +124,17 @@ export class MailgunProvider extends EmailProviderClass { getBuilder(): EmailBuilderClass { return new MailgunMailBuilder(); } + + async getEmailStatus(messageId: string): Promise { + const response = await this._mailgunSdk.get(`/${this.domain}/events`, { + 'message-id': messageId, + ascending: 'no', + limit: 1, + }); + return response.items[0]; + } + + getMessageId(info: SentMessageInfo): string | undefined { + return info?.messageId; + } } diff --git a/modules/email/src/email-provider/transports/mandrill/MandrilProvider.ts b/modules/email/src/email-provider/transports/mandrill/MandrilProvider.ts index dffc868ac..c2966629a 100644 --- a/modules/email/src/email-provider/transports/mandrill/MandrilProvider.ts +++ b/modules/email/src/email-provider/transports/mandrill/MandrilProvider.ts @@ -1,4 +1,4 @@ -import { createTransport } from 'nodemailer'; +import { createTransport, SentMessageInfo } from 'nodemailer'; import { EmailProviderClass } from '../../models/EmailProviderClass.js'; import { MandrillConfig } from './mandrill.config.js'; import { Mandrill } from 'mandrill-api'; @@ -12,6 +12,7 @@ import { MandrillTemplate } from '../../interfaces/mandrill/MandrillTemplate.js' // @ts-expect-error // missing typings for nodemailer-mandrill-transport import mandrillTransport from 'nodemailer-mandrill-transport'; +import { Indexable } from '@conduitplatform/grpc-sdk'; export class MandrillProvider extends EmailProviderClass { private _mandrillSdk?: Mandrill; @@ -119,4 +120,20 @@ export class MandrillProvider extends EmailProviderClass { getBuilder() { return new MandrillBuilder(); } + + async getEmailStatus(messageId: string): Promise { + return new Promise( + resolve => + this._mandrillSdk?.messages.info( + { + id: messageId, + }, + resolve as (json: object) => void, + ), + ); + } + + getMessageId(info: SentMessageInfo): string | undefined { + return info?.messageId; + } } diff --git a/modules/email/src/email-provider/transports/sendgrid/SendgridProvider.ts b/modules/email/src/email-provider/transports/sendgrid/SendgridProvider.ts index 51e6e634c..edca26a94 100644 --- a/modules/email/src/email-provider/transports/sendgrid/SendgridProvider.ts +++ b/modules/email/src/email-provider/transports/sendgrid/SendgridProvider.ts @@ -1,6 +1,6 @@ import { EmailProviderClass } from '../../models/EmailProviderClass.js'; import { SendGridConfig } from './sendgrid.config.js'; -import { createTransport } from 'nodemailer'; +import { createTransport, SentMessageInfo } from 'nodemailer'; import { Client } from '@sendgrid/client'; import { Template } from '../../interfaces/Template.js'; import { CreateEmailTemplate } from '../../interfaces/CreateEmailTemplate.js'; @@ -11,6 +11,7 @@ import { SendgridTemplate, TemplateVersion, } from '../../interfaces/sendgrid/SendgridTemplate.js'; +import { Indexable } from '@conduitplatform/grpc-sdk'; import sgTransport from 'nodemailer-sendgrid'; @@ -136,4 +137,15 @@ export class SendgridProvider extends EmailProviderClass { getBuilder() { return new SendgridMailBuilder(); } + + async getEmailStatus(messageId: string): Promise { + return await this._sgClient.request({ + method: 'GET', + url: `/v3/messages/` + messageId, + }); + } + + getMessageId(info: SentMessageInfo): string | undefined { + return info?.[0]?.caseless?.dict?.['x-message-id']; + } } diff --git a/modules/email/src/email-provider/transports/smtp/SmtpProvider.ts b/modules/email/src/email-provider/transports/smtp/SmtpProvider.ts index d12b531e6..9937a8c60 100644 --- a/modules/email/src/email-provider/transports/smtp/SmtpProvider.ts +++ b/modules/email/src/email-provider/transports/smtp/SmtpProvider.ts @@ -1,4 +1,4 @@ -import { createTransport } from 'nodemailer'; +import { createTransport, SentMessageInfo } from 'nodemailer'; import { Options } from 'nodemailer/lib/mailer'; import { Template } from '../../interfaces/Template.js'; import { DeleteEmailTemplate } from '../../interfaces/DeleteEmailTemplate.js'; @@ -6,6 +6,7 @@ import { UpdateEmailTemplate } from '../../interfaces/UpdateEmailTemplate.js'; import { EmailBuilderClass } from '../../models/EmailBuilderClass.js'; import { EmailProviderClass } from '../../models/EmailProviderClass.js'; import { NodemailerBuilder } from '../nodemailer/nodemailerBuilder.js'; +import { Indexable } from '@conduitplatform/grpc-sdk'; export class SmtpProvider extends EmailProviderClass { constructor(transportSettings: any) { @@ -38,4 +39,12 @@ export class SmtpProvider extends EmailProviderClass { async deleteTemplate(id: string): Promise { throw new Error('Method not implemented.'); } + + getEmailStatus(messageId: string): Promise { + throw new Error('Method not implemented.'); + } + + getMessageId(info: SentMessageInfo): string | undefined { + return undefined; + } } diff --git a/modules/email/src/email.proto b/modules/email/src/email.proto index 19c9c1c5a..66216ac65 100644 --- a/modules/email/src/email.proto +++ b/modules/email/src/email.proto @@ -30,7 +30,25 @@ message SendEmailResponse { string sentMessageInfo = 1; } +message ResendEmailRequest { + string emailRecordId = 1; +} + +message ResendEmailResponse { + string sentMessageInfo = 1; +} + +message GetEmailStatusRequest { + string messageId = 1; +} + +message GetEmailStatusResponse { + string statusInfo = 1; +} + service Email { rpc RegisterTemplate(RegisterTemplateRequest) returns (RegisterTemplateResponse); rpc SendEmail(SendEmailRequest) returns (SendEmailResponse); + rpc ResendEmail(ResendEmailRequest) returns (ResendEmailResponse); + rpc GetEmailStatus(GetEmailStatusRequest) returns (GetEmailStatusResponse); } diff --git a/modules/email/src/jobs/cleanupStoredEmails.ts b/modules/email/src/jobs/cleanupStoredEmails.ts new file mode 100644 index 000000000..d4d1696d3 --- /dev/null +++ b/modules/email/src/jobs/cleanupStoredEmails.ts @@ -0,0 +1,39 @@ +import { ConduitGrpcSdk } from '@conduitplatform/grpc-sdk'; +import { EmailRecord } from '../models/index.js'; +import { SandboxedJob } from 'bullmq'; + +let grpcSdk: ConduitGrpcSdk | undefined = undefined; + +export default async ( + job: SandboxedJob<{ limit: number; deleteStorageFiles: boolean }>, +) => { + if (!grpcSdk) { + if (!process.env.CONDUIT_SERVER) throw new Error('No serverUrl provided!'); + grpcSdk = new ConduitGrpcSdk(process.env.CONDUIT_SERVER, 'email', false); + await grpcSdk.initialize(); + await grpcSdk.initializeEventBus(); + await grpcSdk.waitForExistence('database'); + EmailRecord.getInstance(grpcSdk.database!); + } + const { limit, deleteStorageFiles } = job.data; + const emailsToDelete = await EmailRecord.getInstance().findMany( + {}, + undefined, + undefined, + limit, + 'createdAt', + ); + if (emailsToDelete.length === 0) return; + const emailIdsToDelete = emailsToDelete.map(record => record._id); + await EmailRecord.getInstance().deleteMany({ _id: { $in: emailIdsToDelete } }); + + if (deleteStorageFiles) { + await grpcSdk.waitForExistence('storage'); + const fileIdsToDelete = emailsToDelete + .filter(record => record.contentFile) + .map(record => record.contentFile); + for (const id of fileIdsToDelete) { + await grpcSdk.storage!.deleteFile(id); + } + } +}; diff --git a/modules/email/src/jobs/index.ts b/modules/email/src/jobs/index.ts new file mode 100644 index 000000000..650579f63 --- /dev/null +++ b/modules/email/src/jobs/index.ts @@ -0,0 +1 @@ +export * from './cleanupStoredEmails.js'; diff --git a/modules/email/src/models/EmailRecord.ts b/modules/email/src/models/EmailRecord.ts new file mode 100644 index 000000000..fe5369e64 --- /dev/null +++ b/modules/email/src/models/EmailRecord.ts @@ -0,0 +1,83 @@ +import { ConduitModel, DatabaseProvider, TYPE } from '@conduitplatform/grpc-sdk'; +import { ConduitActiveSchema } from '@conduitplatform/module-tools'; +import { EmailTemplate } from './EmailTemplate.schema.js'; + +const schema: ConduitModel = { + _id: TYPE.ObjectId, + messageId: { + type: TYPE.String, + required: false, + }, + template: { + type: TYPE.Relation, + model: 'EmailTemplate', + required: false, + }, + contentFile: { + type: TYPE.Relation, + model: 'File', + required: false, + }, + sender: { + type: TYPE.String, + required: false, + }, + receiver: { + type: TYPE.String, + required: true, + }, + cc: { + type: [TYPE.String], + required: false, + }, + replyTo: { + type: TYPE.String, + required: false, + }, + sendingDomain: { + type: TYPE.String, + required: false, + }, + createdAt: TYPE.Date, + updatedAt: TYPE.Date, +}; +const modelOptions = { + timestamps: true, + conduit: { + permissions: { + extendable: true, + canCreate: false, + canModify: 'ExtensionOnly', + canDelete: false, + }, + }, +} as const; +const collectionName = undefined; + +export class EmailRecord extends ConduitActiveSchema { + private static _instance: EmailRecord; + _id: string; + messageId?: string; + template: string | EmailTemplate; + contentFile: string; + sender: string; + receiver: string; + cc?: string[]; + replyTo?: string; + sendingDomain?: string; + createdAt: Date; + updatedAt: Date; + + private constructor(database: DatabaseProvider) { + super(database, EmailRecord.name, schema, modelOptions, collectionName); + } + + static getInstance(database?: DatabaseProvider) { + if (EmailRecord._instance) return EmailRecord._instance; + if (!database) { + throw new Error('No database instance provided!'); + } + EmailRecord._instance = new EmailRecord(database); + return EmailRecord._instance; + } +} diff --git a/modules/email/src/models/index.ts b/modules/email/src/models/index.ts index a475b2c26..4ae3e2307 100644 --- a/modules/email/src/models/index.ts +++ b/modules/email/src/models/index.ts @@ -1 +1,2 @@ export * from './EmailTemplate.schema.js'; +export * from './EmailRecord.js'; diff --git a/modules/email/src/services/email.service.ts b/modules/email/src/services/email.service.ts index 00452ac09..4fc39ebbe 100644 --- a/modules/email/src/services/email.service.ts +++ b/modules/email/src/services/email.service.ts @@ -1,5 +1,5 @@ import { isEmpty, isNil } from 'lodash-es'; -import { EmailTemplate } from '../models/index.js'; +import { EmailTemplate, EmailRecord } from '../models/index.js'; import { IRegisterTemplateParams, ISendEmailParams } from '../interfaces/index.js'; import handlebars from 'handlebars'; import { EmailProvider } from '../email-provider/index.js'; @@ -7,10 +7,17 @@ import { CreateEmailTemplate } from '../email-provider/interfaces/CreateEmailTem import { UpdateEmailTemplate } from '../email-provider/interfaces/UpdateEmailTemplate.js'; import { Attachment } from 'nodemailer/lib/mailer'; import { Template } from '../email-provider/interfaces/Template.js'; -import { ConduitGrpcSdk } from '@conduitplatform/grpc-sdk'; +import { ConduitGrpcSdk, GrpcError } from '@conduitplatform/grpc-sdk'; +import { ConfigController } from '@conduitplatform/module-tools'; +import { Config } from '../config/index.js'; +import { status } from '@grpc/grpc-js'; +import { storeEmail } from '../utils/index.js'; export class EmailService { - constructor(private emailer: EmailProvider) {} + constructor( + private readonly grpcSdk: ConduitGrpcSdk, + private emailer: EmailProvider, + ) {} updateProvider(emailer: EmailProvider) { this.emailer = emailer; @@ -54,7 +61,11 @@ export class EmailService { }); } - async sendEmail(template: string, params: ISendEmailParams) { + async sendEmail( + template: string | undefined, + params: ISendEmailParams, + contentFileId?: string, + ) { const { email, body, subject, variables, sender } = params; const builder = this.emailer.emailBuilder(); @@ -64,7 +75,7 @@ export class EmailService { let subjectString = subject!; let bodyString = body!; - let templateFound: EmailTemplate | null; + let templateFound: EmailTemplate | null = null; let senderAddress: string | undefined; if (template) { templateFound = await EmailTemplate.getInstance().findOne({ name: template }); @@ -114,11 +125,9 @@ export class EmailService { } } builder.setSender(senderAddress!); - builder.setContent(bodyString); builder.setReceiver(email); builder.setSubject(subjectString); - if (params.cc) { builder.setCC(params.cc); } @@ -128,6 +137,42 @@ export class EmailService { if (params.attachments) { builder.addAttachments(params.attachments as Attachment[]); } - return this.emailer.sendEmail(builder); + + const sentMessageInfo = await this.emailer.sendEmail(builder); + const messageId = this.emailer._transport?.getMessageId(sentMessageInfo); + + const config = ConfigController.getInstance().config as Config; + if (config.storeEmails.enabled) { + await storeEmail(this.grpcSdk, messageId, templateFound, contentFileId, { + body: bodyString, + subject: subjectString, + ...params, + }).catch(e => { + ConduitGrpcSdk.Logger.error('Failed to store email', e); + }); + } + return { messageId, ...sentMessageInfo }; + } + + async resendEmail(emailRecordId: string) { + if (!this.grpcSdk.isAvailable('storage')) { + throw new GrpcError(status.INTERNAL, 'Storage is not available.'); + } + const emailRecord = await EmailRecord.getInstance().findOne({ _id: emailRecordId }); + if (isNil(emailRecord)) { + throw new GrpcError(status.NOT_FOUND, 'Email record not found.'); + } + const contentFileData = await this.grpcSdk.storage!.getFileData( + emailRecord.contentFile, + ); + if (!contentFileData) { + throw new GrpcError(status.NOT_FOUND, 'Email content file not found.'); + } + const dataString = Buffer.from(contentFileData.data, 'base64').toString('utf-8'); + return this.sendEmail(undefined, JSON.parse(dataString), emailRecord.contentFile); + } + + async getEmailStatus(messageId: string) { + return this.emailer.getEmailStatus(messageId); } } diff --git a/modules/email/src/utils/index.ts b/modules/email/src/utils/index.ts new file mode 100644 index 000000000..38d24de77 --- /dev/null +++ b/modules/email/src/utils/index.ts @@ -0,0 +1 @@ +export * from './storeEmail.js'; diff --git a/modules/email/src/utils/storeEmail.ts b/modules/email/src/utils/storeEmail.ts new file mode 100644 index 000000000..83c790df2 --- /dev/null +++ b/modules/email/src/utils/storeEmail.ts @@ -0,0 +1,43 @@ +import { randomUUID } from 'node:crypto'; +import axios from 'axios'; +import { EmailRecord, EmailTemplate } from '../models/index.js'; +import { ConduitGrpcSdk } from '@conduitplatform/grpc-sdk'; +import { ConfigController } from '@conduitplatform/module-tools'; +import { Config } from '../config/index.js'; +import { ISendEmailParams } from '../interfaces/index.js'; + +export async function storeEmail( + grpcSdk: ConduitGrpcSdk, + messageId: string | undefined, + template: EmailTemplate | null, + contentFileId: string | undefined, + params: ISendEmailParams, +) { + const config = ConfigController.getInstance().config as Config; + let newContentFile; + if (!contentFileId && config.storeEmails.storage.enabled) { + newContentFile = await grpcSdk.storage!.createFileByUrl( + randomUUID(), + config.storeEmails.storage.folder, + config.storeEmails.storage.container, + ); + const buffer = Buffer.from(JSON.stringify(params)); + await axios.put(newContentFile.uploadUrl, buffer, { + headers: { + 'Content-Length': buffer.length, + 'x-ms-blob-type': 'BlockBlob', + }, + }); + } + const emailInfo = { + messageId, + template: template ? template._id : undefined, + contentFile: contentFileId ?? newContentFile?.id, + sender: params.sender, + receiver: params.email, + cc: params.cc, + replyTo: params.replyTo, + sendingDomain: params.sendingDomain, + }; + await EmailRecord.getInstance().create(emailInfo); +} diff --git a/yarn.lock b/yarn.lock index 072ddac41..44f5b2c74 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5403,6 +5403,15 @@ axios@^1.3.3, axios@^1.6.0, axios@^1.6.5, axios@^1.6.7: form-data "^4.0.0" proxy-from-env "^1.1.0" +axios@^1.7.2: + version "1.7.2" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.7.2.tgz#b625db8a7051fbea61c35a3cbb3a1daa7b9c7621" + integrity sha512-2A8QhOMrbomlDuiLeK9XibIBzuHeRcqqNOHp0Cyp5EoJ1IFDh+XZH3A6BkXtv0K4gFGCI0Y4BM7B1wOEi0Rmgw== + dependencies: + follow-redirects "^1.15.6" + form-data "^4.0.0" + proxy-from-env "^1.1.0" + babel-jest@^29.7.0: version "29.7.0" resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-29.7.0.tgz#f4369919225b684c56085998ac63dbd05be020d5" @@ -7758,7 +7767,7 @@ fn.name@1.x.x: resolved "https://registry.yarnpkg.com/fn.name/-/fn.name-1.1.0.tgz#26cad8017967aea8731bc42961d04a3d5988accc" integrity sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw== -follow-redirects@^1.0.0, follow-redirects@^1.15.4: +follow-redirects@^1.0.0, follow-redirects@^1.15.4, follow-redirects@^1.15.6: version "1.15.6" resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.6.tgz#7f815c0cda4249c74ff09e95ef97c23b5fd0399b" integrity sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==