1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-08-08 15:05:22 +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,
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,
}
)

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

View file

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

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_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'),

View file

@ -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':
if (Array.isArray(jobData.messages)) {
this.emailService.send(jobData.messages)
} else {
this.emailService.send([jobData.messages])
}
break
case 'template':
if (Array.isArray(jobData.messages)) {
this.emailService.sendTemplate(jobData.messages)
} else {
this.emailService.sendTemplate([jobData.messages])
}
break
case 'trial-reminders':
this.sendTrialEndReminders()

View file

@ -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<MessageSendingResponse>
send(messages: SharedType.PlainEmailMessage[]): Promise<MessageSendingResponse[]>
import { PostmarkEmailProvider } from './providers/postmark.provider'
export interface IEmailProvider {
send(messages: SharedType.PlainEmailMessage): Promise<SharedType.EmailSendingResponse>
send(messages: SharedType.PlainEmailMessage[]): Promise<SharedType.EmailSendingResponse[]>
send(
messages: SharedType.PlainEmailMessage | SharedType.PlainEmailMessage[]
): Promise<MessageSendingResponse | MessageSendingResponse[]>
sendTemplate(messages: SharedType.TemplateEmailMessage): Promise<MessageSendingResponse>
sendTemplate(messages: SharedType.TemplateEmailMessage[]): Promise<MessageSendingResponse[]>
): Promise<any | any[]>
sendTemplate(
messages: SharedType.TemplateEmailMessage
): Promise<SharedType.EmailSendingResponse>
sendTemplate(
messages: SharedType.TemplateEmailMessage[]
): Promise<SharedType.EmailSendingResponse[]>
sendTemplate(
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(
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<MessageSendingResponse>
async send(messages: SharedType.PlainEmailMessage[]): Promise<MessageSendingResponse[]>
async send(
send(messages: SharedType.PlainEmailMessage): Promise<any>
send(messages: SharedType.PlainEmailMessage[]): Promise<any[]>
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))
): Promise<any | any[]> {
return this.emailProvider.send(messages)
}
/**
@ -60,92 +67,6 @@ export class EmailService implements IEmailService {
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.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()
return this.emailProvider.sendTemplate(messages)
}
}

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 = {
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