Skip to content

Commit

Permalink
feat: Add webhooks support
Browse files Browse the repository at this point in the history
  • Loading branch information
franky47 committed Dec 23, 2021
1 parent cf4bf66 commit efd85f9
Show file tree
Hide file tree
Showing 9 changed files with 309 additions and 23 deletions.
15 changes: 6 additions & 9 deletions .npmignore
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
tests/**
**/*.test.ts
tsconfig.json
.env
.volumes/
yarn-error.log
.github/**
src/**
coverage/
.dependabot/
.github/
.husky/
coverage/
dist/tests/
src/
tsconfig.json
yarn-error.log
42 changes: 41 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
## Features

- Send emails
- Webhooks _(coming soon)_
- Webhooks
- Inbound emails _(coming soon)_

## Installation
Expand Down Expand Up @@ -53,6 +53,46 @@ server.ohmysmtp.sendEmail({

You can provide the API token via the configuration or via the `OHMYSMTP_API_TOKEN` environment variable.

## Webhooks

You can enable reception of webhook events in the configuration object:

```ts
server.register(fastifyOhMySMTP, {
apiToken: 'my-api-token',
webhooks: {
// Where to attach the webhook endpoint (POST)
path: '/webhook',

/*
* An object map of handlers, where keys are the event types, listed here:
* https://docs.ohmysmtp.com/guide/webhooks#email-events
*
* Values are async handlers that take as argument:
* - The payload object of the webhook event
* - The request object from Fastify
* - The Fastify instance
*/
handlers: {
'email.spam': async (event, req, fastify) => {
req.log.info(event, 'Spam detected')
await fastify.ohmysmtp.sendEmail({
from: '[email protected]',
to: '[email protected]',
subject: 'Spam detected',
textbody: `Check event ${event.id} on OhMySMTP`
})
}
},

