changemaker.lite/api/src/modules/email-templates/email-templates-admin.routes.ts
bunker-admin 647efffdc4 Security hardening: JWT algorithm pinning, key separation, injection fixes
- Pin HS256 algorithm on all jwt.verify() calls (9 sites) and jwt.sign()
  calls (3 sites) — prevents algorithm confusion attacks
- Add JWT_INVITE_SECRET env var; volunteer invite tokens now use a
  dedicated key separate from access/refresh secrets
- Remove req.query.secret fallback from Listmonk webhook route — secrets
  must not appear in nginx access logs
- Replace child_process.spawn in email template seed endpoint with direct
  function import; add require.main guard to seed script
- Add sanitizeCsvField() to location CSV export to prevent formula
  injection in Excel/Sheets (=, +, -, @ prefix → apostrophe prefix)
- Cap QR endpoint text input at 2000 chars to prevent DoS via large payloads
- Fix pre-existing TS errors: type participantNeeds as UpsertNeedsInput
  in meeting-planner service; add sso field to UpdateResourcePayload

Bunker Admin
2026-03-22 12:35:04 -06:00

350 lines
10 KiB
TypeScript

import { Router } from 'express';
import { emailTemplatesService } from './email-templates.service';
import { emailService } from '../../services/email.service';
import { validate } from '../../middleware/validate';
import {
listEmailTemplatesSchema,
createEmailTemplateSchema,
updateEmailTemplateSchema,
rollbackToVersionSchema,
validateTemplateSchema,
sendTestEmailSchema,
} from './email-templates.schemas';
import { logger } from '../../utils/logger';
import { authenticate } from '../../middleware/auth.middleware';
import { requireRole } from '../../middleware/rbac.middleware';
import { UserRole } from '@prisma/client';
import { Request, Response } from 'express';
import { BROADCAST_ROLES } from '../../utils/roles';
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
import { redis } from '../../config/redis';
import { seedEmailTemplates } from '../../scripts/seed-email-templates';
const router = Router();
// All email template routes require authentication
router.use(authenticate);
// All routes require broadcast admin role
const requireBroadcastRole = requireRole(...BROADCAST_ROLES);
/**
* List email templates
* GET /email-templates
*/
router.get(
'/',
requireBroadcastRole,
validate(listEmailTemplatesSchema, 'query'),
async (req: Request, res: Response): Promise<void> => {
try {
const result = await emailTemplatesService.list(req.query as any);
res.json(result);
} catch (error) {
logger.error('Error listing email templates:', error);
res.status(500).json({ error: 'Failed to list email templates' });
}
},
);
/**
* Get single email template
* GET /email-templates/:id
*/
router.get(
'/:id',
requireBroadcastRole,
async (req: Request, res: Response): Promise<void> => {
try {
const template = await emailTemplatesService.getById(req.params.id as string);
res.json(template);
} catch (error) {
if (error instanceof Error && error.message === 'Template not found') {
res.status(404).json({ error: 'Template not found' });
return;
}
logger.error('Error getting email template:', error);
res.status(500).json({ error: 'Failed to get email template' });
}
},
);
/**
* Create email template
* POST /email-templates
*/
router.post(
'/',
requireBroadcastRole,
validate(createEmailTemplateSchema),
async (req: Request, res: Response): Promise<void> => {
try {
const template = await emailTemplatesService.create(req.body, req.user!.id);
res.status(201).json(template);
} catch (error) {
if (error instanceof Error && error.message.includes('already exists')) {
res.status(409).json({ error: error.message });
return;
}
if (error instanceof Error && error.message.includes('validation failed')) {
res.status(400).json({ error: error.message });
return;
}
logger.error('Error creating email template:', error);
res.status(500).json({ error: 'Failed to create email template' });
}
},
);
/**
* Update email template
* PUT /email-templates/:id
*/
router.put(
'/:id',
requireBroadcastRole,
validate(updateEmailTemplateSchema),
async (req: Request, res: Response): Promise<void> => {
try {
const template = await emailTemplatesService.update(req.params.id as string, req.body, req.user!.id);
// Clear cache so changes take effect immediately
emailService.clearDatabaseCache(template.key);
logger.info(`Cleared template cache for: ${template.key}`);
res.json(template);
} catch (error) {
if (error instanceof Error && error.message === 'Template not found') {
res.status(404).json({ error: 'Template not found' });
return;
}
if (error instanceof Error && error.message.includes('validation failed')) {
res.status(400).json({ error: error.message });
return;
}
logger.error('Error updating email template:', error);
res.status(500).json({ error: 'Failed to update email template' });
}
},
);
/**
* Delete email template
* DELETE /email-templates/:id
*/
router.delete(
'/:id',
requireBroadcastRole,
async (req: Request, res: Response): Promise<void> => {
try {
// Fetch template before deleting to get the key
const template = await emailTemplatesService.getById(req.params.id as string);
await emailTemplatesService.delete(req.params.id as string);
// Clear cache for deleted template
emailService.clearDatabaseCache(template.key);
logger.info(`Cleared template cache for deleted template: ${template.key}`);
res.status(204).send();
} catch (error) {
if (error instanceof Error && error.message === 'Template not found') {
res.status(404).json({ error: 'Template not found' });
return;
}
if (error instanceof Error && error.message.includes('Cannot delete system templates')) {
res.status(403).json({ error: error.message });
return;
}
logger.error('Error deleting email template:', error);
res.status(500).json({ error: 'Failed to delete email template' });
}
},
);
/**
* Get version history
* GET /email-templates/:id/versions
*/
router.get(
'/:id/versions',
requireBroadcastRole,
async (req: Request, res: Response): Promise<void> => {
try {
const versions = await emailTemplatesService.getVersions(req.params.id as string);
res.json(versions);
} catch (error) {
logger.error('Error getting template versions:', error);
res.status(500).json({ error: 'Failed to get template versions' });
}
},
);
/**
* Get specific version
* GET /email-templates/:id/versions/:versionNumber
*/
router.get(
'/:id/versions/:versionNumber',
requireBroadcastRole,
async (req: Request, res: Response): Promise<void> => {
try {
const version = await emailTemplatesService.getVersion(
req.params.id as string,
parseInt(req.params.versionNumber as string, 10),
);
res.json(version);
} catch (error) {
if (error instanceof Error && error.message === 'Version not found') {
res.status(404).json({ error: 'Version not found' });
return;
}
logger.error('Error getting template version:', error);
res.status(500).json({ error: 'Failed to get template version' });
}
},
);
/**
* Rollback to previous version
* POST /email-templates/:id/rollback
*/
router.post(
'/:id/rollback',
requireBroadcastRole,
validate(rollbackToVersionSchema),
async (req: Request, res: Response): Promise<void> => {
try {
const template = await emailTemplatesService.rollbackToVersion(
req.params.id as string,
req.body,
req.user!.id,
);
res.json(template);
} catch (error) {
if (error instanceof Error && (error.message === 'Template not found' || error.message === 'Version not found')) {
res.status(404).json({ error: error.message });
return;
}
logger.error('Error rolling back template:', error);
res.status(500).json({ error: 'Failed to rollback template' });
}
},
);
/**
* Validate template syntax
* POST /email-templates/validate
*/
router.post(
'/validate',
requireBroadcastRole,
validate(validateTemplateSchema),
async (req: Request, res: Response): Promise<void> => {
try {
const result = emailTemplatesService.validateTemplate(req.body);
res.json(result);
} catch (error) {
logger.error('Error validating template:', error);
res.status(500).json({ error: 'Failed to validate template' });
}
},
);
/**
* Send test email
* POST /email-templates/:id/test
* Rate limited to 10 per 15 minutes per user
*/
router.post(
'/:id/test',
requireBroadcastRole,
rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10,
standardHeaders: true,
legacyHeaders: false,
store: new RedisStore({
sendCommand: (command: string, ...args: string[]) => redis.call(command, ...args) as Promise<any>,
prefix: 'rl:email-template-test:',
}),
keyGenerator: (req) => req.user!.id,
}),
validate(sendTestEmailSchema),
async (req: Request, res: Response): Promise<void> => {
try {
const result = await emailTemplatesService.sendTestEmail(req.params.id as string, req.body, req.user!.id);
res.json(result);
} catch (error) {
if (error instanceof Error && error.message === 'Template not found') {
res.status(404).json({ error: 'Template not found' });
return;
}
logger.error('Error sending test email:', error);
res.status(500).json({ error: 'Failed to send test email' });
}
},
);
/**
* Get test logs for template
* GET /email-templates/:id/test-logs
*/
router.get(
'/:id/test-logs',
requireBroadcastRole,
async (req: Request, res: Response): Promise<void> => {
try {
const limit = req.query.limit ? parseInt(req.query.limit as string, 10) : 10;
const logs = await emailTemplatesService.getTestLogs(req.params.id as string, limit);
res.json(logs);
} catch (error) {
logger.error('Error getting test logs:', error);
res.status(500).json({ error: 'Failed to get test logs' });
}
},
);
/**
* Seed templates from filesystem (SUPER_ADMIN only)
* POST /email-templates/seed
*/
router.post(
'/seed',
requireRole(UserRole.SUPER_ADMIN),
async (req: Request, res: Response): Promise<void> => {
try {
await seedEmailTemplates();
logger.info('Email templates seeded via API');
res.json({ success: true, message: 'Templates seeded successfully' });
} catch (error) {
logger.error('Error seeding templates:', error);
res.status(500).json({ error: 'Failed to seed templates' });
}
},
);
/**
* Clear template cache (SUPER_ADMIN only)
* POST /email-templates/clear-cache
* Body: { key?: string } - Optional template key to clear. If not provided, clears all.
*/
router.post(
'/clear-cache',
requireRole(UserRole.SUPER_ADMIN),
async (req: Request, res: Response): Promise<void> => {
try {
const { key } = req.body;
emailService.clearDatabaseCache(key);
logger.info(`Template cache cleared${key ? ` for: ${key}` : ' (all)'}`);
res.json({ success: true, cleared: key || 'all' });
} catch (error) {
logger.error('Error clearing template cache:', error);
res.status(500).json({ error: 'Failed to clear template cache' });
}
},
);
export default router;