Skip to content

Commit

Permalink
Adds Sendgrid integration (#275)
Browse files Browse the repository at this point in the history
  • Loading branch information
pushchris authored Oct 8, 2023
1 parent e567132 commit 196a48e
Show file tree
Hide file tree
Showing 14 changed files with 4,429 additions and 3,654 deletions.
2,360 changes: 1,309 additions & 1,051 deletions apps/platform/package-lock.json

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion apps/platform/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,9 @@
"mysql2": "^2.3.3",
"node-pushnotifications": "^2.1.0",
"node-schedule": "^2.1.0",
"nodemailer": "^6.7.6",
"nodemailer": "^6.9.5",
"nodemailer-mailgun-transport": "^2.1.5",
"nodemailer-sendgrid": "^1.0.3",
"openid-client": "^5.2.1",
"pino": "^8.1.0",
"pino-pretty": "^8.1.0",
Expand Down Expand Up @@ -77,6 +78,7 @@
"@types/node-schedule": "^2.1.0",
"@types/nodemailer": "^6.4.4",
"@types/nodemailer-mailgun-transport": "^1.4.3",
"@types/nodemailer-sendgrid": "^1.0.1",
"@types/supertest": "^2.0.12",
"@typescript-eslint/eslint-plugin": "^5.30.5",
"@typescript-eslint/parser": "^5.30.5",
Expand Down
7 changes: 6 additions & 1 deletion apps/platform/src/core/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,10 @@ export const isValid = (schema: any, data: any): IsValidSchema => {

export const parseError = (errors: ErrorObject[] | null | undefined = []) => {
if (errors === null || errors.length <= 0) return 'There was an unknown error validating your request.'
return capitalizeFirstLetter(errors[0].message ?? '')
const error = errors[0]
if (error.keyword === 'type') {
const path = error.instancePath.replace('/', ' ').trim()
return `The value of \`${path}\` must be a ${error.params.type}.`
}
return capitalizeFirstLetter(error.message ?? '')
}
2 changes: 1 addition & 1 deletion apps/platform/src/providers/email/EmailProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { LoggerProviderName } from '../LoggerProvider'
import Provider from '../Provider'
import { Email } from './Email'

export type EmailProviderName = 'ses' | 'smtp' | LoggerProviderName
export type EmailProviderName = 'ses' | 'smtp' | 'mailgun' | 'sendgrid' | LoggerProviderName

export default abstract class EmailProvider extends Provider {

Expand Down
12 changes: 10 additions & 2 deletions apps/platform/src/providers/email/MailgunEmailProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@ import crypto from 'crypto'
import mg from 'nodemailer-mailgun-transport'
import EmailProvider from './EmailProvider'
import Router = require('@koa/router')
import Provider, { ExternalProviderParams, ProviderControllers, ProviderSchema } from '../Provider'
import Provider, { ExternalProviderParams, ProviderControllers, ProviderSchema, ProviderSetupMeta } from '../Provider'
import { createController } from '../ProviderService'
import { decodeHashid } from '../../utilities'
import { decodeHashid, encodeHashid } from '../../utilities'
import { getUserFromEmail } from '../../users/UserRepository'
import { RequestError } from '../../core/errors'
import { getCampaign } from '../../campaigns/CampaignService'
import { trackMessageEvent } from '../../render/LinkService'
import App from '../../app'

interface MailgunDataParams {
api_key: string
Expand Down Expand Up @@ -51,6 +52,13 @@ export default class MailgunEmailProvider extends EmailProvider {
additionalProperties: false,
})

loadSetup(app: App): ProviderSetupMeta[] {
return [{
name: 'Webhook URL',
value: `${app.env.apiBaseUrl}/providers/${encodeHashid(this.id)}/${(this.constructor as any).namespace}`,
}]
}

boot() {
const auth = {
auth: {
Expand Down
104 changes: 104 additions & 0 deletions apps/platform/src/providers/email/SendGridEmailProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import nodemailer from 'nodemailer'
import mg from 'nodemailer-sendgrid'
import EmailProvider from './EmailProvider'
import Router = require('@koa/router')
import Provider, { ExternalProviderParams, ProviderControllers, ProviderSchema, ProviderSetupMeta } from '../Provider'
import { createController } from '../ProviderService'
import { decodeHashid, encodeHashid } from '../../utilities'
import { getUserFromEmail } from '../../users/UserRepository'
import { getCampaign } from '../../campaigns/CampaignService'
import { trackMessageEvent } from '../../render/LinkService'
import App from '../../app'
import { Email } from './Email'

interface SendGridDataParams {
api_key: string
}

type SendGridEmailProviderParams = Pick<SendGridEmailProvider, keyof ExternalProviderParams>

interface SendGridEvent {
email: string
event: string
'X-Campaign-Id': string
}

export default class SendGridEmailProvider extends EmailProvider {
api_key!: string

static namespace = 'sendgrid'
static meta = {
name: 'SendGrid',
url: 'https://sendgrid.com',
icon: 'https://parcelvoy.com/providers/sendgrid.svg',
paths: {
'Webhook URL': `/${this.namespace}`,
},
}

static schema = ProviderSchema<SendGridEmailProviderParams, SendGridDataParams>('SendGridProviderParams', {
type: 'object',
required: ['api_key'],
properties: {
api_key: {
type: 'string',
title: 'API Key',
},
},
additionalProperties: false,
})

loadSetup(app: App): ProviderSetupMeta[] {
return [{
name: 'Webhook URL',
value: `${app.env.apiBaseUrl}/providers/${encodeHashid(this.id)}/${(this.constructor as any).namespace}`,
}]
}

boot() {
this.transport = nodemailer.createTransport(mg({
apiKey: this.api_key,
}))
}

async send(message: Email): Promise<any> {
return super.send({
...message,
custom_args: message.headers,
unique_args: message.headers,
} as Email)
}

static controllers(): ProviderControllers {
const admin = createController('email', this)

const router = new Router<{ provider: Provider }>()
router.post(`/${this.namespace}`, async ctx => {
ctx.status = 204

const provider = ctx.state.provider
const events = ctx.request.body as SendGridEvent[]
for (const event of events) {
if (!['dropped', 'bounce', 'spamreport'].includes(event.event)) continue

const type = event.event === 'dropped' || event.event === 'bounce'
? 'bounced'
: 'complained'

// Get values from webhook to identify user and campaign
const campaignId = decodeHashid(event['X-Campaign-Id'])
if (!event.email || !campaignId) return

const projectId = provider.project_id
const user = await getUserFromEmail(projectId, event.email)
const campaign = await getCampaign(campaignId, projectId)
if (!user || !campaign) return

// Create an event and process the unsubscribe
await trackMessageEvent({ user, campaign }, type, 'unsubscribe')
}
})

return { admin, public: router }
}
}
2 changes: 2 additions & 0 deletions apps/platform/src/providers/email/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ import LoggerEmailProvider from './LoggerEmailProvider'
import MailgunEmailProvider from './MailgunEmailProvider'
import SESEmailProvider from './SESEmailProvider'
import SMTPEmailProvider from './SMPTEmailProvider'
import SendGridEmailProvider from './SendGridEmailProvider'

const typeMap = {
mailgun: MailgunEmailProvider,
sendgrid: SendGridEmailProvider,
ses: SESEmailProvider,
smtp: SMTPEmailProvider,
logger: LoggerEmailProvider,
Expand Down
3 changes: 2 additions & 1 deletion apps/platform/src/render/LinkService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,9 +113,10 @@ export const injectInBody = (html: string, injection: string, placement: 'start'
return html
}

export type TrackMessageType = 'opened' | 'clicked' | 'bounced' | 'complained' | 'failed'
export const trackMessageEvent = async (
parts: Partial<TrackedLinkExport>,
type: 'opened' | 'clicked' | 'bounced' | 'complained' | 'failed',
type: TrackMessageType,
action?: 'unsubscribe',
context?: any,
) => {
Expand Down
9 changes: 8 additions & 1 deletion apps/platform/src/render/Template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,14 @@ export class EmailTemplate extends Template {
type: 'object',
required: ['from', 'subject', 'html'],
properties: {
from: { type: 'string' },
from: {
type: 'object',
required: ['address'],
properties: {
name: { type: 'string', nullable: true },
address: { type: 'string' },
},
},
subject: { type: 'string' },
text: { type: 'string' },
html: { type: 'string' },
Expand Down
6 changes: 6 additions & 0 deletions apps/platform/src/render/TemplateService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,12 @@ export const validateTemplates = async (projectId: number, campaignId: number) =

export const sendProof = async (template: TemplateType, variables: Variables, recipient: string) => {

// Ensure proof is ready to send
console.log(template)
const [isValid, error] = template.validate()
console.log(error)
if (!isValid) throw error

const campaign = await getCampaign(template.campaign_id, template.project_id)
const project = await getProject(template.project_id)
if (!campaign || !project) throw new RequestError(CampaignError.CampaignDoesNotExist)
Expand Down
Loading

0 comments on commit 196a48e

Please sign in to comment.