diff --git a/apps/server/src/app/lib/email.ts b/apps/server/src/app/lib/email.ts new file mode 100644 index 00000000..0b4d8ab9 --- /dev/null +++ b/apps/server/src/app/lib/email.ts @@ -0,0 +1,15 @@ +import { ServerClient as PostmarkServerClient } from 'postmark' +import env from '../../env' + +export function initializeEmailClient() { + switch (process.env.NX_EMAIL_PROVIDER) { + case 'postmark': + if (env.NX_EMAIL_PROVIDER_API_TOKEN) { + return new PostmarkServerClient(env.NX_EMAIL_PROVIDER_API_TOKEN) + } else { + throw new Error('Missing Postmark API token') + } + default: + throw new Error('Invalid email provider') + } +} diff --git a/apps/server/src/app/lib/endpoint.ts b/apps/server/src/app/lib/endpoint.ts index 7fba5f3c..2ca0a23a 100644 --- a/apps/server/src/app/lib/endpoint.ts +++ b/apps/server/src/app/lib/endpoint.ts @@ -5,7 +5,6 @@ import type { IInsightService, ISecurityPricingService, IPlanService, - IEmailService, } from '@maybe-finance/server/features' import { CryptoService, @@ -55,10 +54,10 @@ import prisma from './prisma' import plaid, { getPlaidWebhookUrl } from './plaid' import teller, { getTellerWebhookUrl } from './teller' import stripe from './stripe' -import postmark from './postmark' import defineAbilityFor from './ability' import env from '../../env' import logger from '../lib/logger' +import { initializeEmailClient } from './email' // shared services @@ -73,12 +72,12 @@ export const queueService = new QueueService( : new BullQueueFactory(logger.child({ service: 'BullQueueFactory' }), env.NX_REDIS_URL) ) -export const emailService: IEmailService = new EmailService( +export const emailService: EmailService = new EmailService( logger.child({ service: 'EmailService' }), - postmark, + initializeEmailClient(), { - from: env.NX_POSTMARK_FROM_ADDRESS, - replyTo: env.NX_POSTMARK_REPLY_TO_ADDRESS, + from: env.NX_EMAIL_FROM_ADDRESS, + replyTo: env.NX_EMAIL_REPLY_TO_ADDRESS, } ) diff --git a/apps/server/src/app/lib/postmark.ts b/apps/server/src/app/lib/postmark.ts deleted file mode 100644 index ff0e87c6..00000000 --- a/apps/server/src/app/lib/postmark.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { ServerClient } from 'postmark' -import env from '../../env' - -const postmark = env.NX_POSTMARK_API_TOKEN ? new ServerClient(env.NX_POSTMARK_API_TOKEN) : undefined - -export default postmark diff --git a/apps/server/src/env.ts b/apps/server/src/env.ts index 716e6934..59dd5ee8 100644 --- a/apps/server/src/env.ts +++ b/apps/server/src/env.ts @@ -66,9 +66,9 @@ const envSchema = z.object({ // Key to Cloudfront pub key NX_CDN_SIGNER_PUBKEY_ID: z.string().default('REPLACE_THIS'), - NX_POSTMARK_FROM_ADDRESS: z.string().default('account@maybe.co'), - NX_POSTMARK_REPLY_TO_ADDRESS: z.string().default('support@maybe.co'), - NX_POSTMARK_API_TOKEN: z.string().optional(), + NX_EMAIL_FROM_ADDRESS: z.string().default('account@maybe.co'), + NX_EMAIL_REPLY_TO_ADDRESS: z.string().default('support@maybe.co'), + NX_EMAIL_PROVIDER_API_TOKEN: z.string().optional(), }) const env = envSchema.parse(process.env) diff --git a/apps/workers/src/app/lib/di.ts b/apps/workers/src/app/lib/di.ts index e98c9489..2dc491ea 100644 --- a/apps/workers/src/app/lib/di.ts +++ b/apps/workers/src/app/lib/di.ts @@ -8,7 +8,6 @@ import type { IUserProcessor, ISecurityPricingService, IUserService, - IEmailService, IEmailProcessor, } from '@maybe-finance/server/features' import { @@ -55,7 +54,7 @@ import logger from './logger' import prisma from './prisma' import plaid from './plaid' import teller from './teller' -import postmark from './postmark' +import { initializeEmailClient } from './email' import stripe from './stripe' import env from '../../env' import { BullQueueEventHandler, WorkerErrorHandlerService } from '../services' @@ -263,12 +262,12 @@ export const workerErrorHandlerService = new WorkerErrorHandlerService( // send-email -export const emailService: IEmailService = new EmailService( +export const emailService: EmailService = new EmailService( logger.child({ service: 'EmailService' }), - postmark, + initializeEmailClient(), { - from: env.NX_POSTMARK_FROM_ADDRESS, - replyTo: env.NX_POSTMARK_REPLY_TO_ADDRESS, + from: env.NX_EMAIL_FROM_ADDRESS, + replyTo: env.NX_EMAIL_REPLY_TO_ADDRESS, } ) diff --git a/apps/workers/src/app/lib/email.ts b/apps/workers/src/app/lib/email.ts new file mode 100644 index 00000000..0b4d8ab9 --- /dev/null +++ b/apps/workers/src/app/lib/email.ts @@ -0,0 +1,15 @@ +import { ServerClient as PostmarkServerClient } from 'postmark' +import env from '../../env' + +export function initializeEmailClient() { + switch (process.env.NX_EMAIL_PROVIDER) { + case 'postmark': + if (env.NX_EMAIL_PROVIDER_API_TOKEN) { + return new PostmarkServerClient(env.NX_EMAIL_PROVIDER_API_TOKEN) + } else { + throw new Error('Missing Postmark API token') + } + default: + throw new Error('Invalid email provider') + } +} diff --git a/apps/workers/src/app/lib/postmark.ts b/apps/workers/src/app/lib/postmark.ts deleted file mode 100644 index ff0e87c6..00000000 --- a/apps/workers/src/app/lib/postmark.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { ServerClient } from 'postmark' -import env from '../../env' - -const postmark = env.NX_POSTMARK_API_TOKEN ? new ServerClient(env.NX_POSTMARK_API_TOKEN) : undefined - -export default postmark diff --git a/apps/workers/src/env.ts b/apps/workers/src/env.ts index 1b3a651c..4428f66a 100644 --- a/apps/workers/src/env.ts +++ b/apps/workers/src/env.ts @@ -21,9 +21,9 @@ const envSchema = z.object({ NX_POLYGON_API_KEY: z.string().default(''), - NX_POSTMARK_FROM_ADDRESS: z.string().default('account@maybe.co'), - NX_POSTMARK_REPLY_TO_ADDRESS: z.string().default('support@maybe.co'), - NX_POSTMARK_API_TOKEN: z.string().optional(), + NX_EMAIL_FROM_ADDRESS: z.string().default('account@maybe.co'), + NX_EMAIL_REPLY_TO_ADDRESS: z.string().default('support@maybe.co'), + NX_EMAIL_PROVIDER_API_TOKEN: z.string().optional(), NX_STRIPE_SECRET_KEY: z.string().default('sk_test_REPLACE_THIS'), NX_CDN_PRIVATE_BUCKET: z.string().default('REPLACE_THIS'), diff --git a/libs/server/features/src/email/email.processor.ts b/libs/server/features/src/email/email.processor.ts index ab7913fe..debb82ea 100644 --- a/libs/server/features/src/email/email.processor.ts +++ b/libs/server/features/src/email/email.processor.ts @@ -1,7 +1,7 @@ import type { Logger } from 'winston' import type { PrismaClient } from '@prisma/client' import type { SendEmailQueueJobData } from '@maybe-finance/server/shared' -import type { IEmailService } from './email.service' +import type { EmailService } from './email.service' import { DateTime } from 'luxon' export interface IEmailProcessor { @@ -13,17 +13,25 @@ export class EmailProcessor implements IEmailProcessor { constructor( private readonly logger: Logger, private readonly prisma: PrismaClient, - private readonly emailService: IEmailService + private readonly emailService: EmailService ) {} async send(jobData: SendEmailQueueJobData) { if ('type' in jobData) { switch (jobData.type) { case 'plain': - this.emailService.send(jobData.messages) + if (Array.isArray(jobData.messages)) { + this.emailService.send(jobData.messages) + } else { + this.emailService.send([jobData.messages]) + } break case 'template': - this.emailService.sendTemplate(jobData.messages) + if (Array.isArray(jobData.messages)) { + this.emailService.sendTemplate(jobData.messages) + } else { + this.emailService.sendTemplate([jobData.messages]) + } break case 'trial-reminders': this.sendTrialEndReminders() diff --git a/libs/server/features/src/email/email.service.ts b/libs/server/features/src/email/email.service.ts index b8b1d3b7..fde0ce08 100644 --- a/libs/server/features/src/email/email.service.ts +++ b/libs/server/features/src/email/email.service.ts @@ -1,53 +1,60 @@ import type { Logger } from 'winston' -import type { Message, ServerClient as PostmarkServerClient, TemplatedMessage } from 'postmark' +import type { ServerClient as PostmarkServerClient } from 'postmark' import type { MessageSendingResponse } from 'postmark/dist/client/models' import type { SharedType } from '@maybe-finance/shared' -import { chunk, uniq } from 'lodash' -import { EmailTemplateSchema } from './email.schema' -export interface IEmailService { - send(messages: SharedType.PlainEmailMessage): Promise - send(messages: SharedType.PlainEmailMessage[]): Promise +import { PostmarkEmailProvider } from './providers/postmark.provider' + +export interface IEmailProvider { + send(messages: SharedType.PlainEmailMessage): Promise + send(messages: SharedType.PlainEmailMessage[]): Promise send( messages: SharedType.PlainEmailMessage | SharedType.PlainEmailMessage[] - ): Promise - - sendTemplate(messages: SharedType.TemplateEmailMessage): Promise - sendTemplate(messages: SharedType.TemplateEmailMessage[]): Promise + ): Promise + sendTemplate( + messages: SharedType.TemplateEmailMessage + ): Promise + sendTemplate( + messages: SharedType.TemplateEmailMessage[] + ): Promise sendTemplate( messages: SharedType.TemplateEmailMessage | SharedType.TemplateEmailMessage[] - ): Promise + ): Promise } -export class EmailService implements IEmailService { +export class EmailService implements IEmailProvider { + private emailProvider: IEmailProvider constructor( private readonly logger: Logger, - private readonly postmark: PostmarkServerClient | undefined, + private readonly client: PostmarkServerClient | undefined, private readonly defaultAddresses: { from: string; replyTo?: string } - ) {} + ) { + const provider = process.env.EMAIL_PROVIDER + + switch (provider) { + case 'postmark': + this.emailProvider = new PostmarkEmailProvider( + this.logger.child({ service: 'PostmarkEmailProvider' }), + this.client, + this.defaultAddresses + ) + break + default: + throw new Error('Unsupported email provider') + } + } /** * Sends plain email(s) * * @returns success boolean(s) */ - async send(messages: SharedType.PlainEmailMessage): Promise - async send(messages: SharedType.PlainEmailMessage[]): Promise - async send( + send(messages: SharedType.PlainEmailMessage): Promise + send(messages: SharedType.PlainEmailMessage[]): Promise + send( messages: SharedType.PlainEmailMessage | SharedType.PlainEmailMessage[] - ): Promise { - const mapToPostmark = (message: SharedType.PlainEmailMessage): Message => ({ - From: message.from ?? this.defaultAddresses.from, - ReplyTo: message.replyTo ?? this.defaultAddresses.replyTo, - To: message.to, - Subject: message.subject, - TextBody: message.textBody, - HtmlBody: message.htmlBody, - }) - - return Array.isArray(messages) - ? this.sendEmailBatch(messages.map(mapToPostmark)) - : this.sendEmail(mapToPostmark(messages)) + ): Promise { + return this.emailProvider.send(messages) } /** @@ -60,92 +67,6 @@ export class EmailService implements IEmailService { async sendTemplate( messages: SharedType.TemplateEmailMessage | SharedType.TemplateEmailMessage[] ): Promise { - const mapToPostmark = (message: SharedType.TemplateEmailMessage): TemplatedMessage => { - const { alias, model } = EmailTemplateSchema.parse(message.template) - - return { - From: message.from ?? this.defaultAddresses.from, - ReplyTo: message.replyTo ?? this.defaultAddresses.replyTo, - To: message.to, - TemplateAlias: alias, - TemplateModel: model, - } - } - - return Array.isArray(messages) - ? this.sendEmailBatchWithTemplate(messages.map(mapToPostmark)) - : this.sendEmailWithTemplate(mapToPostmark(messages)) - } - - private async sendEmailWithTemplate( - message: TemplatedMessage - ): Promise { - this.logger.info( - `Sending templated email template=${message.TemplateAlias} from=${message.From} to=${message.To}`, - message.TemplateModel - ) - - if (!this.postmark) { - this.logger.info('Postmark API key not provided, skipping email send') - return undefined as unknown as MessageSendingResponse - } - - return await this.postmark.sendEmailWithTemplate(message) - } - - private async sendEmail(message: Message): Promise { - this.logger.info( - `Sending plain email subject=${message.Subject} from=${message.From} to=${message.To}`, - { text: message.TextBody, html: message.HtmlBody } - ) - - if (!this.postmark) { - this.logger.info('Postmark API key not provided, skipping email send') - return undefined as unknown as MessageSendingResponse - } - - return await this.postmark.sendEmail(message) - } - - private async sendEmailBatchWithTemplate( - messages: TemplatedMessage[] - ): Promise { - this.logger.info( - `Sending templated email batch templates=[${uniq( - messages.map(({ TemplateAlias }) => TemplateAlias) - ).join(',')}] count=${messages.length}` - ) - - return ( - await Promise.all( - chunk(messages, 500).map((chunk) => { - if (!this.postmark) { - this.logger.info('Postmark API key not provided, skipping email send') - return [] as MessageSendingResponse[] - } - return this.postmark.sendEmailBatchWithTemplates(chunk) - }) - ) - ).flat() - } - - private async sendEmailBatch(messages: Message[]): Promise { - this.logger.info( - `Sending templated email batch subjects=[${uniq( - messages.map(({ Subject }) => Subject) - ).join(',')}] count=${messages.length}` - ) - - return ( - await Promise.all( - chunk(messages, 500).map((chunk) => { - if (!this.postmark) { - this.logger.info('Postmark API key not provided, skipping email send') - return [] as MessageSendingResponse[] - } - return this.postmark.sendEmailBatch(chunk) - }) - ) - ).flat() + return this.emailProvider.sendTemplate(messages) } } diff --git a/libs/server/features/src/email/providers/postmark.provider.ts b/libs/server/features/src/email/providers/postmark.provider.ts new file mode 100644 index 00000000..ac08d756 --- /dev/null +++ b/libs/server/features/src/email/providers/postmark.provider.ts @@ -0,0 +1,138 @@ +import type { IEmailProvider } from '../email.service' +import type { Logger } from 'winston' +import type { Message, ServerClient as PostmarkServerClient, TemplatedMessage } from 'postmark' +import type { MessageSendingResponse } from 'postmark/dist/client/models' +import type { SharedType } from '@maybe-finance/shared' +import { chunk, uniq } from 'lodash' +import { EmailTemplateSchema } from '../email.schema' + +export class PostmarkEmailProvider implements IEmailProvider { + constructor( + private readonly logger: Logger, + private readonly client: PostmarkServerClient | undefined, + private readonly defaultAddresses: { from: string; replyTo?: string } + ) {} + + /** + * Sends plain email(s) + * + * @returns success boolean(s) + */ + async send(messages: SharedType.PlainEmailMessage): Promise + async send(messages: SharedType.PlainEmailMessage[]): Promise + async send( + messages: SharedType.PlainEmailMessage | SharedType.PlainEmailMessage[] + ): Promise { + const mapToPostmark = (message: SharedType.PlainEmailMessage): Message => ({ + From: message.from ?? this.defaultAddresses.from, + ReplyTo: message.replyTo ?? this.defaultAddresses.replyTo, + To: message.to, + Subject: message.subject, + TextBody: message.textBody, + HtmlBody: message.htmlBody, + }) + + return Array.isArray(messages) + ? this.sendEmailBatch(messages.map(mapToPostmark)) + : this.sendEmail(mapToPostmark(messages)) + } + + /** + * Sends template email(s) + */ + async sendTemplate(messages: SharedType.TemplateEmailMessage): Promise + async sendTemplate( + messages: SharedType.TemplateEmailMessage[] + ): Promise + async sendTemplate( + messages: SharedType.TemplateEmailMessage | SharedType.TemplateEmailMessage[] + ): Promise { + const mapToPostmark = (message: SharedType.TemplateEmailMessage): TemplatedMessage => { + const { alias, model } = EmailTemplateSchema.parse(message.template) + + return { + From: message.from ?? this.defaultAddresses.from, + ReplyTo: message.replyTo ?? this.defaultAddresses.replyTo, + To: message.to, + TemplateAlias: alias, + TemplateModel: model, + } + } + + return Array.isArray(messages) + ? this.sendEmailBatchWithTemplate(messages.map(mapToPostmark)) + : this.sendEmailWithTemplate(mapToPostmark(messages)) + } + + private async sendEmailWithTemplate( + message: TemplatedMessage + ): Promise { + this.logger.info( + `Sending templated email template=${message.TemplateAlias} from=${message.From} to=${message.To}`, + message.TemplateModel + ) + + if (!this.client) { + this.logger.info('Postmark API key not provided, skipping email send') + return undefined as unknown as MessageSendingResponse + } + + return await this.client.sendEmailWithTemplate(message) + } + + private async sendEmail(message: Message): Promise { + this.logger.info( + `Sending plain email subject=${message.Subject} from=${message.From} to=${message.To}`, + { text: message.TextBody, html: message.HtmlBody } + ) + + if (!this.client) { + this.logger.info('Postmark API key not provided, skipping email send') + return undefined as unknown as MessageSendingResponse + } + + return await this.client.sendEmail(message) + } + + private async sendEmailBatchWithTemplate( + messages: TemplatedMessage[] + ): Promise { + this.logger.info( + `Sending templated email batch templates=[${uniq( + messages.map(({ TemplateAlias }) => TemplateAlias) + ).join(',')}] count=${messages.length}` + ) + + return ( + await Promise.all( + chunk(messages, 500).map((chunk) => { + if (!this.client) { + this.logger.info('Postmark API key not provided, skipping email send') + return [] as MessageSendingResponse[] + } + return this.client.sendEmailBatchWithTemplates(chunk) + }) + ) + ).flat() + } + + private async sendEmailBatch(messages: Message[]): Promise { + this.logger.info( + `Sending templated email batch subjects=[${uniq( + messages.map(({ Subject }) => Subject) + ).join(',')}] count=${messages.length}` + ) + + return ( + await Promise.all( + chunk(messages, 500).map((chunk) => { + if (!this.client) { + this.logger.info('Postmark API key not provided, skipping email send') + return [] as MessageSendingResponse[] + } + return this.client.sendEmailBatch(chunk) + }) + ) + ).flat() + } +} diff --git a/libs/shared/src/types/email-types.ts b/libs/shared/src/types/email-types.ts index 14a1d1d2..7d39dfe4 100644 --- a/libs/shared/src/types/email-types.ts +++ b/libs/shared/src/types/email-types.ts @@ -1,3 +1,5 @@ +import type { MessageSendingResponse } from 'postmark/dist/client/models' + type EmailCommon = { from?: string to: string @@ -10,3 +12,5 @@ type PlainMessageContent = { subject: string; textBody?: string; htmlBody?: stri export type PlainEmailMessage = EmailCommon & PlainMessageContent export type TemplateEmailMessage = EmailCommon & { template: EmailTemplate } + +export type EmailSendingResponse = MessageSendingResponse