1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-08-09 23:45:21 +02:00

abstract email provider from email service

This commit is contained in:
Tyler Myracle 2024-01-20 14:05:24 -06:00
parent 4b007713a3
commit 118b2d037e
12 changed files with 238 additions and 151 deletions

View file

@ -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')
}
}

View file

@ -5,7 +5,6 @@ import type {
IInsightService, IInsightService,
ISecurityPricingService, ISecurityPricingService,
IPlanService, IPlanService,
IEmailService,
} from '@maybe-finance/server/features' } from '@maybe-finance/server/features'
import { import {
CryptoService, CryptoService,
@ -55,10 +54,10 @@ import prisma from './prisma'
import plaid, { getPlaidWebhookUrl } from './plaid' import plaid, { getPlaidWebhookUrl } from './plaid'
import teller, { getTellerWebhookUrl } from './teller' import teller, { getTellerWebhookUrl } from './teller'
import stripe from './stripe' import stripe from './stripe'
import postmark from './postmark'
import defineAbilityFor from './ability' import defineAbilityFor from './ability'
import env from '../../env' import env from '../../env'
import logger from '../lib/logger' import logger from '../lib/logger'
import { initializeEmailClient } from './email'
// shared services // shared services
@ -73,12 +72,12 @@ export const queueService = new QueueService(
: new BullQueueFactory(logger.child({ service: 'BullQueueFactory' }), env.NX_REDIS_URL) : 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' }), logger.child({ service: 'EmailService' }),
postmark, initializeEmailClient(),
{ {
from: env.NX_POSTMARK_FROM_ADDRESS, from: env.NX_EMAIL_FROM_ADDRESS,
replyTo: env.NX_POSTMARK_REPLY_TO_ADDRESS, replyTo: env.NX_EMAIL_REPLY_TO_ADDRESS,
} }
) )

View file

@ -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

View file

@ -66,9 +66,9 @@ const envSchema = z.object({
// Key to Cloudfront pub key // Key to Cloudfront pub key
NX_CDN_SIGNER_PUBKEY_ID: z.string().default('REPLACE_THIS'), NX_CDN_SIGNER_PUBKEY_ID: z.string().default('REPLACE_THIS'),
NX_POSTMARK_FROM_ADDRESS: z.string().default('account@maybe.co'), NX_EMAIL_FROM_ADDRESS: z.string().default('account@maybe.co'),
NX_POSTMARK_REPLY_TO_ADDRESS: z.string().default('support@maybe.co'), NX_EMAIL_REPLY_TO_ADDRESS: z.string().default('support@maybe.co'),
NX_POSTMARK_API_TOKEN: z.string().optional(), NX_EMAIL_PROVIDER_API_TOKEN: z.string().optional(),
}) })
const env = envSchema.parse(process.env) const env = envSchema.parse(process.env)

View file

