Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

feat(smtp): add smtp mail server and api-key check #45

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,6 @@ CORS_ORIGIN="http://localhost:*" # Allowed CORS origin, adjust as necessary
# Rate Limiting
COMMON_RATE_LIMIT_WINDOW_MS="1000" # Window size for rate limiting (ms)
COMMON_RATE_LIMIT_MAX_REQUESTS="20" # Max number of requests per window per IP

# Your API Key in the x-api-key header
API_KEY=your_api_key_here
618 changes: 543 additions & 75 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"helmet": "^7.1.0",
"http-status-codes": "^2.3.0",
"jsdom": "^24.0.0",
"nodemailer": "^6.9.13",
"path": "^0.12.7",
"pino-http": "^9.0.0",
"swagger-ui-express": "^5.0.0",
Expand All @@ -47,6 +48,7 @@
"@release-it/conventional-changelog": "^8.0.1",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/nodemailer": "^6.4.15",
"@types/supertest": "^6.0.2",
"@types/swagger-ui-express": "^4.1.6",
"@typescript-eslint/eslint-plugin": "^7.1.0",
Expand Down
15 changes: 14 additions & 1 deletion src/api-docs/openAPIDocumentGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,23 @@ import { OpenApiGeneratorV3, OpenAPIRegistry } from '@asteasolutions/zod-to-open

import { articleReaderRegistry } from '@/routes/articleReader/articleReaderRouter';
import { healthCheckRegistry } from '@/routes/healthCheck/healthCheckRouter';
import { smtpMailRegistry } from '@/routes/smtpMail/smtpMailRouter';
import { transcriptRegistry } from '@/routes/youtubeTranscript/transcriptRouter';

