356 lines
9.7 KiB
TypeScript
356 lines
9.7 KiB
TypeScript
import express from 'express'
|
|
import cors from 'cors'
|
|
import helmet from 'helmet'
|
|
import rateLimit from 'express-rate-limit'
|
|
import { config } from './src/lib/config'
|
|
import { logger, requestLogger } from './src/lib/logger'
|
|
import { ipWhitelistAuth } from './src/lib/ipAuth'
|
|
import { ResendService } from './src/lib/resendService'
|
|
import { formatValidationError } from './src/lib/validation'
|
|
import { z } from 'zod'
|
|
|
|
const app = express()
|
|
|
|
// Security middleware
|
|
app.use(helmet({
|
|
contentSecurityPolicy: {
|
|
directives: {
|
|
defaultSrc: ["'self'"],
|
|
scriptSrc: ["'none'"],
|
|
objectSrc: ["'none'"],
|
|
styleSrc: ["'self'", "'unsafe-inline'"], // Allow inline styles for emails
|
|
},
|
|
},
|
|
hsts: {
|
|
maxAge: 31536000,
|
|
includeSubDomains: true,
|
|
preload: true
|
|
}
|
|
}))
|
|
|
|
// CORS configuration
|
|
app.use(cors({
|
|
origin: config.security.corsOrigin,
|
|
credentials: true,
|
|
methods: ['GET', 'POST'],
|
|
allowedHeaders: ['Content-Type', 'Authorization']
|
|
}))
|
|
|
|
// Body parsing
|
|
app.use(express.json({ limit: '1mb' }))
|
|
app.use(express.urlencoded({ extended: true, limit: '1mb' }))
|
|
|
|
// Request logging
|
|
app.use(requestLogger)
|
|
|
|
// Rate limiting
|
|
const emailRateLimit = rateLimit({
|
|
windowMs: config.security.rateLimit.windowMs,
|
|
max: config.security.rateLimit.max,
|
|
message: {
|
|
error: 'Too many email requests, please try again later',
|
|
retryAfter: Math.ceil(config.security.rateLimit.windowMs / 1000)
|
|
},
|
|
standardHeaders: true,
|
|
legacyHeaders: false,
|
|
handler: (req, res) => {
|
|
logger.rateLimitExceeded(req.ip, req.path)
|
|
res.status(429).json({
|
|
error: 'Too many email requests, please try again later',
|
|
retryAfter: Math.ceil(config.security.rateLimit.windowMs / 1000)
|
|
})
|
|
}
|
|
})
|
|
|
|
// Apply rate limiting and IP authentication to email endpoints (restrict to one backend)
|
|
app.use('/api/emails', emailRateLimit, ipWhitelistAuth)
|
|
|
|
// Initialize email service
|
|
const emailService = new ResendService()
|
|
|
|
// Error handling middleware
|
|
const asyncHandler = (fn: (req: express.Request, res: express.Response, next: express.NextFunction) => Promise<void>) =>
|
|
(req: express.Request, res: express.Response, next: express.NextFunction) => {
|
|
Promise.resolve(fn(req, res, next)).catch(next)
|
|
}
|
|
|
|
// Health check endpoint
|
|
app.get('/health', asyncHandler(async (req: express.Request, res: express.Response) => {
|
|
const health = await emailService.healthCheck()
|
|
const status = health.status === 'healthy' ? 200 : 503
|
|
|
|
res.status(status).json({
|
|
status: health.status,
|
|
timestamp: health.timestamp,
|
|
service: 'email-template-service',
|
|
version: '1.0.0'
|
|
})
|
|
}))
|
|
|
|
// API info endpoint
|
|
app.get('/api', (req: express.Request, res: express.Response) => {
|
|
res.json({
|
|
service: 'Email Template Service',
|
|
version: '1.0.0',
|
|
endpoints: {
|
|
'POST /api/emails/invitation': 'Send invitation email',
|
|
'POST /api/emails/payment-request': 'Send payment request email',
|
|
'POST /api/emails/enhanced-payment-request': 'Send enhanced payment request email with line items',
|
|
'POST /api/emails/team-invitation': 'Send team member invitation email',
|
|
'POST /api/emails/invoice-share': 'Send invoice sharing notification email',
|
|
'POST /api/emails/password-reset': 'Send password reset email',
|
|
'GET /health': 'Health check',
|
|
},
|
|
rateLimit: {
|
|
max: config.security.rateLimit.max,
|
|
windowMs: config.security.rateLimit.windowMs
|
|
}
|
|
})
|
|
})
|
|
|
|
// Send invitation email
|
|
app.post('/api/emails/invitation', asyncHandler(async (req: express.Request, res: express.Response) => {
|
|
try {
|
|
const result = await emailService.sendInvitation(req.body)
|
|
|
|
if (!result.success) {
|
|
const status = result.code === 'VALIDATION_ERROR' ? 400 : 500
|
|
return res.status(status).json({
|
|
success: false,
|
|
error: result.error,
|
|
code: result.code
|
|
})
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
messageId: result.id,
|
|
duration: result.duration
|
|
})
|
|
} catch (error) {
|
|
logger.error('Invitation email endpoint error', error as Error)
|
|
res.status(500).json({
|
|
success: false,
|
|
error: 'Internal server error',
|
|
code: 'INTERNAL_ERROR'
|
|
})
|
|
}
|
|
}))
|
|
|
|
// Send payment request email
|
|
app.post('/api/emails/payment-request', asyncHandler(async (req: express.Request, res: express.Response) => {
|
|
try {
|
|
const result = await emailService.sendPaymentRequest(req.body)
|
|
|
|
if (!result.success) {
|
|
const status = result.code === 'VALIDATION_ERROR' ? 400 : 500
|
|
return res.status(status).json({
|
|
success: false,
|
|
error: result.error,
|
|
code: result.code
|
|
})
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
messageId: result.id,
|
|
duration: result.duration
|
|
})
|
|
} catch (error) {
|
|
logger.error('Payment request email endpoint error', error as Error)
|
|
res.status(500).json({
|
|
success: false,
|
|
error: 'Internal server error',
|
|
code: 'INTERNAL_ERROR'
|
|
})
|
|
}
|
|
}))
|
|
|
|
// Send password reset email
|
|
app.post('/api/emails/password-reset', asyncHandler(async (req: express.Request, res: express.Response) => {
|
|
try {
|
|
const result = await emailService.sendPasswordReset(req.body)
|
|
|
|
if (!result.success) {
|
|
const status = result.code === 'VALIDATION_ERROR' ? 400 : 500
|
|
return res.status(status).json({
|
|
success: false,
|
|
error: result.error,
|
|
code: result.code
|
|
})
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
messageId: result.id,
|
|
duration: result.duration
|
|
})
|
|
} catch (error) {
|
|
logger.error('Password reset email endpoint error', error as Error)
|
|
res.status(500).json({
|
|
success: false,
|
|
error: 'Internal server error',
|
|
code: 'INTERNAL_ERROR'
|
|
})
|
|
}
|
|
}))
|
|
|
|
// Send team invitation email
|
|
app.post('/api/emails/team-invitation', asyncHandler(async (req: express.Request, res: express.Response) => {
|
|
try {
|
|
const result = await emailService.sendTeamInvitation(req.body)
|
|
|
|
if (!result.success) {
|
|
const status = result.code === 'VALIDATION_ERROR' ? 400 : 500
|
|
return res.status(status).json({
|
|
success: false,
|
|
error: result.error,
|
|
code: result.code
|
|
})
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
messageId: result.id,
|
|
duration: result.duration
|
|
})
|
|
} catch (error) {
|
|
logger.error('Team invitation email endpoint error', error as Error)
|
|
res.status(500).json({
|
|
success: false,
|
|
error: 'Internal server error',
|
|
code: 'INTERNAL_ERROR'
|
|
})
|
|
}
|
|
}))
|
|
|
|
// Send invoice sharing email
|
|
app.post('/api/emails/invoice-share', asyncHandler(async (req: express.Request, res: express.Response) => {
|
|
try {
|
|
const result = await emailService.sendInvoiceShare(req.body)
|
|
|
|
if (!result.success) {
|
|
const status = result.code === 'VALIDATION_ERROR' ? 400 : 500
|
|
return res.status(status).json({
|
|
success: false,
|
|
error: result.error,
|
|
code: result.code
|
|
})
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
messageId: result.id,
|
|
duration: result.duration
|
|
})
|
|
} catch (error) {
|
|
logger.error('Invoice share email endpoint error', error as Error)
|
|
res.status(500).json({
|
|
success: false,
|
|
error: 'Internal server error',
|
|
code: 'INTERNAL_ERROR'
|
|
})
|
|
}
|
|
}))
|
|
|
|
// Send enhanced payment request email
|
|
app.post('/api/emails/enhanced-payment-request', asyncHandler(async (req: express.Request, res: express.Response) => {
|
|
try {
|
|
const result = await emailService.sendEnhancedPaymentRequest(req.body)
|
|
|
|
if (!result.success) {
|
|
const status = result.code === 'VALIDATION_ERROR' ? 400 : 500
|
|
return res.status(status).json({
|
|
success: false,
|
|
error: result.error,
|
|
code: result.code
|
|
})
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
messageId: result.id,
|
|
duration: result.duration
|
|
})
|
|
} catch (error) {
|
|
logger.error('Enhanced payment request email endpoint error', error as Error)
|
|
res.status(500).json({
|
|
success: false,
|
|
error: 'Internal server error',
|
|
code: 'INTERNAL_ERROR'
|
|
})
|
|
}
|
|
}))
|
|
|
|
// Yaltopia API Integration Endpoints
|
|
|
|
// 404 handler
|
|
app.use('*', (req: express.Request, res: express.Response) => {
|
|
res.status(404).json({
|
|
error: 'Endpoint not found',
|
|
path: req.originalUrl,
|
|
method: req.method
|
|
})
|
|
})
|
|
|
|
// Global error handler
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
app.use((error: Error, req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
|
logger.error('Unhandled error', error, {
|
|
path: req.path,
|
|
method: req.method,
|
|
body: req.body
|
|
})
|
|
|
|
if (error instanceof z.ZodError) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: 'Validation error',
|
|
details: formatValidationError(error),
|
|
code: 'VALIDATION_ERROR'
|
|
})
|
|
}
|
|
|
|
if (error.message.includes('Resend')) {
|
|
return res.status(503).json({
|
|
success: false,
|
|
error: 'Email service temporarily unavailable',
|
|
code: 'SERVICE_UNAVAILABLE'
|
|
})
|
|
}
|
|
|
|
res.status(500).json({
|
|
success: false,
|
|
error: config.features.enableDetailedErrors ? error.message : 'Internal server error',
|
|
code: 'INTERNAL_ERROR'
|
|
})
|
|
})
|
|
|
|
// Graceful shutdown
|
|
process.on('SIGTERM', () => {
|
|
logger.info('SIGTERM received, shutting down gracefully')
|
|
process.exit(0)
|
|
})
|
|
|
|
process.on('SIGINT', () => {
|
|
logger.info('SIGINT received, shutting down gracefully')
|
|
process.exit(0)
|
|
})
|
|
|
|
// Start server
|
|
const server = app.listen(config.app.port, () => {
|
|
logger.info('Email service started', {
|
|
port: config.app.port,
|
|
nodeEnv: config.app.nodeEnv,
|
|
fromDomain: config.email.fromDomain,
|
|
rateLimit: config.security.rateLimit
|
|
})
|
|
})
|
|
|
|
// Handle server errors
|
|
server.on('error', (error: Error) => {
|
|
logger.error('Server error', error)
|
|
process.exit(1)
|
|
})
|
|
|
|
export default app |