From efd85f99d3149c88765a4a68be17259694276e66 Mon Sep 17 00:00:00 2001 From: Francois Best Date: Thu, 23 Dec 2021 23:13:09 +0100 Subject: [PATCH] feat: Add webhooks support --- .npmignore | 15 ++--- README.md | 42 +++++++++++++- package.json | 19 ++++--- src/index.ts | 48 ++++++++++++++-- src/tests/integration/main.ts | 32 +++++++++++ src/tests/webhooks.test.ts | 101 ++++++++++++++++++++++++++++++++++ src/webhooks.ts | 55 ++++++++++++++++++ tsconfig.json | 8 ++- yarn.lock | 12 ++++ 9 files changed, 309 insertions(+), 23 deletions(-) create mode 100644 src/tests/integration/main.ts create mode 100644 src/tests/webhooks.test.ts create mode 100644 src/webhooks.ts diff --git a/.npmignore b/.npmignore index 435d0ce..6f6c87a 100644 --- a/.npmignore +++ b/.npmignore @@ -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 diff --git a/README.md b/README.md index 30e9f4f..913eba0 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ ## Features - Send emails -- Webhooks _(coming soon)_ +- Webhooks - Inbound emails _(coming soon)_ ## Installation @@ -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: 'robots@example.com', + to: 'admin@example.com', + 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) diff --git a/package.json b/package.json index 26a437b..4521f5f 100644 --- a/package.json +++ b/package.json @@ -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", @@ -53,7 +55,10 @@ "jest": { "verbose": true, "preset": "ts-jest/presets/js-with-ts", - "testEnvironment": "node" + "testEnvironment": "node", + "testMatch": [ + "**/*.test.ts" + ] }, "prettier": { "arrowParens": "avoid", diff --git a/src/index.ts b/src/index.ts index 8fb9338..85549f7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 { @@ -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 + 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(ohMySMTPPlugin, { fastify: '3.x' }) diff --git a/src/tests/integration/main.ts b/src/tests/integration/main.ts new file mode 100644 index 0000000..30b5fa0 --- /dev/null +++ b/src/tests/integration/main.ts @@ -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: 'robots@example.com', + to: 'admin@example.com', + 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()) +}) diff --git a/src/tests/webhooks.test.ts b/src/tests/webhooks.test.ts new file mode 100644 index 0000000..e6f0158 --- /dev/null +++ b/src/tests/webhooks.test.ts @@ -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: 'alice@example.com', + replyto: null, + to: 'bob@example.com', + 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) + } + }) +}) diff --git a/src/webhooks.ts b/src/webhooks.ts new file mode 100644 index 0000000..1e7a82f --- /dev/null +++ b/src/webhooks.ts @@ -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 +export type WebhookBody = z.output +export const webhookBodySchema = zodToJsonSchema(webhookBody) + +export type WebhookHandlersMap = Partial<{ + [Event in WebhookBody['event']]: ( + payload: WebhookBody['payload'], + request: FastifyRequest, + fastify: FastifyInstance + ) => Promise +}> diff --git a/tsconfig.json b/tsconfig.json index 3d21a33..9f0ebde 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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/" + ] } diff --git a/yarn.lock b/yarn.lock index e577daf..64516ee 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3996,3 +3996,15 @@ yocto-queue@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== + +zod-to-json-schema@^3.11.2: + version "3.11.2" + resolved "https://registry.yarnpkg.com/zod-to-json-schema/-/zod-to-json-schema-3.11.2.tgz#abdd682d90bcf1aa955f158b2dcdecb8192fcb12" + integrity sha512-bm1aGX1HdNxddRftVFxB2S0IgShnfzqZ6EL2Xu/y6W02RI8JAQV0jitp3S4OsLDZCGFrbVRe1ldVfMPH7o0rEA== + dependencies: + zod "^3.11.6" + +zod@^3.11.6: + version "3.11.6" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.11.6.tgz#e43a5e0c213ae2e02aefe7cb2b1a6fa3d7f1f483" + integrity sha512-daZ80A81I3/9lIydI44motWe6n59kRBfNzTuS2bfzVh1nAXi667TOTWWtatxyG+fwgNUiagSj/CWZwRRbevJIg==