export function generateOpenAPIDocument() {
const registry = new OpenAPIRegistry([healthCheckRegistry, transcriptRegistry, articleReaderRegistry]);
const registry = new OpenAPIRegistry([
healthCheckRegistry,
transcriptRegistry,
articleReaderRegistry,
smtpMailRegistry,
]);

registry.registerComponent('headers', 'x-api-key', {
example: '1234',
required: true,
description: 'The API Key you were given in the developer portal',
});

const generator = new OpenApiGeneratorV3(registry.definitions);

return generator.generateDocument({
Expand Down
5 changes: 5 additions & 0 deletions src/api-docs/openAPIHeaderBuilders.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { z } from 'zod';

export const apiKeyHeader = z.object({
'x-api-key': z.string(),
});
2 changes: 1 addition & 1 deletion src/api-docs/openAPIResponseBuilders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export function createApiResponse(schema: z.ZodTypeAny, description: string, sta
description,
content: {
'application/json': {
schema: ServiceResponseSchema(schema),
schema: ServiceResponseSchema(schema, statusCode == StatusCodes.OK, statusCode),
},
},
},
Expand Down
23 changes: 23 additions & 0 deletions src/common/middleware/apiKeyHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { NextFunction, Request, Response } from 'express';
import { StatusCodes } from 'http-status-codes';

import { env } from '@/common/utils/envConfig';

import { ResponseStatus, ServiceResponse } from '../models/serviceResponse';
import { handleServiceResponse } from '../utils/httpHandlers';

export const apiKeyHandler = (req: Request, res: Response, next: NextFunction) => {
const apiKeyHeader = req.headers['x-api-key'];
if (!apiKeyHeader || apiKeyHeader !== env.API_KEY) {
return handleServiceResponse(
new ServiceResponse(
ResponseStatus.Failed,
'Please input your x-api-key in the header!',
null,
StatusCodes.UNAUTHORIZED
),
res
);
}
next();
};
14 changes: 11 additions & 3 deletions src/common/models/serviceResponse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,18 @@ export class ServiceResponse<T = null> {
}
}

export const ServiceResponseSchema = <T extends z.ZodTypeAny>(dataSchema: T) =>
export const ServiceResponseSchema = <T extends z.ZodTypeAny>(
dataSchema: T,
success: boolean = true,
statusCode: number = 200
) =>
z.object({
success: z.boolean(),
success: z.boolean().openapi({
example: success,
}),
message: z.string(),
responseObject: dataSchema.optional(),
statusCode: z.number(),
statusCode: z.number().openapi({
example: statusCode,
}),
});
1 change: 1 addition & 0 deletions src/common/utils/envConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ export const env = cleanEnv(process.env, {
CORS_ORIGIN: str({ default: '*' }),
COMMON_RATE_LIMIT_MAX_REQUESTS: num({ default: 100 }),
COMMON_RATE_LIMIT_WINDOW_MS: num({ default: 60000 }),
API_KEY: str(),
});
13 changes: 12 additions & 1 deletion src/common/utils/httpHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,18 @@ export const validateRequest = (schema: ZodSchema) => (req: Request, res: Respon
schema.parse({ body: req.body, query: req.query, params: req.params });
next();
} catch (err) {
const errorMessage = `Invalid input: ${(err as ZodError).errors.map((e) => e.message).join(', ')}`;
const errorMessage = `Invalid input: ${(err as ZodError).errors.map((e) => `${e.path}: ${e.message} ${e.code}`).join(', ')}`;
const statusCode = StatusCodes.BAD_REQUEST;
res.status(statusCode).send(new ServiceResponse<null>(ResponseStatus.Failed, errorMessage, null, statusCode));
}
};

export const validateRequestBody = (schema: ZodSchema) => (req: Request, res: Response, next: NextFunction) => {
try {
schema.parse(req.body);
next();
} catch (err) {
const errorMessage = `Invalid input: ${(err as ZodError).errors.map((e) => `${e.path}: ${e.message} ${e.code}`).join(', ')}`;
const statusCode = StatusCodes.BAD_REQUEST;
res.status(statusCode).send(new ServiceResponse<null>(ResponseStatus.Failed, errorMessage, null, statusCode));
}
Expand Down
12 changes: 11 additions & 1 deletion src/routes/articleReader/articleReaderRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import got from 'got';
import { StatusCodes } from 'http-status-codes';
import { JSDOM } from 'jsdom';

import { apiKeyHeader } from '@/api-docs/openAPIHeaderBuilders';
import { createApiResponse } from '@/api-docs/openAPIResponseBuilders';
import { apiKeyHandler } from '@/common/middleware/apiKeyHandler';
import { ResponseStatus, ServiceResponse } from '@/common/models/serviceResponse';
import { handleServiceResponse } from '@/common/utils/httpHandlers';

Expand Down Expand Up @@ -62,10 +64,15 @@ const fetchAndCleanContent = async (url: string) => {
export const articleReaderRouter: Router = (() => {
const router = express.Router();

router.use(apiKeyHandler);

articleReaderRegistry.registerPath({
method: 'get',
path: '/content',
tags: ['Article Reader'],
request: {
headers: [apiKeyHeader],
},
responses: createApiResponse(ArticleReaderSchema, 'Success'),
});

Expand All @@ -88,7 +95,10 @@ export const articleReaderRouter: Router = (() => {
} catch (error) {
console.error(`Error fetching content ${(error as Error).message}`);
const errorMessage = `Error fetching content $${(error as Error).message}`;
return new ServiceResponse(ResponseStatus.Failed, errorMessage, null, StatusCodes.INTERNAL_SERVER_ERROR);
handleServiceResponse(
new ServiceResponse(ResponseStatus.Failed, errorMessage, null, StatusCodes.INTERNAL_SERVER_ERROR),
res
);
}
});

Expand Down
58 changes: 58 additions & 0 deletions src/routes/smtpMail/smtpMailModel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi';
import { z } from 'zod';

extendZodWithOpenApi(z);

export type SmtpRequest = z.infer<typeof SmtpRequestSchema>;

export const SmtpRequestSchema = z
.object({
host: z.string().openapi({
description: 'SMPT email host',
example: 'smtp.example.com',
}),
port: z.number().openapi({
description: 'SMPT port',
example: 465,
}),
secure: z.boolean().optional().default(false).openapi({
description: 'Use `true` for port 465, `false` for all other ports',
}),
auth: z.object({
username: z.string(),
password: z.string(),
}),
mailContent: z.object({
from: z.string().openapi({
example: '[email protected], [email protected]',
description: `The e-mail address of the sender. All e-mail addresses can be plain '[email protected]' or formatted 'Sender Name <[email protected]>'`,
}),
to: z.string().openapi({
example: '[email protected], [email protected]',
description: 'Comma separated list of recipients e-mail addresses that will appear on the To: field',
}),
subject: z.string(),
text: z.string().optional(),
html: z.string().optional(),
cc: z.string().optional().openapi({
example: '[email protected], [email protected]',
description: 'Comma separated list of recipients e-mail addresses that will appear on the Cc: field',
}),
bcc: z.string().optional().openapi({
example: '[email protected], [email protected]',
description: 'Comma separated list recipients e-mail addresses that will appear on the Bcc: field',
}),
}),
})
.openapi({
description: 'Send SMPT Email Request',
});

export type SmtpResponse = z.infer<typeof SmtpResponseSchema>;

export const SmtpResponseSchema = z
.object({
messageId: z.string().optional(),
response: z.string().optional(),
})
.optional();
87 changes: 87 additions & 0 deletions src/routes/smtpMail/smtpMailRouter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi';
import express, { Request, Response, Router } from 'express';
import { StatusCodes } from 'http-status-codes';
import nodemailer from 'nodemailer';

import { apiKeyHeader } from '@/api-docs/openAPIHeaderBuilders';
import { createApiResponse } from '@/api-docs/openAPIResponseBuilders';
import { apiKeyHandler } from '@/common/middleware/apiKeyHandler';
import { ResponseStatus, ServiceResponse } from '@/common/models/serviceResponse';
import { handleServiceResponse, validateRequestBody } from '@/common/utils/httpHandlers';

import { SmtpRequest, SmtpRequestSchema, SmtpResponseSchema } from './smtpMailModel';

export const smtpMailRegistry = new OpenAPIRegistry();
smtpMailRegistry.register('SmtpRequest', SmtpRequestSchema);
smtpMailRegistry.register('SmtpResponse', SmtpResponseSchema);

smtpMailRegistry.registerPath({
method: 'post',
path: '/send-smtp-mail',
tags: ['SMTP Mail'],
request: {
headers: [apiKeyHeader],
body: {
content: {
'application/json': {
schema: SmtpRequestSchema,
},
},
},
},
responses: {
...createApiResponse(SmtpResponseSchema, 'OK'),
...createApiResponse(SmtpResponseSchema, 'Please input your x-api-key', StatusCodes.UNAUTHORIZED),
},
});

export const smtpMailRouter: Router = (() => {
const router = express.Router();

router.use(apiKeyHandler);

router.post('/', validateRequestBody(SmtpRequestSchema), async (_req: Request, res: Response) => {
const { auth, host, port, secure, mailContent }: SmtpRequest = _req.body;
try {
const transporter = nodemailer.createTransport({
auth: {
user: auth.username,
pass: auth.password,
},
port: port,
host: host,
secure: secure,
});

const mailOptions = {
from: mailContent.from,
to: mailContent.to,
subject: mailContent.subject || '',
text: mailContent.text || '',
html: mailContent.html || '',

Check failure

Code scanning / CodeQL

Client-side cross-site scripting High

HTML injection vulnerability due to
user-provided value
.
cc: mailContent.cc,
bcc: mailContent.bcc,
};

const info = await transporter.sendMail(mailOptions);
const serviceResponse = new ServiceResponse(
ResponseStatus.Success,
'Your mail is sent!',
{
messageId: info.messageId,
response: info.response,
},
StatusCodes.OK
);
handleServiceResponse(serviceResponse, res);
} catch (error) {
console.log(`${(error as Error).stack}`);
const errorMessage = `Error sending your email: ${(error as Error).message}`;
handleServiceResponse(
new ServiceResponse(ResponseStatus.Failed, errorMessage, null, StatusCodes.INTERNAL_SERVER_ERROR),
res
);
}
});
return router;
})();
12 changes: 11 additions & 1 deletion src/routes/youtubeTranscript/transcriptRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import express, { Request, Response, Router } from 'express';
import { StatusCodes } from 'http-status-codes';
import { YoutubeTranscript } from 'youtube-transcript';

import { apiKeyHeader } from '@/api-docs/openAPIHeaderBuilders';
import { createApiResponse } from '@/api-docs/openAPIResponseBuilders';
import { apiKeyHandler } from '@/common/middleware/apiKeyHandler';
import { ResponseStatus, ServiceResponse } from '@/common/models/serviceResponse';
import { handleServiceResponse } from '@/common/utils/httpHandlers';

Expand All @@ -15,10 +17,15 @@ transcriptRegistry.register('Transcript', TranscriptSchema);
export const transcriptRouter: Router = (() => {
const router = express.Router();

router.use(apiKeyHandler);

transcriptRegistry.registerPath({
method: 'get',
path: '/transcript',
tags: ['Youtube Transcript'],
request: {
headers: [apiKeyHeader],
},
responses: createApiResponse(TranscriptSchema, 'Success'),
});

Expand Down Expand Up @@ -46,7 +53,10 @@ export const transcriptRouter: Router = (() => {
handleServiceResponse(serviceResponse, res);
} catch (error) {
const errorMessage = `Error fetching transcript $${(error as Error).message}`;
return new ServiceResponse(ResponseStatus.Failed, errorMessage, null, StatusCodes.INTERNAL_SERVER_ERROR);
handleServiceResponse(
new ServiceResponse(ResponseStatus.Failed, errorMessage, null, StatusCodes.INTERNAL_SERVER_ERROR),
res
);
}
});
return router;
Expand Down
3 changes: 3 additions & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import requestLogger from '@/common/middleware/requestLogger';
import { healthCheckRouter } from '@/routes/healthCheck/healthCheckRouter';

import { articleReaderRouter } from './routes/articleReader/articleReaderRouter';
import { smtpMailRouter } from './routes/smtpMail/smtpMailRouter';
import { transcriptRouter } from './routes/youtubeTranscript/transcriptRouter';
const logger = pino({ name: 'server start' });
const app: Express = express();
Expand All @@ -36,6 +37,8 @@ app.use('/health-check', healthCheckRouter);
app.use('/images', express.static('public/images'));
app.use('/transcript', transcriptRouter);
app.use('/get-content', articleReaderRouter);
app.use('/send-smtp-mail', smtpMailRouter);

// Swagger UI
app.use(openAPIRouter);

Expand Down
Loading