@ -8,7 +8,6 @@ import type {
IUserProcessor, IUserProcessor,
ISecurityPricingService, ISecurityPricingService,
IUserService, IUserService,
IEmailService,
IEmailProcessor, IEmailProcessor,
} from '@maybe-finance/server/features' } from '@maybe-finance/server/features'
import { import {
@ -55,7 +54,7 @@ import logger from './logger'
import prisma from './prisma' import prisma from './prisma'
import plaid from './plaid' import plaid from './plaid'
import teller from './teller' import teller from './teller'
import postmark from './postmark' import { initializeEmailClient } from './email'
import stripe from './stripe' import stripe from './stripe'
import env from '../../env' import env from '../../env'
import { BullQueueEventHandler, WorkerErrorHandlerService } from '../services' import { BullQueueEventHandler, WorkerErrorHandlerService } from '../services'
@ -263,12 +262,12 @@ export const workerErrorHandlerService = new WorkerErrorHandlerService(
// send-email // send-email
export const emailService: IEmailService = new EmailService( export const emailService: EmailService = new EmailService(
logger.child({ service: 'EmailService' }), logger.child({ service: 'EmailService' }),
postmark, initializeEmailClient(),
{ {
from: env.NX_POSTMARK_FROM_ADDRESS, from: env.NX_EMAIL_FROM_ADDRESS,
replyTo: env.NX_POSTMARK_REPLY_TO_ADDRESS, replyTo: env.NX_EMAIL_REPLY_TO_ADDRESS,
} }
) )

View file

@ -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')
}
}

View file

@ -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

View file

@ -21,9 +21,9 @@ const envSchema = z.object({
NX_POLYGON_API_KEY: z.string().default(''), NX_POLYGON_API_KEY: z.string().default(''),
NX_POSTMARK_FROM_ADDRESS: z.string().default('account@maybe.co'), NX_EMAIL_FROM_ADDRESS: z.string().default('account@maybe.co'),
NX_POSTMARK_REPLY_TO_ADDRESS: z.string().default('support@maybe.co'), NX_EMAIL_REPLY_TO_ADDRESS: z.string().default('support@maybe.co'),
NX_POSTMARK_API_TOKEN: z.string().optional(), NX_EMAIL_PROVIDER_API_TOKEN: z.string().optional(),
NX_STRIPE_SECRET_KEY: z.string().default('sk_test_REPLACE_THIS'), NX_STRIPE_SECRET_KEY: z.string().default('sk_test_REPLACE_THIS'),
NX_CDN_PRIVATE_BUCKET: z.string().default('REPLACE_THIS'), NX_CDN_PRIVATE_BUCKET: z.string().default('REPLACE_THIS'),

View file

@ -1,7 +1,7 @@
import type { Logger } from 'winston' import type { Logger } from 'winston'
import type { PrismaClient } from '@prisma/client' import type { PrismaClient } from '@prisma/client'
import type { SendEmailQueueJobData } from '@maybe-finance/server/shared' import type { SendEmailQueueJobData } from '@maybe-finance/server/shared'
import type { IEmailService } from './email.service' import type { EmailService } from './email.service'
import { DateTime } from 'luxon' import { DateTime } from 'luxon'
export interface IEmailProcessor { export interface IEmailProcessor {
@ -13,17 +13,25 @@ export class EmailProcessor implements IEmailProcessor {
constructor( constructor(
private readonly logger: Logger, private readonly logger: Logger,
private readonly prisma: PrismaClient, private readonly prisma: PrismaClient,
private readonly emailService: IEmailService private readonly emailService: EmailService
) {} ) {}
async send(jobData: SendEmailQueueJobData) { async send(jobData: SendEmailQueueJobData) {
if ('type' in jobData) { if ('type' in jobData) {
switch (jobData.type) { switch (jobData.type) {
case 'plain': case 'plain':
this.emailService.send(jobData.messages) if (Array.isArray(jobData.messages)) {
this.emailService.send(jobData.messages)
} else {
this.emailService.send([jobData.messages])
}
break break
case 'template': case 'template':
this.emailService.sendTemplate(jobData.messages) if (Array.isArray(jobData.messages)) {
this.emailService.sendTemplate(jobData.messages)
} else {
this.emailService.sendTemplate([jobData.messages])
}
break break
case 'trial-reminders': case 'trial-reminders':
this.sendTrialEndReminders() this.sendTrialEndReminders()

View file

@ -1,53 +1,60 @@
import type { Logger } from 'winston' 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 { MessageSendingResponse } from 'postmark/dist/client/models'
import type { SharedType } from '@maybe-finance/shared' import type { SharedType } from '@maybe-finance/shared'
import { chunk, uniq } from 'lodash'
import { EmailTemplateSchema } from './email.schema'
export interface IEmailService { import { PostmarkEmailProvider } from './providers/postmark.provider'
send(messages: SharedType.PlainEmailMessage): Promise<MessageSendingResponse>
send(messages: SharedType.PlainEmailMessage[]): Promise<MessageSendingResponse[]> export interface IEmailProvider {
send(messages: SharedType.PlainEmailMessage): Promise<SharedType.EmailSendingResponse>
send(messages: SharedType.PlainEmailMessage[]): Promise<SharedType.EmailSendingResponse[]>
send( send(
messages: SharedType.PlainEmailMessage | SharedType.PlainEmailMessage[] messages: SharedType.PlainEmailMessage | SharedType.PlainEmailMessage[]
): Promise<MessageSendingResponse | MessageSendingResponse[]> ): Promise<any | any[]>
sendTemplate(
sendTemplate(messages: SharedType.TemplateEmailMessage): Promise<MessageSendingResponse> messages: SharedType.TemplateEmailMessage
sendTemplate(messages: SharedType.TemplateEmailMessage[]): Promise<MessageSendingResponse[]> ): Promise<SharedType.EmailSendingResponse>
sendTemplate(
messages: SharedType.TemplateEmailMessage[]
): Promise<SharedType.EmailSendingResponse[]>
sendTemplate( sendTemplate(
messages: SharedType.TemplateEmailMessage | SharedType.TemplateEmailMessage[] messages: SharedType.TemplateEmailMessage | SharedType.TemplateEmailMessage[]
): Promise<MessageSendingResponse | MessageSendingResponse[]> ): Promise<SharedType.EmailSendingResponse | SharedType.EmailSendingResponse[]>
} }
export class EmailService implements IEmailService { export class EmailService implements IEmailProvider {
private emailProvider: IEmailProvider
constructor( constructor(
private readonly logger: Logger, private readonly logger: Logger,
private readonly postmark: PostmarkServerClient | undefined, private readonly client: PostmarkServerClient | undefined,
private readonly defaultAddresses: { from: string; replyTo?: string } 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) * Sends plain email(s)
* *
* @returns success boolean(s) * @returns success boolean(s)
*/ */
async send(messages: SharedType.PlainEmailMessage): Promise<MessageSendingResponse> send(messages: SharedType.PlainEmailMessage): Promise<any>
async send(messages: SharedType.PlainEmailMessage[]): Promise<MessageSendingResponse[]> send(messages: SharedType.PlainEmailMessage[]): Promise<any[]>
async send( send(
messages: SharedType.PlainEmailMessage | SharedType.PlainEmailMessage[] messages: SharedType.PlainEmailMessage | SharedType.PlainEmailMessage[]
): Promise<MessageSendingResponse | MessageSendingResponse[]> { ): Promise<any | any[]> {
const mapToPostmark = (message: SharedType.PlainEmailMessage): Message => ({ return this.emailProvider.send(messages)
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))
} }
/** /**
@ -60,92 +67,6 @@ export class EmailService implements IEmailService {
async sendTemplate( async sendTemplate(
messages: SharedType.TemplateEmailMessage | SharedType.TemplateEmailMessage[] messages: SharedType.TemplateEmailMessage | SharedType.TemplateEmailMessage[]
): Promise<MessageSendingResponse | MessageSendingResponse[]> { ): Promise<MessageSendingResponse | MessageSendingResponse[]> {
const mapToPostmark = (message: SharedType.TemplateEmailMessage): TemplatedMessage => { return this.emailProvider.sendTemplate(messages)
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<MessageSendingResponse> {
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<MessageSendingResponse> {
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<MessageSendingResponse[]> {
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<MessageSendingResponse[]> {
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()
} }
} }

View file

@ -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<MessageSendingResponse>
async send(messages: SharedType.PlainEmailMessage[]): Promise<MessageSendingResponse[]>
async send(
messages: SharedType.PlainEmailMessage | SharedType.PlainEmailMessage[]
): Promise<MessageSendingResponse | MessageSendingResponse[]> {
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<MessageSendingResponse>
async sendTemplate(
messages: SharedType.TemplateEmailMessage[]
): Promise<MessageSendingResponse[]>
async sendTemplate(
messages: SharedType.TemplateEmailMessage | SharedType.TemplateEmailMessage[]
): Promise<MessageSendingResponse | MessageSendingResponse[]> {
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<MessageSendingResponse> {
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<MessageSendingResponse> {
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<MessageSendingResponse[]> {
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<MessageSendingResponse[]> {
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()
}
}

View file

@ -1,3 +1,5 @@
import type { MessageSendingResponse } from 'postmark/dist/client/models'
type EmailCommon = { type EmailCommon = {
from?: string from?: string
to: string to: string
@ -10,3 +12,5 @@ type PlainMessageContent = { subject: string; textBody?: string; htmlBody?: stri
export type PlainEmailMessage = EmailCommon & PlainMessageContent export type PlainEmailMessage = EmailCommon & PlainMessageContent
export type TemplateEmailMessage = EmailCommon & { template: EmailTemplate } export type TemplateEmailMessage = EmailCommon & { template: EmailTemplate }
export type EmailSendingResponse = MessageSendingResponse