Improve provider unsubscribes and lookups

This commit is contained in:
Chris Anderson 2023-03-22 17:10:46 -05:00
parent e2fb6b9155
commit 4544f07d65
55 changed files with 593 additions and 250 deletions

View file

@ -30,6 +30,7 @@
"handlebars": "^4.7.7",
"handlebars-utils": "^1.0.6",
"hashids": "^2.2.10",
"html-to-text": "^9.0.4",
"jsonpath": "^1.1.1",
"jsonwebtoken": "^8.5.1",
"knex": "^2.3.0",
@ -47,6 +48,7 @@
},
"devDependencies": {
"@types/busboy": "^1.5.0",
"@types/html-to-text": "^9.0.0",
"@types/jest": "^28.1.6",
"@types/jsonpath": "^0.2.0",
"@types/jsonwebtoken": "^8.5.9",
@ -2900,6 +2902,18 @@
"uuid": "dist/bin/uuid"
}
},
"node_modules/@selderee/plugin-htmlparser2": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.10.0.tgz",
"integrity": "sha512-gW69MEamZ4wk1OsOq1nG1jcyhXIQcnrsX5JwixVw/9xaiav8TCyjESAruu1Rz9yyInhgBXxkNwMeygKnN2uxNA==",
"dependencies": {
"domhandler": "^5.0.3",
"selderee": "^0.10.0"
},
"funding": {
"url": "https://ko-fi.com/killymxi"
}
},
"node_modules/@sinclair/typebox": {
"version": "0.24.51",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.24.51.tgz",
@ -3088,6 +3102,12 @@
"@types/node": "*"
}
},
"node_modules/@types/html-to-text": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/@types/html-to-text/-/html-to-text-9.0.0.tgz",
"integrity": "sha512-FnF3p2FJZ1kJT/0C/lmBzw7HSlH3RhtACVYyrwUsJoCmFNuiLpusWT2FWWB7P9A48CaYpvD6Q2fprn7sZeffpw==",
"dev": true
},
"node_modules/@types/http-assert": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/@types/http-assert/-/http-assert-1.5.3.tgz",
@ -4555,7 +4575,6 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.0.tgz",
"integrity": "sha512-z2wJZXrmeHdvYJp/Ux55wIjqo81G5Bp4c+oELTW+7ar6SogWHajt5a9gO3s3IDaGSAXjDk0vlQKN3rms8ab3og==",
"dev": true,
"engines": {
"node": ">=0.10.0"
}
@ -4675,6 +4694,57 @@
"node": ">=6.0.0"
}
},
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"entities": "^4.2.0"
},
"funding": {
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
]
},
"node_modules/domhandler": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"dependencies": {
"domelementtype": "^2.3.0"
},
"engines": {
"node": ">= 4"
},
"funding": {
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/domutils": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.0.1.tgz",
"integrity": "sha512-z08c1l761iKhDFtfXO04C7kTdPBLi41zwOZl00WS8b5eiaebNpY00HKbztwBq+e3vyqWNwWF3mP9YLUeqIrF+Q==",
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.1"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/dotenv": {
"version": "16.0.3",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.0.3.tgz",
@ -4753,6 +4823,17 @@
"once": "^1.4.0"
}
},
"node_modules/entities": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.4.0.tgz",
"integrity": "sha512-oYp7156SP8LkeGD0GF85ad1X9Ai79WtRsZ2gxJqtBuzH+98YUV6jkHEKlZkMbcrjJjIVJNIDP/3WL9wQkoPbWA==",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/error-ex": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz",
@ -6192,6 +6273,39 @@
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
"dev": true
},
"node_modules/html-to-text": {
"version": "9.0.4",
"resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.4.tgz",
"integrity": "sha512-ckrQ5N2yZS7qSgKxUbqrBZ02NxD5cSy7KuYjCNIf+HWbdzY3fbjYjQsoRIl6TiaZ4+XWOi0ggFP8/pmgCK/o+A==",
"dependencies": {
"@selderee/plugin-htmlparser2": "^0.10.0",
"deepmerge": "^4.3.0",
"dom-serializer": "^2.0.0",
"htmlparser2": "^8.0.1",
"selderee": "^0.10.0"
},
"engines": {
"node": ">=14"
}
},
"node_modules/htmlparser2": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.1.tgz",
"integrity": "sha512-4lVbmc1diZC7GUJQtRQ5yBAeUCL1exyMwmForWkRLnwyzWBFxN633SALPMGYaWZvKe9j1pRZJpauvmxENSp/EA==",
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"domutils": "^3.0.1",
"entities": "^4.3.0"
}
},
"node_modules/http_ece": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.1.0.tgz",
@ -7854,6 +7968,14 @@
"node": ">= 0.6"
}
},
"node_modules/leac": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz",
"integrity": "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==",
"funding": {
"url": "https://ko-fi.com/killymxi"
}
},
"node_modules/leven": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
@ -8683,6 +8805,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/parseley": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/parseley/-/parseley-0.11.0.tgz",
"integrity": "sha512-VfcwXlBWgTF+unPcr7yu3HSSA6QUdDaDnrHcytVfj5Z8azAyKBDrYnSIfeSxlrEayndNcLmrXzg+Vxbo6DWRXQ==",
"dependencies": {
"leac": "^0.6.0",
"peberminta": "^0.8.0"
},
"funding": {
"url": "https://ko-fi.com/killymxi"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@ -8736,6 +8870,14 @@
"node": ">=8"
}
},
"node_modules/peberminta": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.8.0.tgz",
"integrity": "sha512-YYEs+eauIjDH5nUEGi18EohWE0nV2QbGTqmxQcqgZ/0g+laPCQmuIqq7EBLVi9uim9zMgfJv0QBZEnQ3uHw/Tw==",
"funding": {
"url": "https://ko-fi.com/killymxi"
}
},
"node_modules/performance-now": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
@ -9445,6 +9587,17 @@
"resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz",
"integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw=="
},
"node_modules/selderee": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/selderee/-/selderee-0.10.0.tgz",
"integrity": "sha512-DEL/RW/f4qLw/NrVg97xKaEBC8IpzIG2fvxnzCp3Z4yk4jQ3MXom+Imav9wApjxX2dfS3eW7x0DXafJr85i39A==",
"dependencies": {
"parseley": "^0.11.0"
},
"funding": {
"url": "https://ko-fi.com/killymxi"
}
},
"node_modules/semver": {
"version": "7.3.8",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz",

View file

@ -24,6 +24,7 @@
"handlebars": "^4.7.7",
"handlebars-utils": "^1.0.6",
"hashids": "^2.2.10",
"html-to-text": "^9.0.4",
"jsonpath": "^1.1.1",
"jsonwebtoken": "^8.5.1",
"knex": "^2.3.0",
@ -50,6 +51,7 @@
},
"devDependencies": {
"@types/busboy": "^1.5.0",
"@types/html-to-text": "^9.0.0",
"@types/jest": "^28.1.6",
"@types/jsonpath": "^0.2.0",
"@types/jsonwebtoken": "^8.5.9",

View file

@ -8,7 +8,7 @@ import SubscriptionController, { publicRouter as PublicSubscriptionController }
import JourneyController from '../journey/JourneyController'
import ImageController from '../storage/ImageController'
import AuthController from '../auth/AuthController'
import ProviderController from '../providers/ProviderController'
import { adminRouter as AdminProviderController, publicRouter as PublicProviderController } from '../providers/ProviderController'
import LinkController from '../render/LinkController'
import TemplateController from '../render/TemplateController'
import UserController from '../users/UserController'
@ -73,7 +73,7 @@ export const projectRouter = (prefix = '/projects/:project') => {
JourneyController,
ImageController,
TemplateController,
ProviderController,
AdminProviderController,
ProjectAdminController,
ProjectApiKeyController,
UserController,
@ -120,6 +120,7 @@ export const publicRouter = () => {
return register(router,
AuthController,
PublicSubscriptionController,
PublicProviderController,
LinkController,
)
}

View file

@ -1,6 +1,7 @@
import Campaign from '../campaigns/Campaign'
import { updateSendState } from '../campaigns/CampaignService'
import Project from '../projects/Project'
import { RenderContext } from '../render'
import Template, { TemplateType } from '../render/Template'
import { User } from '../users/User'
import { UserEvent } from '../users/UserEvent'
@ -12,6 +13,7 @@ interface MessageTriggerHydrated<T> {
campaign: Campaign
template: T
project: Project
context: RenderContext
}
export async function loadSendJob<T extends TemplateType>({ campaign_id, user_id, event_id }: MessageTrigger): Promise<MessageTriggerHydrated<T> | undefined> {
@ -42,5 +44,11 @@ export async function loadSendJob<T extends TemplateType>({ campaign_id, user_id
return
}
return { campaign, template: template.map() as T, user, project, event }
const context = {
campaign_id: campaign.id,
template_id: template.id,
subscription_id: campaign.subscription_id,
}
return { campaign, template: template.map() as T, user, project, event, context }
}

View file

@ -1,3 +1,4 @@
import Router from '@koa/router'
import Model, { ModelParams } from '../core/Model'
import { JSONSchemaType } from '../core/validate'
@ -86,3 +87,8 @@ export type ProviderMap<T extends Provider> = (record: any) => T
export type ProviderParams = Omit<Provider, ModelParams>
export type ExternalProviderParams = Omit<ProviderParams, 'group'>
export interface ProviderControllers {
admin: Router
public?: Router
}

View file

@ -1,38 +1,61 @@
import Router from '@koa/router'
import { ProjectState } from '../auth/AuthMiddleware'
import { searchParamsSchema } from '../core/searchParams'
import { extractQueryParams } from '../utilities'
import { decodeHashid, extractQueryParams } from '../utilities'
import { loadAnalyticsControllers } from './analytics'
import { loadEmailControllers } from './email'
import { ProviderMeta } from './Provider'
import { getProvider } from './ProviderRepository'
import { allProviders, pagedProviders } from './ProviderService'
import { loadPushControllers } from './push'
import { loadTextControllers } from './text'
import { loadWebhookControllers } from './webhook'
const router = new Router<ProjectState>({
const adminRouter = new Router<ProjectState>({
prefix: '/providers',
})
const publicRouter = new Router({
prefix: '/providers/:hash',
})
publicRouter.param('hash', async (value, ctx, next) => {
try {
const providerId = decodeHashid(value)
if (!providerId) {
ctx.throw(404)
return
}
ctx.state.provider = await getProvider(providerId)
if (!ctx.state.provider) {
ctx.throw(404)
return
}
return await next()
} catch {
ctx.throw(404)
}
})
const providers: ProviderMeta[] = []
const routers = { admin: adminRouter, public: publicRouter }
loadTextControllers(routers, providers)
loadEmailControllers(routers, providers)
loadWebhookControllers(routers, providers)
loadPushControllers(routers, providers)
loadAnalyticsControllers(routers, providers)
loadTextControllers(router, providers)
loadEmailControllers(router, providers)
loadWebhookControllers(router, providers)
loadPushControllers(router, providers)
loadAnalyticsControllers(router, providers)
router.get('/', async ctx => {
adminRouter.get('/', async ctx => {
const params = extractQueryParams(ctx.query, searchParamsSchema)
ctx.body = await pagedProviders(params, ctx.state.project.id)
})
router.get('/all', async ctx => {
adminRouter.get('/all', async ctx => {
ctx.body = await allProviders(ctx.state.project.id)
})
router.get('/meta', async ctx => {
adminRouter.get('/meta', async ctx => {
ctx.body = providers
})
export default router
export { adminRouter, publicRouter }

View file

@ -1,21 +1,18 @@
import App from '../app'
import Provider, { ProviderMap, ProviderParams, ExternalProviderParams } from './Provider'
export const getProvider = async (id: number, projectId: number) => {
return await Provider.find(id, qb => qb.where('project_id', projectId))
export const getProvider = async (id: number, projectId?: number) => {
return await Provider.find(id, qb => projectId ? qb.where('project_id', projectId) : qb)
}
export const loadProvider = async <T extends Provider>(id: number, projectId: number, mapper: ProviderMap<T>, app = App.main) => {
export const loadProvider = async <T extends Provider>(id: number, mapper: ProviderMap<T>, projectId?: number, app = App.main) => {
// Check if value is cached in memory
const cache = app.get<T>(Provider.cacheKey.internal(id))
if (cache) return cache
// If not, fetch from DB
const record = await Provider.table()
.where('project_id', projectId)
.where('id', id)
.first()
const record = await Provider.find(id, qb => projectId ? qb.where('project_id', projectId) : qb, app.db)
if (!record) return
// Map to appropriate type, cache and return
@ -44,24 +41,6 @@ export const loadDefaultProvider = async <T extends Provider>(group: string, pro
return mappedValue
}
export const getProviderByExternalId = async <T extends Provider>(externalId: string, mapper: ProviderMap<T>, app = App.main): Promise<T | undefined> => {
// Check if value is cached in memory
const cache = app.get<T>(Provider.cacheKey.external(externalId))
if (cache) return cache
// If not, fetch from DB
const record = await Provider.table()
.where('external_id', externalId)
.first()
if (!record) return
// Map to appropriate type, cache and return
const mappedValue = mapper(record)
cacheProvider(mappedValue)
return mappedValue
}
export const createProvider = async (projectId: number, params: ProviderParams) => {
return await Provider.insertAndFetch({
...params,

View file

@ -2,7 +2,7 @@ import Router from '@koa/router'
import { ProjectState } from '../auth/AuthMiddleware'
import { SearchParams } from '../core/searchParams'
import { JSONSchemaType, validate } from '../core/validate'
import Provider, { ExternalProviderParams, ProviderGroup } from './Provider'
import Provider, { ExternalProviderParams, ProviderControllers, ProviderGroup, ProviderMeta } from './Provider'
import { createProvider, getProvider, updateProvider } from './ProviderRepository'
export const allProviders = async (projectId: number) => {
@ -17,6 +17,30 @@ export const pagedProviders = async (params: SearchParams, projectId: number) =>
)
}
export const loadControllers = <T extends Record<string, any>>(typeMap: T, channel: string) => {
return async (routers: ProviderControllers, providers: ProviderMeta[]) => {
for (const type of Object.values(typeMap)) {
const { admin, public: publicRouter }: ProviderControllers = type.controllers()
routers.admin.use(
admin.routes(),
admin.allowedMethods(),
)
if (routers.public && publicRouter) {
routers.public.use(
publicRouter.routes(),
publicRouter.allowedMethods(),
)
}
providers.push({
...type.meta,
type: type.namespace,
channel,
schema: type.schema,
})
}
}
}
export const createController = <T extends ExternalProviderParams>(group: ProviderGroup, type: string, schema: JSONSchemaType<T>, externalKey?: (payload: T) => string): Router => {
const router = new Router<
ProjectState & { provider?: Provider }

View file

@ -1,6 +1,5 @@
import Router from '@koa/router'
import { Analytics as Segment } from '@segment/analytics-node'
import { ProviderParams, ProviderSchema } from '../Provider'
import { ProviderControllers, ProviderParams, ProviderSchema } from '../Provider'
import { AnalyticsTypeConfig } from './Analytics'
import { AnalyticsProvider, AnalyticsUserEvent } from './AnalyticsProvider'
import { createController } from '../ProviderService'
@ -52,7 +51,7 @@ export default class SegmentAnalyticsProvider extends AnalyticsProvider {
})
}
static controllers(): Router {
return createController('analytics', this.namespace, this.schema)
static controllers(): ProviderControllers {
return { admin: createController('analytics', this.namespace, this.schema) }
}
}

View file

@ -1,6 +1,5 @@
import Router from '@koa/router'
import { ProviderMeta } from '../Provider'
import { loadDefaultProvider } from '../ProviderRepository'
import { loadControllers } from '../ProviderService'
import Analytics from './Analytics'
import { AnalyticsProvider, AnalyticsProviderName } from './AnalyticsProvider'
import SegmentAnalyticsProvider from './SegmentProvider'
@ -18,18 +17,4 @@ export const loadAnalytics = async (projectId: number): Promise<Analytics> => {
return new Analytics(provider)
}
export const loadAnalyticsControllers = async (router: Router, providers: ProviderMeta[]) => {
for (const type of Object.values(typeMap)) {
const controllers = type.controllers()
router.use(
controllers.routes(),
controllers.allowedMethods(),
)
providers.push({
...type.meta,
type: type.namespace,
channel: 'analytics',
schema: type.schema,
})
}
}
export const loadAnalyticsControllers = loadControllers(typeMap, 'analytics')

View file

@ -7,4 +7,5 @@ export interface Email {
subject: string
text: string
html: string
headers?: Record<string, any>
}

View file

@ -1,5 +1,6 @@
import { Variables, Wrap } from '../../render'
import { EmailTemplate } from '../../render/Template'
import { Email } from './Email'
import EmailProvider from './EmailProvider'
export default class EmailChannel {
@ -19,7 +20,7 @@ export default class EmailChannel {
// TODO: Explore caching the Handlebars template
// before passing in variables for better performance
const compiled = template.compile(variables)
const email = {
const email: Email = {
...compiled,
to: variables.user.email,
html: Wrap({
@ -27,6 +28,10 @@ export default class EmailChannel {
preheader: compiled.preheader,
variables,
}), // Add link and open tracking
headers: {
'X-Campaign-Id': variables.context.campaign_id,
'X-Subscription-Id': variables.context.subscription_id,
},
}
await this.provider.send(email)
}

View file

@ -19,8 +19,9 @@ export default class EmailJob extends Job {
const { campaign, template, user, project, event } = data
const context = {
campaign_id: campaign?.id,
template_id: template?.id,
campaign_id: campaign.id,
template_id: template.id,
subscription_id: campaign.subscription_id,
}
// Send and render email

View file

@ -1,7 +1,6 @@
import Router from '@koa/router'
import { logger } from '../../config/logger'
import { randomInt, sleep } from '../../utilities'
import { ExternalProviderParams, ProviderSchema } from '../Provider'
import { ExternalProviderParams, ProviderControllers, ProviderSchema } from '../Provider'
import { createController } from '../ProviderService'
import { Email } from './Email'
import EmailProvider from './EmailProvider'
@ -31,7 +30,7 @@ export default class LoggerEmailProvider extends EmailProvider {
return true
}
static controllers(): Router {
return createController('email', this.namespace, this.schema)
static controllers(): ProviderControllers {
return { admin: createController('email', this.namespace, this.schema) }
}
}

View file

@ -3,8 +3,13 @@ import aws = require('@aws-sdk/client-ses')
import { AWSConfig } from '../../core/aws'
import EmailProvider from './EmailProvider'
import Router = require('@koa/router')
import { ExternalProviderParams, ProviderSchema } from '../Provider'
import Provider, { ExternalProviderParams, ProviderControllers, ProviderSchema } from '../Provider'
import { createController } from '../ProviderService'
import { createEvent } from '../../users/UserEventRepository'
import { secondsAgo } from '../../utilities'
import { getUserFromEmail } from '../../users/UserRepository'
import { unsubscribe } from '../../subscriptions/SubscriptionService'
import { RequestError } from '../../core/errors'
interface SESDataParams {
config: AWSConfig
@ -58,7 +63,56 @@ export default class SESEmailProvider extends EmailProvider {
})
}
static controllers(): Router {
return createController('email', this.namespace, this.schema)
static controllers(): ProviderControllers {
const admin = createController('email', this.namespace, this.schema)
const router = new Router<{ provider: Provider }>()
router.post(`/${this.namespace}`, async ctx => {
const { Type, Message, SubscribeURL, Timestamp } = ctx.request.body
const timestamp = Date.parse(Timestamp)
if (!Type || secondsAgo(timestamp) > 30) throw new RequestError('Unsupported SES Body')
ctx.status = 204
// If we are getting an SNS topic confirmation, ping back
if (Type === 'SubscriptionConfirmation' && SubscribeURL) {
await fetch(SubscribeURL)
} else if (Type === 'Notification' && Message) {
await this.rejection(ctx.state.provider.project_id, Message)
}
})
return { admin, public: router }
}
static async rejection(projectId: number, message: string) {
const getHeader = (
headers: Array<{ name: string, value: string }>,
key: string,
) => (headers ?? []).find((item) => item.name === key)?.value
const json = JSON.parse(message) as Record<string, any>
const { notificationType, mail: { destination, headers } } = json
const email: string | undefined = destination[0]
const subscriptionId = getHeader(headers, 'X-Subscription-Id')
const campaignId = getHeader(headers, 'X-Campaign-Id')
if (!email || !subscriptionId) return
const user = await getUserFromEmail(projectId, email)
if (!user) return
const name = notificationType === 'Bounce' ? 'bounce' : 'complaint'
await createEvent(user, {
name,
data: {
subscription_id: subscriptionId,
campaign_id: campaignId,
},
})
if (name === 'bounce' && subscriptionId) {
await unsubscribe(user.id, parseInt(subscriptionId))
}
}
}

View file

@ -1,6 +1,5 @@
import Router from '@koa/router'
import nodemailer from 'nodemailer'
import { ExternalProviderParams, ProviderSchema } from '../Provider'
import { ExternalProviderParams, ProviderControllers, ProviderSchema } from '../Provider'
import { createController } from '../ProviderService'
import EmailProvider from './EmailProvider'
@ -55,7 +54,7 @@ export default class SMTPEmailProvider extends EmailProvider {
})
}
static controllers(): Router {
return createController('email', this.namespace, this.schema)
static controllers(): ProviderControllers {
return { admin: createController('email', this.namespace, this.schema) }
}
}

View file

@ -1,6 +1,5 @@
import Router from '@koa/router'
import { ProviderMeta } from '../Provider'
import { loadProvider } from '../ProviderRepository'
import { loadControllers } from '../ProviderService'
import EmailChannel from './EmailChannel'
import EmailProvider, { EmailProviderName } from './EmailProvider'
import LoggerEmailProvider from './LoggerEmailProvider'
@ -18,23 +17,9 @@ export const providerMap = (record: { type: EmailProviderName }): EmailProvider
}
export const loadEmailChannel = async (providerId: number, projectId: number): Promise<EmailChannel | undefined> => {
const provider = await loadProvider(providerId, projectId, providerMap)
const provider = await loadProvider(providerId, providerMap, projectId)
if (!provider) return
return new EmailChannel(provider)
}
export const loadEmailControllers = async (router: Router, providers: ProviderMeta[]) => {
for (const type of Object.values(typeMap)) {
const controllers = type.controllers()
router.use(
controllers.routes(),
controllers.allowedMethods(),
)
providers.push({
...type.meta,
type: type.namespace,
channel: 'email',
schema: type.schema,
})
}
}
export const loadEmailControllers = loadControllers(typeMap, 'email')

View file

@ -2,8 +2,7 @@ import { PushProvider } from './PushProvider'
import PushNotifications from 'node-pushnotifications'
import { Push, PushResponse } from './Push'
import PushError from './PushError'
import Router from '@koa/router'
import { ExternalProviderParams, ProviderSchema } from '../Provider'
import { ExternalProviderParams, ProviderControllers, ProviderSchema } from '../Provider'
import { createController } from '../ProviderService'
interface APNParams {
@ -111,7 +110,7 @@ export default class LocalPushProvider extends PushProvider {
}
}
static controllers(): Router {
return createController('push', this.namespace, this.schema)
static controllers(): ProviderControllers {
return { admin: createController('push', this.namespace, this.schema) }
}
}

View file

@ -1,7 +1,6 @@
import Router from '@koa/router'
import { logger } from '../../config/logger'
import { randomInt, sleep } from '../../utilities'
import { ExternalProviderParams, ProviderSchema } from '../Provider'
import { ExternalProviderParams, ProviderControllers, ProviderSchema } from '../Provider'
import { createController } from '../ProviderService'
import { Push, PushResponse } from './Push'
import { PushProvider } from './PushProvider'
@ -31,7 +30,7 @@ export default class LoggerPushProvider extends PushProvider {
}
}
static controllers(): Router {
return createController('email', 'push', this.schema)
static controllers(): ProviderControllers {
return { admin: createController('email', 'push', this.schema) }
}
}

View file

@ -21,8 +21,9 @@ export default class PushJob extends Job {
const { campaign, template, user, project, event } = data
const context = {
campaign_id: campaign?.id,
template_id: template?.id,
campaign_id: campaign.id,
template_id: template.id,
subscription_id: campaign.subscription_id,
}
try {

View file

@ -1,6 +1,5 @@
import Router from '@koa/router'
import { ProviderMeta } from '../Provider'
import { loadProvider } from '../ProviderRepository'
import { loadControllers } from '../ProviderService'
import LocalPushProvider from './LocalPushProvider'
import LoggerPushProvider from './LoggerPushProvider'
import PushChannel from './PushChannel'
@ -16,23 +15,9 @@ export const providerMap = (record: { type: PushProviderName }): PushProvider =>
}
export const loadPushChannel = async (providerId: number, projectId: number): Promise<PushChannel | undefined> => {
const provider = await loadProvider(providerId, projectId, providerMap)
const provider = await loadProvider(providerId, providerMap, projectId)
if (!provider) return
return new PushChannel(provider)
}
export const loadPushControllers = async (router: Router, providers: ProviderMeta[]) => {
for (const type of Object.values(typeMap)) {
const controllers = type.controllers()
router.use(
controllers.routes(),
controllers.allowedMethods(),
)
providers.push({
...type.meta,
type: type.namespace,
channel: 'push',
schema: type.schema,
})
}
}
export const loadPushControllers = loadControllers(typeMap, 'push')

View file

@ -1,7 +1,6 @@
import Router from '@koa/router'
import { logger } from '../../config/logger'
import { randomInt, sleep } from '../../utilities'
import { ProviderParams, ProviderSchema } from '../Provider'
import { ProviderControllers, ProviderParams, ProviderSchema } from '../Provider'
import { createController } from '../ProviderService'
import { InboundTextMessage, TextMessage, TextResponse } from './TextMessage'
import { TextProvider } from './TextProvider'
@ -40,7 +39,7 @@ export default class LoggerTextProvider extends TextProvider {
}
}
static controllers(): Router {
return createController('text', this.namespace, this.schema)
static controllers(): ProviderControllers {
return { admin: createController('text', this.namespace, this.schema) }
}
}

View file

@ -1,8 +1,7 @@
import { InboundTextMessage, TextMessage, TextResponse } from './TextMessage'
import TextError from './TextError'
import { TextProvider } from './TextProvider'
import { ProviderParams, ProviderSchema } from '../Provider'
import Router from '@koa/router'
import { ProviderControllers, ProviderParams, ProviderSchema } from '../Provider'
import { createController } from '../ProviderService'
interface NexmoDataParams {
@ -83,7 +82,8 @@ export default class NexmoTextProvider extends TextProvider {
}
}
static controllers(): Router {
return createController('text', this.namespace, this.schema, (payload) => payload.data.phone_number)
static controllers(): ProviderControllers {
const admin = createController('text', this.namespace, this.schema)
return { admin, public: this.unsubscribe(this.namespace) }
}
}

View file

@ -1,5 +1,4 @@
import Router from '@koa/router'
import { ExternalProviderParams, ProviderSchema } from '../Provider'
import { ExternalProviderParams, ProviderControllers, ProviderSchema } from '../Provider'
import { createController } from '../ProviderService'
import { InboundTextMessage, TextMessage, TextResponse } from './TextMessage'
import { TextProvider } from './TextProvider'
@ -77,7 +76,8 @@ export default class PlivoTextProvider extends TextProvider {
}
}
static controllers(): Router {
return createController('text', this.namespace, this.schema, (payload) => payload.data.phone_number)
static controllers(): ProviderControllers {
const admin = createController('text', this.namespace, this.schema)
return { admin, public: this.unsubscribe(this.namespace) }
}
}

View file

@ -18,11 +18,7 @@ export default class TextJob extends Job {
const data = await loadSendJob<TextTemplate>(trigger)
if (!data) return
const { campaign, template, user, project, event } = data
const context = {
campaign_id: campaign?.id,
template_id: template?.id,
}
const { campaign, template, user, project, event, context } = data
// Send and render text
const channel = await loadTextChannel(campaign.provider_id, project.id)

View file

@ -1,3 +1,6 @@
import Router from '@koa/router'
import { loadTextChannel } from '.'
import { unsubscribeSms } from '../../subscriptions/SubscriptionService'
import Provider from '../Provider'
import { InboundTextMessage, TextMessage, TextResponse } from './TextMessage'
@ -6,4 +9,18 @@ export type TextProviderName = 'nexmo' | 'plivo' | 'twilio' | 'logger'
export abstract class TextProvider extends Provider {
abstract send(message: TextMessage): Promise<TextResponse>
abstract parseInbound(inbound: any): InboundTextMessage
static unsubscribe(namespace: string) {
const router = new Router<{ provider: Provider }>()
router.post(`/${namespace}/unsubscribe`, async ctx => {
const channel = await loadTextChannel(ctx.state.provider.id)
if (!channel) return
// Always return with positive status code
ctx.status = 204
await unsubscribeSms(channel.provider, ctx.request.body)
})
return router
}
}

View file

@ -1,5 +1,4 @@
import Router from '@koa/router'
import { ExternalProviderParams, ProviderSchema } from '../Provider'
import { ExternalProviderParams, ProviderControllers, ProviderSchema } from '../Provider'
import { createController } from '../ProviderService'
import { InboundTextMessage, TextMessage, TextResponse } from './TextMessage'
import { TextProvider } from './TextProvider'
@ -82,7 +81,8 @@ export default class TwilioTextProvider extends TextProvider {
}
}
static controllers(): Router {
return createController('text', this.namespace, this.schema, (payload) => payload.data.phone_number)
static controllers(): ProviderControllers {
const admin = createController('text', this.namespace, this.schema)
return { admin, public: this.unsubscribe(this.namespace) }
}
}

View file

@ -1,6 +1,5 @@
import Router from '@koa/router'
import { ProviderMeta } from '../Provider'
import { getProviderByExternalId, loadProvider } from '../ProviderRepository'
import { loadProvider } from '../ProviderRepository'
import { loadControllers } from '../ProviderService'
import LoggerTextProvider from './LoggerTextProvider'
import NexmoTextProvider from './NexmoTextProvider'
import PlivoTextProvider from './PlivoTextProvider'
@ -19,30 +18,10 @@ export const providerMap = (record: { type: TextProviderName }): TextProvider =>
return typeMap[record.type].fromJson(record)
}
export const loadTextChannel = async (providerId: number, projectId: number): Promise<TextChannel | undefined> => {
const provider = await loadProvider(providerId, projectId, providerMap)
export const loadTextChannel = async (providerId: number, projectId?: number): Promise<TextChannel | undefined> => {
const provider = await loadProvider(providerId, providerMap, projectId)
if (!provider) return
return new TextChannel(provider)
}
export const loadTextChannelInbound = async (inboundNumber: string): Promise<TextChannel | undefined> => {
const provider = await getProviderByExternalId(inboundNumber, providerMap)
if (!provider) return
return new TextChannel(provider)
}
export const loadTextControllers = async (router: Router, providers: ProviderMeta[]) => {
for (const type of Object.values(typeMap)) {
const controllers = type.controllers()
router.use(
controllers.routes(),
controllers.allowedMethods(),
)
providers.push({
...type.meta,
type: type.namespace,
channel: 'text',
schema: type.schema,
})
}
}
export const loadTextControllers = loadControllers(typeMap, 'text')

View file

@ -1,5 +1,4 @@
import Router from '@koa/router'
import { ProviderParams, ProviderSchema } from '../Provider'
import { ProviderControllers, ProviderParams, ProviderSchema } from '../Provider'
import { createController } from '../ProviderService'
import { Webhook, WebhookResponse } from './Webhook'
import { WebhookProvider } from './WebhookProvider'
@ -37,7 +36,7 @@ export default class LocalWebhookProvider extends WebhookProvider {
}
}
static controllers(): Router {
return createController('webhook', this.namespace, this.schema)
static controllers(): ProviderControllers {
return { admin: createController('webhook', this.namespace, this.schema) }
}
}

View file

@ -1,7 +1,6 @@
import Router from '@koa/router'
import { logger } from '../../config/logger'
import { randomInt, sleep } from '../../utilities'
import { ProviderParams, ProviderSchema } from '../Provider'
import { ProviderControllers, ProviderParams, ProviderSchema } from '../Provider'
import { createController } from '../ProviderService'
import { Webhook, WebhookResponse } from './Webhook'
import { WebhookProvider } from './WebhookProvider'
@ -32,7 +31,7 @@ export default class LoggerWebhookProvider extends WebhookProvider {
}
}
static controllers(): Router {
return createController('webhook', this.namespace, this.schema)
static controllers(): ProviderControllers {
return { admin: createController('webhook', this.namespace, this.schema) }
}
}

View file

@ -17,11 +17,7 @@ export default class WebhookJob extends Job {
const data = await loadSendJob<WebhookTemplate>(trigger)
if (!data) return
const { campaign, template, user, project, event } = data
const context = {
campaign_id: campaign?.id,
template_id: template?.id,
}
const { campaign, template, user, project, event, context } = data
// Send and render webhook
const channel = await loadWebhookChannel(campaign.provider_id, project.id)

View file

@ -1,6 +1,5 @@
import Router from '@koa/router'
import { ProviderMeta } from '../Provider'
import { loadProvider } from '../ProviderRepository'
import { loadControllers } from '../ProviderService'
import LocalWebhookProvider from './LocalWebhookProvider'
import LoggerWebhookProvider from './LoggerWebhookProvider'
import WebhookChannel from './WebhookChannel'
@ -16,23 +15,9 @@ export const providerMap = (record: { type: WebhookProviderName }): WebhookProvi
}
export const loadWebhookChannel = async (providerId: number, projectId: number): Promise<WebhookChannel | undefined> => {
const provider = await loadProvider(providerId, projectId, providerMap)
const provider = await loadProvider(providerId, providerMap, projectId)
if (!provider) return
return new WebhookChannel(provider)
}
export const loadWebhookControllers = async (router: Router, providers: ProviderMeta[]) => {
for (const type of Object.values(typeMap)) {
const controllers = type.controllers()
router.use(
controllers.routes(),
controllers.allowedMethods(),
)
providers.push({
...type.meta,
type: type.namespace,
channel: 'webhook',
schema: type.schema,
})
}
}
export const loadWebhookControllers = loadControllers(typeMap, 'webhook')

View file

@ -96,7 +96,7 @@ export class EmailTemplate extends Template {
validate() {
return isValid({
type: 'object',
required: ['from', 'subject', 'text', 'html'],
required: ['from', 'subject', 'html'],
properties: {
from: { type: 'string' },
subject: { type: 'string' },

View file

@ -9,10 +9,11 @@ import { unsubscribeEmailLink } from '../subscriptions/SubscriptionService'
import { clickWrapHtml, openWrapHtml, preheaderWrapHtml } from './LinkService'
import App from '../app'
export interface RenderContext {
export type RenderContext = {
template_id: number
campaign_id: number
}
subscription_id: number
} & Record<string, unknown>
export interface Variables {
context: RenderContext

View file

@ -1,10 +1,9 @@
import Router from '@koa/router'
import App from '../app'
import { loadTextChannelInbound } from '../providers/text'
import { RequestError } from '../core/errors'
import { JSONSchemaType, validate } from '../core/validate'
import Subscription, { SubscriptionParams } from './Subscription'
import { createSubscription, getSubscription, pagedSubscriptions, unsubscribe, unsubscribeSms } from './SubscriptionService'
import { createSubscription, getSubscription, pagedSubscriptions, unsubscribe } from './SubscriptionService'
import SubscriptionError from './SubscriptionError'
import { encodedLinkToParts } from '../render/LinkService'
import { ProjectState } from '../auth/AuthMiddleware'
@ -40,7 +39,6 @@ export const emailUnsubscribeSchema: JSONSchemaType<EmailUnsubscribeParams> = {
},
additionalProperties: false,
}
publicRouter.post('/email', async ctx => {
const { user, campaign } = await encodedLinkToParts(ctx.URL)
@ -52,19 +50,6 @@ publicRouter.post('/email', async ctx => {
ctx.status = 204
})
publicRouter.post('/sms', async ctx => {
// Always return with positive status code
ctx.status = 204
// Match up to provider based on inbound number
const to = ctx.request.body.To || ctx.request.body.to
const channel = await loadTextChannelInbound(to)
if (!channel) return
await unsubscribeSms(channel.provider, ctx.request.body)
})
export { publicRouter }
/**

View file

@ -32,6 +32,13 @@ export const getUserFromPhone = async (projectId: number, phone: string): Promis
)
}
export const getUserFromEmail = async (projectId: number, email: string): Promise<User | undefined> => {
return await User.first(
qb => qb.where('email', email)
.where('project_id', projectId),
)
}
export const pagedUsers = async (params: SearchParams, projectId: number) => {
return await User.searchParams(
params,

View file

@ -2,6 +2,7 @@ import { JSONSchemaType } from 'ajv'
import { validate } from '../core/validate'
import crypto from 'crypto'
import Hashids from 'hashids'
import { differenceInSeconds } from 'date-fns'
export const pluralize = (noun: string, count = 2, suffix = 's') => `${noun}${count !== 1 ? suffix : ''}`
@ -37,6 +38,10 @@ export const uuid = (): string => {
return crypto.randomUUID()
}
export const secondsAgo = (timestamp: Date | number) => {
return differenceInSeconds(Date.now(), timestamp)
}
const hashCharacters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890'
const hashMinimumLength = 10
export const encodeHashid = function(value: number): string {

View file

@ -8,20 +8,17 @@ pagination_next: null
custom_edit_url: null
---
import Card from '@site/src/components/Card'
import Cards from '@site/src/components/Cards'
# Documentation
## What is Parcelvoy?
Parcelvoy is an open sourced automated messaging and customer engagement tool for growth companies and enterprise alike. It combines the user experience you would expect in a Saas product with the flexibility of an open sourced tool.
## Discover
### Overview
Start here to get up and running and get aquainted at a high level with Parcelvoy.
### How To
Learn each component of Parcelvoy and how to do everything from create a campaign to generating a dynamic list.
### Integrations & Clients
To make full use of Parcelvoy you'll want to integrate it with our list of providers and include it in your app.
### API
We have an extensive list of available APIs to manage the platform or ingest data however you see fit. Documentation Coming soon.
<Cards>
<Card title="Overview" href="/overview/introduction">Start here to get up and running and get aquainted at a high level with Parcelvoy.</Card>
<Card title="How To" href="/how-to">Learn each component of Parcelvoy and how to do everything from create a campaign to generating a dynamic list.</Card>
<Card title="Integrations & Clients" href="/providers">To make full use of Parcelvoy you'll want to integrate it with our list of providers and include it in your app.</Card>
<Card title="API" href="/api">We have an extensive list of available APIs to manage the platform or ingest data however you see fit. Documentation Coming soon.</Card>
</Cards>

View file

@ -1 +1,3 @@
# Index for settings!
# Settings
Under the settings section you can configure all things related to a project.Most configuration settings are project specific to allow things to be more flexible. You could setup projects to be anything from testing/production environments, to represent a given client.

View file

@ -1,6 +1,6 @@
{
"label": "Quick Start",
"label": "Overview",
"position": 1,
"collapsible": false
}
}

View file

@ -0,0 +1,4 @@
# Introduction
Parcelvoy is an open sourced automated messaging and customer engagement tool for growth companies and enterprise alike. It combines the user experience you would expect in a SaaS product with the flexibility of an open sourced tool.
## Why?

View file

@ -0,0 +1 @@
# Quick Start

View file

@ -0,0 +1,14 @@
---
title: Providers
sidebar_position: 1
pagination_prev: null
pagination_next: null
custom_edit_url: null
---
import Providers from '@site/src/components/Providers'
# Providers
Providers are the integration building blocks that let Parcelvoy do things. Currently they include senders (like Twilio for SMS) and analytics (like Segment)
<Providers />

View file

@ -1,4 +0,0 @@
# Nexmo
## Setup
## Outbound
## Inbound

View file

@ -1,6 +1,55 @@
# SES
Amazon SES is quite possibly the most cost effective email service available. If you are sending high volumes of emails it is highly recommended, but it does require some extra setup.
## Setup
### Requesting Approval
## Inbound
## Outbound
### Requesting Approval
Once you are ready to use SES you must request approval to send emails in production. In general it is recommended you try and request access earlier than later as you may be denied. The process usually does not take longer than a day, but providing lots of context is important.
To request access, do the following:
1. Navigate to the AWS SES portal (https://console.aws.amazon.com/ses/home)
2. On your account dashboard you should see an alert informing you that your account is in sandbox mode. Hit the `Request Production Access` button.
3. Fill out the form with the details of your product and submit. The more details the better as it will improve your chances of being approved.
## Outbound
### Create Verified Identity
1. From the SES portal, navigate to `Verified Identities`
2. Click `Create Identity`
3. In general for Parcelvoy you will want the flexibility of sending emails from any address, so we recommend picking the `Domain` entity type.
4. Next, enter the domain you want to use for sending emails. In general it is recommended that you use a subdomain to prevent your primary emails reputation score (which may house your company emails) being affected by any marketing or transaction emails you send.
5. Follow the instructions on how to verify your domain as well as setup DKIM for email security.
6. Hit create. Once created, SES will verify your domain to make sure it is setup correctly.
### Setup Integration
1. Navigate to `IAM` in the AWS portal
2. Go to `Users` and hit `Create User`
3. Pick a random username for your user and hit `Next`
4. If you have existing groups that cover the permissions necessary use that, otherwise select `Attach policies directly` under `Permission Options`
6. Search for `AmazonSES` and select `AmazonSESFullAccess` from the list
7. Hit `Next` and then `Create User`
8. Navigate to your newly created user and then to the `Security Credentials` tab
9. Under `Access Keys` hit `Create Access Key`
10. From the provided best practices list, pick `Application running outside AWS` and hit `Next` then `Create Access Key`
11. Save the provided access key and secret access key
12. Open a new window and go to your Parcelvoy project settings
13. Navigate to `Integrations` and click the `Add Integration` button.
14. Pick `Amazon SES` from the list of integrations and enter the `Access Key Id` (access key) and `Secret Access Key` in the provided fields.
15. To determine your region, navigate back to SES and look at the URL bar. The first part before `console.aws.amazon.com` is your region (i.e. `us-east-1`). Enter the region in the appropriate field.
16. Hit save to create the provider.
## Inbound
Email sending is not hte only important part, you also need to keep track of things like email opens, clicks, unsubscribes, bounces and complaints. Parcelvoy automatically takes care of opens, clicks and unsubscribes for you, but bounces and complaints require notifications from SES.
To setup inbound notifications, do the following:
1. Open the [https://console.aws.amazon.com/sns/home](Amazon SNS console) and choose `Topics`.
2. On the Topics page, choose Create topic.
3. In the `Details` section of the Create topic page, choose Standard for type and provider a name.
4. Choose `Create topic`
5. From the Topic details of the topic that you created, choose `Create subscription`
6. For Protocol, select `HTTPS` and enter the Parcelvoy SES unsubscribe URL for your provider (which can be found on the provider details screen)
7. Hit save
8. Navigate to the [https://console.aws.amazon.com/ses/home]SES console) and choose `Verified identities`
9. Select your previously created identity and go to the `Notifications` tab.
10. Disable Email feedback forwarding
11. Under Feedback notifications, hit `Edit`
12. For `Bounce feedback` and `Complaint feedback` select the SNS topic you previously created and check the `Include original email headers` checkboxes.
13. Hit save changes and you are all set!

View file

@ -1,24 +1,37 @@
# Twilio
## Setup
Start by creating a new account at [https://twilio.com](https://twilio.com).
After you've created an account, hit the `Account` button in the top right hand corner of the Twilio dashboard and navigate to the `General Settings -> Keys & Credentials -> API keys & tokens`. Under `Auth Tokens` there should be two sets of values, live credentials and test credentials. For Parcelvoy, you need live credentials.
Open a new window and go to your Parcelvoy project settings. Navigate to `Integrations` and click the `Add Integration` button, followed by picking Twilio from the list. Enter the `Auth Token` and `Account SID` from the Twilio window. Next, we will purchase a phone number.
Start by creating a new account at [https://twilio.com](https://twilio.com). Once your account is created, the following steps will get your account linked to Parcelvoy
## Outbound
All you need for outbound messages is a phone number that supports SMS.
To purchase a new phone number, go to `Develop -> Phone Numbers -> Buy a Number`. From here, you can pick the search criteria you care about for a number. In order for Parcelvoy to work, just make sure it has SMS listed as a capability. You will not be able to send messages without it.
After you've purchased the number, enter it in the configuration on your Parcelvoy provider and hit save to create.
If you already have a phone number, jump to step four.
1. Go to `Develop -> Phone Numbers -> Buy a Number`
2. From here, you can pick the search criteria you care about for a number. Just make sure the number selected supports SMS (Parcelvoy will not work without it)
3. Purchase the number and copy it down.
4. Next, hit the `Account` button in the top right hand corner of the Twilio dashboard and navigate to the `General Settings -> Keys & Credentials -> API keys & tokens`.
5. Under `Auth Tokens` there should be two sets of values, live credentials and test credentials. For Parcelvoy, you need live credentials, copy them.
6. Open a new window and go to your Parcelvoy project settings
7. Navigate to `Integrations` and click the `Add Integration` button.
8. Pick Twilio from the list of integrations and enter the `Auth Token`, `Account SID` and `Phone Number` from Twilio.
9. Hit save to create the provider.
You are now setup to send SMS messages using Twilio. There is one more step however to make it fully functioning and that is to setup inbound messages so that Parcelvoy is notified of unsubscribes.
## Inbound
By default Twilio automatically manages [opt-outs (unsubscribes)](https://support.twilio.com/hc/en-us/articles/360034798533-Getting-Started-with-Advanced-Opt-Out-for-Messaging-Services), you just have to listen for the inbound webhook to then register that event in Parcelvoy.
To setup inbound SMS for Twilio, go to `Develop -> Phone Numbers -> Manage -> Active Numbers` and pick the phone number you are using internally. From there scroll down to the `Messaging` section. On the enter `A Message Comes In` set the type to `Webhook`, the method to `HTTP POST` and then the URL to the following:
To setup inbound SMS for Twilio, do the following:
1. In Twilip, navigate to `Develop -> Phone Numbers -> Manage -> Active Numbers`.
2. Pick the phone number you are using internally.
3. Scroll down to the `Messaging` section.
4. On the line item `A Message Comes In` set the type to `Webhook`, the method to `HTTP POST` and then the URL to the following:
```
https://yourdomain.com/api/unsubscribe/sms
```
Where `yourdomain.com` is replaced with whatever domain you are running Parcelvoy under.
5. Save the values.
Inbound Twilio notifications are now configured and unsubscribe events will register as Parcelvoy user events.

View file

@ -0,0 +1,8 @@
---
id: vonage
---
# Vonage (Nexmo)
## Setup
## Outbound
## Inbound

View file

@ -61,6 +61,7 @@ const config = {
logo: {
alt: 'Parcelvoy',
src: 'img/parcelvoy.svg',
srcDark: 'img/parcelvoy-light.svg',
},
items: [
{

View file

@ -0,0 +1,21 @@
.card {
display: flex;
border-radius: 8px;
border: 1px solid var(--color-grey);
padding: 20px;
line-height: 1.2;
box-shadow: none;
flex-direction: row;
gap: 20px;
align-items: center;
}
a.card:hover {
cursor: pointer;
border-color: var(--color-primary);
text-decoration: none;
}
.card img {
border-radius: 8px;
}

View file

@ -0,0 +1,14 @@
import React from 'react'
import './Card.css'
export default function Card({ image, title, href, children }) {
return (
<a href={href} className="card">
<img src={image} />
<div className="card-content">
<h3 style={{ margin: '0 0 5px 0', padding: '0' }}>{title}</h3>
<div>{children}</div>
</div>
</a>
)
}

View file

@ -0,0 +1,14 @@
import React from 'react'
export default function Cards({ children }) {
return (
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '20px'
}}>
{children}
</div>
)
}

View file

@ -0,0 +1,21 @@
import React from 'react'
import { useDocsSidebar } from '@docusaurus/theme-common/internal'
import Card from './Card'
import Cards from './Cards'
export default function Providers() {
const sidebar = useDocsSidebar()
const items = sidebar.items.filter((item) => item.label === 'Providers')[0].items
return (
<div className="providers">
<Cards>
{items.map((item) => <Card
image={`https://parcelvoy.com/${item.docId}.svg`}
key={item.docId}
title={item.label}
href={item.href}></Card>)}
</Cards>
</div>
)
}

12
docs/static/img/parcelvoy-light.svg vendored Normal file
View file

@ -0,0 +1,12 @@
<svg width="196" height="39" viewBox="0 0 196 39" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M50.176 5.57602H40.132V30.776H44.092V22.424H50.176C56.044 22.424 59.428 19.256 59.428 14C59.428 8.74402 56.044 5.57602 50.176 5.57602ZM49.888 18.752H44.092V9.24802H49.888C53.56 9.24802 55.324 10.796 55.324 14C55.324 17.204 53.56 18.752 49.888 18.752Z" fill="white"/>
<path d="M75.0368 13.892L74.8208 16.412H74.7128C73.7048 14.54 71.4728 13.496 68.9888 13.496C64.3088 13.496 60.6728 17.312 60.6728 22.316C60.6728 27.356 64.3088 31.172 68.9888 31.172C71.4728 31.172 73.7048 30.128 74.7128 28.256H74.8208L75.0368 30.776H78.4568V13.892H75.0368ZM64.4888 22.316C64.4888 19.22 66.5408 16.88 69.6728 16.88C72.8048 16.88 74.8568 19.22 74.8568 22.316C74.8568 25.412 72.8048 27.788 69.6728 27.788C66.5408 27.788 64.4888 25.412 64.4888 22.316Z" fill="white"/>
<path d="M91.6446 13.568C89.2686 13.568 87.2526 14.792 86.3166 17.132H86.2086L85.9926 13.892H82.5726V30.776H86.3166V23.612C86.3166 19.112 88.2966 17.132 91.5366 17.132C92.1486 17.132 92.7606 17.204 93.1926 17.276V13.82C92.7246 13.64 92.2206 13.568 91.6446 13.568Z" fill="white"/>
<path d="M103.018 31.172C107.554 31.172 110.974 28.364 111.334 24.332H107.518C107.122 26.528 105.358 27.788 103.054 27.788C99.9221 27.788 97.9421 25.52 97.9421 22.316C97.9421 19.148 99.9221 16.88 103.054 16.88C105.394 16.88 107.122 18.14 107.554 20.372H111.37C110.974 16.304 107.59 13.496 103.018 13.496C97.6541 13.496 94.1261 17.348 94.1261 22.316C94.1261 27.284 97.6541 31.172 103.018 31.172Z" fill="white"/>
<path d="M130.966 22.244C130.966 16.988 127.438 13.496 122.362 13.496C117.106 13.496 113.542 17.204 113.542 22.352C113.542 27.536 117.07 31.172 122.47 31.172C127.006 31.172 129.994 28.688 130.678 25.556H127.078C126.646 27.068 124.99 28.112 122.506 28.112C119.554 28.112 117.682 26.564 117.322 23.504H130.858C130.93 23.108 130.966 22.748 130.966 22.244ZM122.362 16.52C125.026 16.52 126.718 17.924 127.15 20.804H117.358C117.826 17.96 119.626 16.52 122.362 16.52Z" fill="white"/>
<path d="M134.106 30.776H137.85V5.57602H134.106V30.776Z" fill="white"/>
<path d="M147.284 30.776H150.776L158.156 13.892H154.124L149.084 26.24H148.976L143.9 13.892H139.904L147.284 30.776Z" fill="white"/>
<path d="M167.642 31.172C172.79 31.172 176.642 27.356 176.642 22.316C176.642 17.312 172.79 13.496 167.642 13.496C162.494 13.496 158.642 17.312 158.642 22.316C158.642 27.356 162.494 31.172 167.642 31.172ZM167.642 27.788C164.51 27.788 162.458 25.412 162.458 22.316C162.458 19.22 164.51 16.88 167.642 16.88C170.774 16.88 172.79 19.22 172.79 22.316C172.79 25.412 170.774 27.788 167.642 27.788Z" fill="white"/>
<path d="M191.339 13.892L186.299 26.24H186.191L181.115 13.892H177.119L184.283 30.272L180.575 38.552H184.571L195.371 13.892H191.339Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M15.9788 0.626526L31.4444 8.35935V27.6648L15.9788 35.3977L0.513153 27.6648V8.35935L15.9788 0.626526ZM5.90762 9.92568C4.93867 10.4102 4.3266 11.4005 4.3266 12.4838V23.5404C4.3266 24.6237 4.93867 25.614 5.90762 26.0985L7.29265 26.791V16.4628C7.29265 13.5739 8.92482 10.933 11.5087 9.64107L18.4947 6.14805L17.2579 5.52962C16.4527 5.12703 15.5049 5.12703 14.6997 5.52962L5.90762 9.92568ZM14.6997 30.4946L12.2117 29.2505C11.5341 28.9118 11.1061 28.2192 11.1061 27.4617V24.2751L20.449 19.6037C23.0328 18.3117 24.665 15.6708 24.665 12.782V9.23319L26.05 9.92568C27.0189 10.4102 27.631 11.4005 27.631 12.4838V23.5404C27.631 24.6237 27.0189 25.614 26.05 26.0985L17.2579 30.4946C16.4527 30.8972 15.5049 30.8972 14.6997 30.4946ZM20.8516 9.2332V12.782C20.8516 14.2264 20.0355 15.5469 18.7435 16.1928L11.1061 20.0115V16.4628C11.1061 15.0183 11.9222 13.6979 13.2141 13.0519L20.8516 9.2332Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 3.6 KiB