Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adds Sendgrid integration #275

Merged
merged 2 commits into from
Oct 8, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading