From e2de6bf47523855e4f6dbcf1ee9552dc09ae712f Mon Sep 17 00:00:00 2001 From: Abhishek Chatterjee Date: Mon, 18 May 2026 18:14:48 +0530 Subject: [PATCH 1/3] feat(email): ROSS-168: implement configurable email plugin with dummy engine support --- example_config.json | 3 ++ src/interfaces/config.ts | 6 +++ src/plugin/email.ts | 24 +++++++++ src/server.ts | 6 +++ src/types/fastify.d.ts | 7 +++ src/validators/config/schema.ts | 13 +++++ tests/plugin/email.test.ts | 86 +++++++++++++++++++++++++++++++++ tests/server.test.ts | 28 +++++++++++ tests/validators/config.test.ts | 52 ++++++++++++++++++++ 9 files changed, 225 insertions(+) create mode 100644 src/plugin/email.ts create mode 100644 tests/plugin/email.test.ts diff --git a/example_config.json b/example_config.json index 8839f11..b5de3ef 100644 --- a/example_config.json +++ b/example_config.json @@ -52,6 +52,9 @@ "passwordColumn": "password" } }, + "email": { + "emailEngine": "dummy" + }, "models": [ { "name": "users", diff --git a/src/interfaces/config.ts b/src/interfaces/config.ts index c7f4031..f4b9942 100644 --- a/src/interfaces/config.ts +++ b/src/interfaces/config.ts @@ -60,6 +60,7 @@ export type JsonSchemaObject = { export type WebhookData = 'query' | 'body' | 'params' | 'resp'; export type AuthEngine = 'api-key' | 'up-auth'; export type SspParamType = 'path' | 'query' | 'body'; +export type EmailEngine = 'dummy'; export interface SwaggerConfig { enabled: boolean; @@ -211,6 +212,10 @@ export interface AuthConfig { apiKey?: string; } +export interface EmailConfig { + emailEngine: EmailEngine; +} + export interface AppConfig { application: ApplicationConfig; swagger: SwaggerConfig; @@ -220,4 +225,5 @@ export interface AppConfig { cache_db?: CacheDbConfig; customAPIs?: CustomAPIConfig; auth?: AuthConfig; + email?: EmailConfig; } diff --git a/src/plugin/email.ts b/src/plugin/email.ts new file mode 100644 index 0000000..94be8df --- /dev/null +++ b/src/plugin/email.ts @@ -0,0 +1,24 @@ +import {FastifyInstance} from 'fastify'; +import fp from 'fastify-plugin'; + +const sendDummyEmail = async ( + email: string, + htmlBody: string, + body: string, +) => { + console.log('Email:', email); + console.log('HTML Body:', htmlBody); + console.log('Body:', body); +}; + +export default fp(async (fastify: FastifyInstance) => { + const communicate = { + sendEmail: async (email: string, htmlBody: string, body: string) => { + if (fastify.appConfig.email.emailEngine === 'dummy') { + await sendDummyEmail(email, htmlBody, body); + } + }, + }; + + fastify.decorate('communicate', communicate); +}); diff --git a/src/server.ts b/src/server.ts index 51e884b..7f86a63 100644 --- a/src/server.ts +++ b/src/server.ts @@ -11,6 +11,7 @@ import migrateDatabase from '@/migrator'; import authPlugin from '@/plugin/auth'; import cachePlugin from '@/plugin/cache'; import dbPlugin from '@/plugin/database'; +import emailPlugin from '@/plugin/email'; import rateLimitPlugin from '@/plugin/rate-limit'; import responsePlugin from '@/plugin/response'; import sspPlugin from '@/plugin/ssp'; @@ -121,6 +122,11 @@ export async function startServer( // config-driven cache (Redis or NodeCache) await app.register(cachePlugin); + // config-driven email + if (config.email) { + await app.register(emailPlugin); + } + // config-driven rate limit if (config.application.rateLimit) { await app.register(rateLimitPlugin, { diff --git a/src/types/fastify.d.ts b/src/types/fastify.d.ts index 3667ee1..776b8bd 100644 --- a/src/types/fastify.d.ts +++ b/src/types/fastify.d.ts @@ -34,5 +34,12 @@ declare module 'fastify' { payload: unknown, ) => Promise; enforceSSP: (request: import('fastify').FastifyRequest) => void; + communicate: { + sendEmail: ( + email: string, + htmlBody: string, + body: string, + ) => Promise; + }; } } diff --git a/src/validators/config/schema.ts b/src/validators/config/schema.ts index ef1395a..d282f75 100644 --- a/src/validators/config/schema.ts +++ b/src/validators/config/schema.ts @@ -462,6 +462,18 @@ const authSchema = { }, }; +const emailSchema = { + type: 'object', + additionalProperties: false, + required: ['emailEngine'], + properties: { + emailEngine: { + type: 'string', + enum: ['dummy'], + }, + }, +}; + const schema = { type: 'object', required: ['application', 'swagger', 'database', 'models'], @@ -479,6 +491,7 @@ const schema = { cache_db: cacheDbSchema, customAPIs: customAPIsSchema, auth: authSchema, + email: emailSchema, }, }; diff --git a/tests/plugin/email.test.ts b/tests/plugin/email.test.ts new file mode 100644 index 0000000..de75576 --- /dev/null +++ b/tests/plugin/email.test.ts @@ -0,0 +1,86 @@ +import Fastify, {FastifyInstance} from 'fastify'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; + +import emailPlugin from '@/plugin/email'; + +import {AppConfig} from '@/interfaces/config'; + +describe('email plugin', () => { + let app: FastifyInstance; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(async () => { + if (app) { + await app.close(); + } + }); + + it('decorates fastify with communicate', async () => { + app = Fastify(); + app.appConfig = { + email: { + emailEngine: 'dummy', + }, + } as unknown as AppConfig; + + await app.register(emailPlugin); + await app.ready(); + + expect(app.hasDecorator('communicate')).toBe(true); + expect(app.communicate).toBeDefined(); + expect(typeof app.communicate.sendEmail).toBe('function'); + }); + + it('sends a dummy email when emailEngine is dummy', async () => { + app = Fastify(); + app.appConfig = { + email: { + emailEngine: 'dummy', + }, + } as unknown as AppConfig; + + await app.register(emailPlugin); + await app.ready(); + + const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await app.communicate.sendEmail( + 'test@example.com', + '

Hello

', + 'Hello', + ); + + expect(consoleLogSpy).toHaveBeenCalledWith('Email:', 'test@example.com'); + expect(consoleLogSpy).toHaveBeenCalledWith('HTML Body:', '

Hello

'); + expect(consoleLogSpy).toHaveBeenCalledWith('Body:', 'Hello'); + + consoleLogSpy.mockRestore(); + }); + + it('does not send an email if emailEngine is not dummy', async () => { + app = Fastify(); + app.appConfig = { + email: { + emailEngine: 'other' as unknown as 'dummy', + }, + } as unknown as AppConfig; + + await app.register(emailPlugin); + await app.ready(); + + const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await app.communicate.sendEmail( + 'test@example.com', + '

Hello

', + 'Hello', + ); + + expect(consoleLogSpy).not.toHaveBeenCalled(); + + consoleLogSpy.mockRestore(); + }); +}); diff --git a/tests/server.test.ts b/tests/server.test.ts index 5e90596..ee83493 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -7,6 +7,7 @@ import Fastify, { import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import migrateDatabase from '@/migrator/index'; +import emailPlugin from '@/plugin/email'; import {startServer} from '@/server'; import {registerRoutes} from '@/routes/index'; @@ -542,4 +543,31 @@ describe('Server', () => { ).toHaveProperty('apiKeyAuth'); }); }); + + describe('Email Configuration', () => { + it('should register email plugin when email is configured', async () => { + const configWithEmail: AppConfig = { + ...mockConfig, + email: { + emailEngine: 'dummy', + }, + }; + + const registerMock = mockApp.register; + await startServer(configWithEmail, 3000, 'dev'); + + expect(registerMock).toHaveBeenCalledWith(emailPlugin); + }); + + it('should not register email plugin when email is not configured', async () => { + const registerMock = mockApp.register; + await startServer(mockConfig, 3000, 'dev'); + + // Assert that none of the registered calls are the emailPlugin + const emailPluginCall = registerMock.mock.calls.find( + (call: unknown[]) => call[0] === emailPlugin, + ); + expect(emailPluginCall).toBeUndefined(); + }); + }); }); diff --git a/tests/validators/config.test.ts b/tests/validators/config.test.ts index a92cad9..5b54696 100644 --- a/tests/validators/config.test.ts +++ b/tests/validators/config.test.ts @@ -3466,3 +3466,55 @@ describe('validateValidAuthorizationConfig', () => { expect(validateConfig(config as unknown as AppConfig)).toEqual(config); }); }); + +// check email configs validation +describe('validateEmailConfig', () => { + it('should pass when email config is valid', () => { + const config = { + ...validBaseConfig, + email: { + emailEngine: 'dummy', + }, + }; + + expect(validateConfig(config as unknown as AppConfig)).toEqual(config); + }); + + it('should throw when email config is missing required emailEngine', () => { + const config = { + ...validBaseConfig, + email: {}, + }; + + expect(() => validateConfig(config as unknown as AppConfig)).toThrow( + "must have required property 'emailEngine'", + ); + }); + + it('should throw when email config has invalid emailEngine enum value', () => { + const config = { + ...validBaseConfig, + email: { + emailEngine: 'invalid-engine', + }, + }; + + expect(() => validateConfig(config as unknown as AppConfig)).toThrow( + 'must be equal to one of the allowed values', + ); + }); + + it('should throw when email config has extra properties', () => { + const config = { + ...validBaseConfig, + email: { + emailEngine: 'dummy', + extraProperty: true, + }, + }; + + expect(() => validateConfig(config as unknown as AppConfig)).toThrow( + 'must NOT have additional properties', + ); + }); +}); From b07c41e611cfb4a08cd7360c852b1eb25f7b072e Mon Sep 17 00:00:00 2001 From: Abhishek Chatterjee Date: Mon, 18 May 2026 18:16:11 +0530 Subject: [PATCH 2/3] refactor(email): ROSS-168: rename email plugin to communicate to support broader messaging functionality --- src/plugin/{email.ts => communicate.ts} | 0 src/server.ts | 2 +- tests/plugin/{email.test.ts => communicate.test.ts} | 2 +- tests/server.test.ts | 2 +- 4 files changed, 3 insertions(+), 3 deletions(-) rename src/plugin/{email.ts => communicate.ts} (100%) rename tests/plugin/{email.test.ts => communicate.test.ts} (97%) diff --git a/src/plugin/email.ts b/src/plugin/communicate.ts similarity index 100% rename from src/plugin/email.ts rename to src/plugin/communicate.ts diff --git a/src/server.ts b/src/server.ts index 7f86a63..8d8a73a 100644 --- a/src/server.ts +++ b/src/server.ts @@ -10,8 +10,8 @@ import Fastify, { import migrateDatabase from '@/migrator'; import authPlugin from '@/plugin/auth'; import cachePlugin from '@/plugin/cache'; +import emailPlugin from '@/plugin/communicate'; import dbPlugin from '@/plugin/database'; -import emailPlugin from '@/plugin/email'; import rateLimitPlugin from '@/plugin/rate-limit'; import responsePlugin from '@/plugin/response'; import sspPlugin from '@/plugin/ssp'; diff --git a/tests/plugin/email.test.ts b/tests/plugin/communicate.test.ts similarity index 97% rename from tests/plugin/email.test.ts rename to tests/plugin/communicate.test.ts index de75576..3b9ddb6 100644 --- a/tests/plugin/email.test.ts +++ b/tests/plugin/communicate.test.ts @@ -1,7 +1,7 @@ import Fastify, {FastifyInstance} from 'fastify'; import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; -import emailPlugin from '@/plugin/email'; +import emailPlugin from '@/plugin/communicate'; import {AppConfig} from '@/interfaces/config'; diff --git a/tests/server.test.ts b/tests/server.test.ts index ee83493..dfb4b76 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -7,7 +7,7 @@ import Fastify, { import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import migrateDatabase from '@/migrator/index'; -import emailPlugin from '@/plugin/email'; +import emailPlugin from '@/plugin/communicate'; import {startServer} from '@/server'; import {registerRoutes} from '@/routes/index'; From d5e2bf26acb55b733f8ac5a7a36b8ccb60411b18 Mon Sep 17 00:00:00 2001 From: Abhishek Chatterjee Date: Mon, 18 May 2026 18:24:02 +0530 Subject: [PATCH 3/3] feat(email): ROSS-168: nest email configuration under a new communicate module in config and codebase --- example_config.json | 6 +++-- src/interfaces/config.ts | 6 ++++- src/plugin/communicate.ts | 2 +- src/server.ts | 8 +++--- src/validators/config/schema.ts | 10 +++++++- tests/plugin/communicate.test.ts | 28 ++++++++++++-------- tests/server.test.ts | 28 ++++++++++---------- tests/validators/config.test.ts | 44 ++++++++++++++++++++++++-------- 8 files changed, 89 insertions(+), 43 deletions(-) diff --git a/example_config.json b/example_config.json index b5de3ef..eb2358f 100644 --- a/example_config.json +++ b/example_config.json @@ -52,8 +52,10 @@ "passwordColumn": "password" } }, - "email": { - "emailEngine": "dummy" + "communicate": { + "email": { + "emailEngine": "dummy" + } }, "models": [ { diff --git a/src/interfaces/config.ts b/src/interfaces/config.ts index f4b9942..e685fa3 100644 --- a/src/interfaces/config.ts +++ b/src/interfaces/config.ts @@ -216,6 +216,10 @@ export interface EmailConfig { emailEngine: EmailEngine; } +export interface CommunicateConfig { + email?: EmailConfig; +} + export interface AppConfig { application: ApplicationConfig; swagger: SwaggerConfig; @@ -225,5 +229,5 @@ export interface AppConfig { cache_db?: CacheDbConfig; customAPIs?: CustomAPIConfig; auth?: AuthConfig; - email?: EmailConfig; + communicate?: CommunicateConfig; } diff --git a/src/plugin/communicate.ts b/src/plugin/communicate.ts index 94be8df..853f440 100644 --- a/src/plugin/communicate.ts +++ b/src/plugin/communicate.ts @@ -14,7 +14,7 @@ const sendDummyEmail = async ( export default fp(async (fastify: FastifyInstance) => { const communicate = { sendEmail: async (email: string, htmlBody: string, body: string) => { - if (fastify.appConfig.email.emailEngine === 'dummy') { + if (fastify.appConfig.communicate?.email?.emailEngine === 'dummy') { await sendDummyEmail(email, htmlBody, body); } }, diff --git a/src/server.ts b/src/server.ts index 8d8a73a..f2bd6e9 100644 --- a/src/server.ts +++ b/src/server.ts @@ -10,7 +10,7 @@ import Fastify, { import migrateDatabase from '@/migrator'; import authPlugin from '@/plugin/auth'; import cachePlugin from '@/plugin/cache'; -import emailPlugin from '@/plugin/communicate'; +import communicatePlugin from '@/plugin/communicate'; import dbPlugin from '@/plugin/database'; import rateLimitPlugin from '@/plugin/rate-limit'; import responsePlugin from '@/plugin/response'; @@ -122,9 +122,9 @@ export async function startServer( // config-driven cache (Redis or NodeCache) await app.register(cachePlugin); - // config-driven email - if (config.email) { - await app.register(emailPlugin); + // config-driven communicate + if (config.communicate) { + await app.register(communicatePlugin); } // config-driven rate limit diff --git a/src/validators/config/schema.ts b/src/validators/config/schema.ts index d282f75..f6a6d8d 100644 --- a/src/validators/config/schema.ts +++ b/src/validators/config/schema.ts @@ -474,6 +474,14 @@ const emailSchema = { }, }; +const communicateSchema = { + type: 'object', + additionalProperties: false, + properties: { + email: emailSchema, + }, +}; + const schema = { type: 'object', required: ['application', 'swagger', 'database', 'models'], @@ -491,7 +499,7 @@ const schema = { cache_db: cacheDbSchema, customAPIs: customAPIsSchema, auth: authSchema, - email: emailSchema, + communicate: communicateSchema, }, }; diff --git a/tests/plugin/communicate.test.ts b/tests/plugin/communicate.test.ts index 3b9ddb6..0b7f089 100644 --- a/tests/plugin/communicate.test.ts +++ b/tests/plugin/communicate.test.ts @@ -1,11 +1,11 @@ import Fastify, {FastifyInstance} from 'fastify'; import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; -import emailPlugin from '@/plugin/communicate'; +import communicatePlugin from '@/plugin/communicate'; import {AppConfig} from '@/interfaces/config'; -describe('email plugin', () => { +describe('communicate plugin', () => { let app: FastifyInstance; beforeEach(() => { @@ -21,12 +21,14 @@ describe('email plugin', () => { it('decorates fastify with communicate', async () => { app = Fastify(); app.appConfig = { - email: { - emailEngine: 'dummy', + communicate: { + email: { + emailEngine: 'dummy', + }, }, } as unknown as AppConfig; - await app.register(emailPlugin); + await app.register(communicatePlugin); await app.ready(); expect(app.hasDecorator('communicate')).toBe(true); @@ -37,12 +39,14 @@ describe('email plugin', () => { it('sends a dummy email when emailEngine is dummy', async () => { app = Fastify(); app.appConfig = { - email: { - emailEngine: 'dummy', + communicate: { + email: { + emailEngine: 'dummy', + }, }, } as unknown as AppConfig; - await app.register(emailPlugin); + await app.register(communicatePlugin); await app.ready(); const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); @@ -63,12 +67,14 @@ describe('email plugin', () => { it('does not send an email if emailEngine is not dummy', async () => { app = Fastify(); app.appConfig = { - email: { - emailEngine: 'other' as unknown as 'dummy', + communicate: { + email: { + emailEngine: 'other' as unknown as 'dummy', + }, }, } as unknown as AppConfig; - await app.register(emailPlugin); + await app.register(communicatePlugin); await app.ready(); const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); diff --git a/tests/server.test.ts b/tests/server.test.ts index dfb4b76..b261ccc 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -7,7 +7,7 @@ import Fastify, { import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import migrateDatabase from '@/migrator/index'; -import emailPlugin from '@/plugin/communicate'; +import communicatePlugin from '@/plugin/communicate'; import {startServer} from '@/server'; import {registerRoutes} from '@/routes/index'; @@ -544,30 +544,32 @@ describe('Server', () => { }); }); - describe('Email Configuration', () => { - it('should register email plugin when email is configured', async () => { - const configWithEmail: AppConfig = { + describe('Communicate Configuration', () => { + it('should register communicate plugin when communicate is configured', async () => { + const configWithCommunicate: AppConfig = { ...mockConfig, - email: { - emailEngine: 'dummy', + communicate: { + email: { + emailEngine: 'dummy', + }, }, }; const registerMock = mockApp.register; - await startServer(configWithEmail, 3000, 'dev'); + await startServer(configWithCommunicate, 3000, 'dev'); - expect(registerMock).toHaveBeenCalledWith(emailPlugin); + expect(registerMock).toHaveBeenCalledWith(communicatePlugin); }); - it('should not register email plugin when email is not configured', async () => { + it('should not register communicate plugin when communicate is not configured', async () => { const registerMock = mockApp.register; await startServer(mockConfig, 3000, 'dev'); - // Assert that none of the registered calls are the emailPlugin - const emailPluginCall = registerMock.mock.calls.find( - (call: unknown[]) => call[0] === emailPlugin, + // Assert that none of the registered calls are the communicatePlugin + const communicatePluginCall = registerMock.mock.calls.find( + (call: unknown[]) => call[0] === communicatePlugin, ); - expect(emailPluginCall).toBeUndefined(); + expect(communicatePluginCall).toBeUndefined(); }); }); }); diff --git a/tests/validators/config.test.ts b/tests/validators/config.test.ts index 5b54696..c9230ac 100644 --- a/tests/validators/config.test.ts +++ b/tests/validators/config.test.ts @@ -3467,13 +3467,15 @@ describe('validateValidAuthorizationConfig', () => { }); }); -// check email configs validation -describe('validateEmailConfig', () => { - it('should pass when email config is valid', () => { +// check communicate configs validation +describe('validateCommunicateConfig', () => { + it('should pass when communicate config is valid', () => { const config = { ...validBaseConfig, - email: { - emailEngine: 'dummy', + communicate: { + email: { + emailEngine: 'dummy', + }, }, }; @@ -3483,7 +3485,9 @@ describe('validateEmailConfig', () => { it('should throw when email config is missing required emailEngine', () => { const config = { ...validBaseConfig, - email: {}, + communicate: { + email: {}, + }, }; expect(() => validateConfig(config as unknown as AppConfig)).toThrow( @@ -3494,8 +3498,10 @@ describe('validateEmailConfig', () => { it('should throw when email config has invalid emailEngine enum value', () => { const config = { ...validBaseConfig, - email: { - emailEngine: 'invalid-engine', + communicate: { + email: { + emailEngine: 'invalid-engine', + }, }, }; @@ -3507,8 +3513,26 @@ describe('validateEmailConfig', () => { it('should throw when email config has extra properties', () => { const config = { ...validBaseConfig, - email: { - emailEngine: 'dummy', + communicate: { + email: { + emailEngine: 'dummy', + extraProperty: true, + }, + }, + }; + + expect(() => validateConfig(config as unknown as AppConfig)).toThrow( + 'must NOT have additional properties', + ); + }); + + it('should throw when communicate config itself has extra properties', () => { + const config = { + ...validBaseConfig, + communicate: { + email: { + emailEngine: 'dummy', + }, extraProperty: true, }, };