// You can pass additional Fastify route props here:
routeConfig: {
logLevel: 'warn'
}
}
})
```

## License

[MIT](https://github.com/47ng/fastify-ohmysmtp/blob/main/LICENSE) - Made with ❤️ by [François Best](https://francoisbest.com)
Expand Down
19 changes: 12 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,18 +24,20 @@
"access": "public"
},
"scripts": {
"test": "jest --coverage --runInBand",
"test:watch": "jest --watch --runInBand",
"build": "run-s build:*",
"build:clean": "rm -rf ./dist",
"build:ts": "tsc",
"build": "run-s build:clean build:ts",
"ci": "run-s build",
"test:integration": "NODE_ENV=production ts-node ./tests/integration/main.ts",
"test": "run-s test:*",
"test:unit": "jest --coverage --runInBand",
"test:integration": "ts-node ./src/tests/integration/main.ts",
"ci": "run-s build test",
"prepare": "husky install"
},
"dependencies": {
"@ohmysmtp/ohmysmtp.js": "^0.0.11",
"fastify-plugin": "^3.0.0"
"fastify-plugin": "^3.0.0",
"zod": "^3.11.6",
"zod-to-json-schema": "^3.11.2"
},
"devDependencies": {
"@commitlint/config-conventional": "^15.0.0",
Expand All @@ -53,7 +55,10 @@
"jest": {
"verbose": true,
"preset": "ts-jest/presets/js-with-ts",
"testEnvironment": "node"
"testEnvironment": "node",
"testMatch": [
"**/*.test.ts"
]
},
"prettier": {
"arrowParens": "avoid",
Expand Down
48 changes: 44 additions & 4 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import OhMySMTP from '@ohmysmtp/ohmysmtp.js'
import type { FastifyInstance } from 'fastify'
import * as OhMySMTP from '@ohmysmtp/ohmysmtp.js'
import type { FastifyInstance, RouteShorthandOptions } from 'fastify'
import fp from 'fastify-plugin'
import { WebhookBody, webhookBodySchema, WebhookHandlersMap } from './webhooks'

declare module 'fastify' {
interface FastifyInstance {
Expand All @@ -16,18 +17,57 @@ export interface Configuration {
* environment variable.
*/
apiToken?: string

/**
* Enable incoming webhooks for email events by passing
* an object with a path to register your endpoint to,
* and a map of handlers for specific event types.
*/
webhooks?: {
path: string
routeConfig?: Omit<RouteShorthandOptions, 'schema'>
handlers: WebhookHandlersMap
}
}

async function ohMySMTPPlugin(fastify: FastifyInstance, config: Configuration) {
async function ohMySMTPPlugin(
fastify: FastifyInstance,
config: Configuration = {}
) {
const apiToken = config.apiToken ?? process.env.OHMYSMTP_API_TOKEN
if (!apiToken) {
throw new Error('[fastify-ohmysmtp] Missing API token for OhMySMTP')
}
const client = new OhMySMTP.DomainClient(apiToken)
fastify.decorate('ohmysmtp', client)

if (config.webhooks) {
fastify.post<{ Body: WebhookBody }>(
config.webhooks.path,
{
...(config.webhooks.routeConfig ?? {}),
schema: {
body: webhookBodySchema,
response: {
200: {
type: 'null'
}
}
}
},
async (request, reply) => {
const handler = config.webhooks!.handlers[request.body.event]
if (!handler) {
return reply.status(200).send()
}
await handler(request.body.payload, request, fastify)
return reply.status(200).send()
}
)
}
}

export const fastifyOhMySMTP = fp(ohMySMTPPlugin, {
export const fastifyOhMySMTP = fp<Configuration>(ohMySMTPPlugin, {
fastify: '3.x'
})

Expand Down
32 changes: 32 additions & 0 deletions src/tests/integration/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import Fastify from 'fastify'
import fastifyOhMySMTP from '../../../dist'

const server = Fastify({
logger: true
})

server.register(fastifyOhMySMTP, {
apiToken: 'my-api-token',
webhooks: {
path: '/webhook',
handlers: {
'email.spam': async (event, req, app) => {
req.log.info(event)
await app.ohmysmtp.sendEmail({
from: '[email protected]',
to: '[email protected]',
subject: 'Spam detected',
textbody: `Check event ${event.id} on OhMySMTP`
})
}
}
}
})

server.ready(error => {
if (error) {
throw error
}
console.log(server.printPlugins())
console.log(server.printRoutes())
})
101 changes: 101 additions & 0 deletions src/tests/webhooks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { webhookBody, webhookBodySchema, WebhookJSONBody } from '../webhooks'

describe('webhooks', () => {
test('body schema', () => {
const expected = {
$schema: 'http://json-schema.org/draft-07/schema#',
type: 'object',
properties: {
event: {
type: 'string',
enum: [
'email.queued',
'email.delivered',
'email.deferred',
'email.bounced',
'email.spam'
]
},
payload: {
type: 'object',
properties: {
status: {
type: 'string',
enum: ['queued', 'delivered', 'deferred', 'bounced', 'spam']
},
id: { type: 'number' },
domain_id: { type: 'number' },
created_at: {
anyOf: [
{ type: 'integer', exclusiveMinimum: 0 },
{ type: 'string', format: 'date-time' }
]
},
updated_at: { $ref: '#/properties/payload/properties/created_at' },
from: { type: 'string', format: 'email' },
to: { type: ['string', 'null'] },
htmlbody: { type: ['string', 'null'] },
textbody: { type: ['string', 'null'] },
cc: { type: ['string', 'null'] },
bcc: { type: ['string', 'null'] },
subject: { type: ['string', 'null'] },
replyto: {
anyOf: [{ type: 'string', format: 'email' }, { type: 'null' }]
},
message_id: { type: 'string' },
list_unsubscribe: { type: ['string', 'null'] }
},
required: [
'status',
'id',
'domain_id',
'created_at',
'updated_at',
'from',
'to',
'htmlbody',
'textbody',
'cc',
'bcc',
'subject',
'replyto',
'message_id',
'list_unsubscribe'
],
additionalProperties: false
}
},
required: ['event', 'payload'],
additionalProperties: false
}
expect(webhookBodySchema).toEqual(expected)
})

test('parse correctly shaped webhook body', () => {
const body: WebhookJSONBody = {
event: 'email.delivered',
payload: {
status: 'delivered',
id: 1,
domain_id: 2,
created_at: 123,
updated_at: 123,
message_id: '',
from: '[email protected]',
replyto: null,
to: '[email protected]',
cc: null,
bcc: null,
subject: 'Hey!',
htmlbody: 'Hello',
textbody: 'Hello',
list_unsubscribe: null
}
}
const result = webhookBody.safeParse(body)
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.payload.created_at.valueOf()).toEqual(123)
}
})
})
55 changes: 55 additions & 0 deletions src/webhooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import type { FastifyInstance, FastifyRequest } from 'fastify'
import { z } from 'zod'
import zodToJsonSchema from 'zod-to-json-schema'

const dateLike = z
.union([z.number().positive().int(), z.date()])
.refine(value => Number.isSafeInteger(new Date(value).valueOf()), {
message: 'Invalid date input'
})
.transform(value => new Date(value))

export const webhookBody = z.object({
event: z.enum([
'email.queued',
'email.delivered',
'email.deferred',
'email.bounced',
'email.spam'
] as const),
payload: z.object({
status: z.enum([
'queued',
'delivered',
'deferred',
'bounced',
'spam'
] as const),
id: z.number(),
domain_id: z.number(),
created_at: dateLike,
updated_at: dateLike,
from: z.string().email(),
to: z.string().nullable(),
htmlbody: z.string().nullable(),
textbody: z.string().nullable(),
cc: z.string().nullable(),
bcc: z.string().nullable(),
subject: z.string().nullable(),
replyto: z.string().email().nullable(),
message_id: z.string(),
list_unsubscribe: z.string().nullable()
})
})

export type WebhookJSONBody = z.input<typeof webhookBody>
export type WebhookBody = z.output<typeof webhookBody>
export const webhookBodySchema = zodToJsonSchema(webhookBody)

export type WebhookHandlersMap = Partial<{
[Event in WebhookBody['event']]: (
payload: WebhookBody['payload'],
request: FastifyRequest,
fastify: FastifyInstance
) => Promise<void>
}>
8 changes: 6 additions & 2 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@
"ts-node": {
"logError": true
},
"include": ["./src/**/*.ts", "src/sentry.test.ts"],
"exclude": ["./tests/**/*.test.ts", "./dist", "./node_modules"]
"include": ["./src/**/*.ts"],
"exclude": [
"node_modules/",
"./dist/",
"./src/tests/integration/"
]
}
Loading

0 comments on commit efd85f9

Please sign in to comment.