diff --git a/api/middleware/authenticator.js b/api/middleware/authenticator.js new file mode 100644 index 0000000..9bf8404 --- /dev/null +++ b/api/middleware/authenticator.js @@ -0,0 +1,97 @@ +/** + * API Authentication Middleware + * Supports both session-based auth and Bearer token auth + */ + +function createAuthenticator(apiTokenRepository) { + /** + * Require authentication - returns 401 if not authenticated + */ + function requireAuth(req, res, next) { + // Check session first (existing web auth) + if (req.session && req.session.isAuthenticated && req.session.userId) { + req.user = { + id: req.session.userId, + username: req.session.username + } + req.authMethod = 'session' + return next() + } + + // Check Bearer token + const authHeader = req.headers.authorization + if (authHeader && authHeader.startsWith('Bearer ')) { + const token = authHeader.substring(7) // Remove 'Bearer ' prefix + + const tokenData = apiTokenRepository.getByToken(token) + if (tokenData) { + req.user = { + id: tokenData.user_id, + username: tokenData.username + } + req.authMethod = 'token' + + // Update last_used timestamp asynchronously + setImmediate(() => { + try { + apiTokenRepository.updateLastUsed(token) + } catch (err) { + // Log but don't fail the request + console.error('Failed to update token last_used:', err) + } + }) + + return next() + } + } + + // No valid authentication found + return res.apiError('Authentication required', 'UNAUTHORIZED', 401) + } + + /** + * Optional authentication - sets req.user if authenticated, but doesn't require it + */ + function optionalAuth(req, res, next) { + // Check session first + if (req.session && req.session.isAuthenticated && req.session.userId) { + req.user = { + id: req.session.userId, + username: req.session.username + } + req.authMethod = 'session' + return next() + } + + // Check Bearer token + const authHeader = req.headers.authorization + if (authHeader && authHeader.startsWith('Bearer ')) { + const token = authHeader.substring(7) + + const tokenData = apiTokenRepository.getByToken(token) + if (tokenData) { + req.user = { + id: tokenData.user_id, + username: tokenData.username + } + req.authMethod = 'token' + + // Update last_used timestamp asynchronously + setImmediate(() => { + try { + apiTokenRepository.updateLastUsed(token) + } catch (err) { + console.error('Failed to update token last_used:', err) + } + }) + } + } + + // Continue regardless of auth status + next() + } + + return { requireAuth, optionalAuth } +} + +module.exports = createAuthenticator diff --git a/api/middleware/error-handler.js b/api/middleware/error-handler.js new file mode 100644 index 0000000..5f44d6b --- /dev/null +++ b/api/middleware/error-handler.js @@ -0,0 +1,74 @@ +/** + * Global error handler for API routes + * Catches all errors and formats them as consistent JSON responses + */ +function errorHandler(err, req, res, next) { + // Don't handle if response already sent + if (res.headersSent) { + return next(err) + } + + // Log error for debugging + console.error('API Error:', err) + + // Default error response + let statusCode = 500 + let message = 'Internal server error' + let code = 'INTERNAL_ERROR' + + // Handle specific error types + if (err.statusCode) { + statusCode = err.statusCode + } + + if (err.message) { + message = err.message + } + + if (err.code) { + code = err.code + } + + // Common HTTP error codes + if (statusCode === 400) { + code = code || 'BAD_REQUEST' + } else if (statusCode === 401) { + code = code || 'UNAUTHORIZED' + } else if (statusCode === 403) { + code = code || 'FORBIDDEN' + } else if (statusCode === 404) { + code = code || 'NOT_FOUND' + } else if (statusCode === 429) { + code = code || 'RATE_LIMIT_EXCEEDED' + } else if (statusCode === 500) { + code = code || 'INTERNAL_ERROR' + // Don't expose internal error details in production + if (process.env.NODE_ENV === 'production') { + message = 'Internal server error' + } + } + + // Send error response + res.status(statusCode).json({ + success: false, + error: message, + code: code + }) +} + +/** + * Helper to create an API error + */ +class ApiError extends Error { + constructor(message, code = 'ERROR', statusCode = 400) { + super(message) + this.name = 'ApiError' + this.code = code + this.statusCode = statusCode + } +} + +module.exports = { + errorHandler, + ApiError +} diff --git a/api/middleware/rate-limiter.js b/api/middleware/rate-limiter.js new file mode 100644 index 0000000..4474913 --- /dev/null +++ b/api/middleware/rate-limiter.js @@ -0,0 +1,106 @@ +/** + * API Rate Limiter + * Limits requests per token (if authenticated) or IP address (if not) + */ + +class RateLimiter { + constructor() { + this.requests = new Map() // key -> {count, resetTime} + + // Cleanup old entries every 5 minutes + setInterval(() => this.cleanup(), 5 * 60 * 1000) + } + + cleanup() { + const now = Date.now() + for (const [key, data] of this.requests.entries()) { + if (data.resetTime < now) { + this.requests.delete(key) + } + } + } + + /** + * Check and increment rate limit + * @param {string} key - Identifier (token or IP) + * @param {number} maxRequests - Max requests allowed + * @param {number} windowMs - Time window in milliseconds + * @returns {object} {allowed: boolean, remaining: number, resetTime: number} + */ + checkLimit(key, maxRequests, windowMs) { + const now = Date.now() + const data = this.requests.get(key) + + // No previous requests or window expired + if (!data || data.resetTime < now) { + this.requests.set(key, { + count: 1, + resetTime: now + windowMs + }) + return { + allowed: true, + remaining: maxRequests - 1, + resetTime: now + windowMs + } + } + + // Within window - check if limit exceeded + if (data.count >= maxRequests) { + return { + allowed: false, + remaining: 0, + resetTime: data.resetTime + } + } + + // Increment count + data.count++ + return { + allowed: true, + remaining: maxRequests - data.count, + resetTime: data.resetTime + } + } +} + +// Global rate limiter instance +const rateLimiter = new RateLimiter() + +/** + * Create rate limiting middleware + * @param {number} maxRequests - Maximum requests allowed (default: 100) + * @param {number} windowMs - Time window in milliseconds (default: 60000 = 1 minute) + */ +function createRateLimiter(maxRequests = 100, windowMs = 60000) { + return function(req, res, next) { + // Determine key: use token if authenticated via Bearer, otherwise IP + let key + if (req.authMethod === 'token' && req.user) { + key = `token:${req.user.id}` + } else { + // Get IP address (consider proxy headers) + key = `ip:${req.ip || req.connection.remoteAddress}` + } + + const result = rateLimiter.checkLimit(key, maxRequests, windowMs) + + // Set rate limit headers + res.setHeader('X-RateLimit-Limit', maxRequests) + res.setHeader('X-RateLimit-Remaining', result.remaining) + res.setHeader('X-RateLimit-Reset', Math.ceil(result.resetTime / 1000)) + + if (!result.allowed) { + const retryAfter = Math.ceil((result.resetTime - Date.now()) / 1000) + res.setHeader('Retry-After', retryAfter) + return res.apiError( + 'Rate limit exceeded. Please try again later.', + 'RATE_LIMIT_EXCEEDED', + 429 + ) + } + + next() + } +} + +module.exports = createRateLimiter diff --git a/api/middleware/response-formatter.js b/api/middleware/response-formatter.js new file mode 100644 index 0000000..e855eae --- /dev/null +++ b/api/middleware/response-formatter.js @@ -0,0 +1,58 @@ +/** + * Middleware to add consistent API response helpers to the response object + */ +function responseFormatter(req, res, next) { + /** + * Send a successful API response + * @param {*} data - Data to return + * @param {number} statusCode - HTTP status code (default: 200) + */ + res.apiSuccess = function(data = null, statusCode = 200, templateContext = null) { + const response = { + success: true, + data: data + }; + if (templateContext) response.templateContext = templateContext; + res.status(statusCode).json(response); + } + + /** + * Send an error API response + * @param {string} message - Error message + * @param {string} code - Error code for programmatic handling + * @param {number} statusCode - HTTP status code (default: 400) + */ + res.apiError = function(message, code = 'ERROR', statusCode = 400, templateContext = null) { + const response = { + success: false, + error: message, + code: code + }; + if (templateContext) response.templateContext = templateContext; + res.status(statusCode).json(response); + } + + /** + * Send a list API response with pagination info + * @param {array} items - Array of items + * @param {number} total - Total count (optional, defaults to items.length) + * @param {number} statusCode - HTTP status code (default: 200) + */ + res.apiList = function(items, total = null, statusCode = 200, templateContext = null) { + if (!Array.isArray(items)) { + items = []; + } + const response = { + success: true, + data: items, + count: items.length, + total: total !== null ? total : items.length + }; + if (templateContext) response.templateContext = templateContext; + res.status(statusCode).json(response); + } + + next() +} + +module.exports = responseFormatter \ No newline at end of file diff --git a/api/router.js b/api/router.js new file mode 100644 index 0000000..814372c --- /dev/null +++ b/api/router.js @@ -0,0 +1,58 @@ +const express = require('express') +const cors = require('cors') +const responseFormatter = require('./middleware/response-formatter') +const createRateLimiter = require('./middleware/rate-limiter') +const { errorHandler } = require('./middleware/error-handler') + +/** + * Main API Router (v1) + * Mounts all API endpoints under /api/v1 + */ +function createApiRouter(dependencies) { + const router = express.Router() + const { apiTokenRepository } = dependencies + + // CORS - allow all origins for public API + router.use(cors({ + origin: true, // Allow all origins + credentials: true, // Allow cookies/session + methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization'] + })) + + // Response formatting helpers + router.use(responseFormatter) + + // Rate limiting - 100 requests per minute per token/IP + router.use(createRateLimiter(100, 60000)) + + // Health check endpoint + router.get('/health', (req, res) => { + res.apiSuccess({ + status: 'ok', + version: '1.0.0', + timestamp: new Date().toISOString() + }) + }) + + // Mount sub-routers + router.use('/auth', require('./routes/auth')(dependencies)) + router.use('/account', require('./routes/account')(dependencies)) + router.use('/inbox', require('./routes/inbox')(dependencies)) + router.use('/mail', require('./routes/mail')(dependencies)) + router.use('/locks', require('./routes/locks')(dependencies)) + router.use('/stats', require('./routes/stats')(dependencies)) + router.use('/config', require('./routes/config')(dependencies)) + + // 404 handler for API routes + router.use((req, res) => { + res.apiError('Endpoint not found', 'NOT_FOUND', 404) + }) + + // Error handler (must be last) + router.use(errorHandler) + + return router +} + +module.exports = createApiRouter diff --git a/api/routes/account.api.md b/api/routes/account.api.md new file mode 100644 index 0000000..6d1214c --- /dev/null +++ b/api/routes/account.api.md @@ -0,0 +1,102 @@ +# Account Management API + +## Overview +Manage user accounts, forwarding emails, locked inboxes, and API tokens. + +--- + +## Endpoints + +### GET `/api/account/` +Get account info and stats for the authenticated user. +- **Auth:** Required +- **Response:** + - `userId`, `username`, `createdAt`, `lastLogin`, `verifiedEmails`, `lockedInboxes`, `apiToken` + +### POST `/api/account/verify-email` +Add a forwarding email (triggers verification). +- **Auth:** Required +- **Body:** + - `email`: string (required) +- **Response:** + - Success or error + +### DELETE `/api/account/verify-email/:id` +Remove a forwarding email by ID. +- **Auth:** Required +- **Response:** + - Success or error + +### POST `/api/account/change-password` +Change account password. +- **Auth:** Required +- **Body:** + - `oldPassword`, `newPassword` +- **Response:** + - Success or error + +### DELETE `/api/account/` +Delete the user account. +- **Auth:** Required +- **Response:** + - Success or error + +### GET `/api/account/token` +Get API token info (not the token itself). +- **Auth:** Required +- **Response:** + - `hasToken`, `createdAt`, `lastUsed` + +### POST `/api/account/token` +Generate or regenerate API token. +- **Auth:** Required +- **Response:** + - Success or error + +### DELETE `/api/account/token` +Revoke API token. +- **Auth:** Required +- **Response:** + - Success or error + +--- + +## Response Format +All responses follow: +``` +{ + success: true|false, + data: ..., + error?: ..., + code?: ... +} +``` + +## Error Codes +- `AUTH_DISABLED`: Authentication is disabled +- `VALIDATION_ERROR`: Invalid input +- `REGISTRATION_FAILED`: Registration failed +- `NOT_FOUND`: Resource not found +- `FORBIDDEN`: Unauthorized + +--- + +## Example Response +``` +{ + "success": true, + "data": { + "userId": "abc123", + "username": "user1", + "createdAt": "2026-01-01T00:00:00Z", + "lastLogin": "2026-01-05T12:00:00Z", + "verifiedEmails": ["forward@example.com"], + "lockedInboxes": ["inbox1@example.com"], + "apiToken": { + "hasToken": true, + "createdAt": "2026-01-01T00:00:00Z", + "lastUsed": "2026-01-05T12:00:00Z" + } + } +} +``` diff --git a/api/routes/account.js b/api/routes/account.js new file mode 100644 index 0000000..60a0edf --- /dev/null +++ b/api/routes/account.js @@ -0,0 +1,294 @@ +const express = require('express') +const { body, validationResult } = require('express-validator') +const createAuthenticator = require('../middleware/authenticator') +const { ApiError } = require('../middleware/error-handler') + +/** + * Account Management API Routes + * GET /account - Get account info with stats + * POST /verify-email - Add forwarding email (triggers verification) + * DELETE /verify-email/:id - Remove forwarding email + * POST /change-password - Change password + * DELETE /account - Delete account + * GET /token - Get API token info + * POST /token - Generate/regenerate API token + * DELETE /token - Revoke API token + */ +function createAccountRouter(dependencies) { + const router = express.Router() + const { + authService, + userRepository, + apiTokenRepository, + inboxLock, + config + } = dependencies + + // Check if auth is enabled + if (!authService || !config.user.authEnabled) { + router.all('*', (req, res) => { + res.apiError('Authentication is disabled', 'AUTH_DISABLED', 503) + }) + return router + } + + const { requireAuth } = createAuthenticator(apiTokenRepository) + + /** + * GET /account - Get account information + */ + router.get('/', requireAuth, async(req, res, next) => { + try { + const userId = req.user.id + + // Get user stats + const stats = userRepository.getUserStats(userId) + + // Get verified emails + const verifiedEmails = userRepository.getForwardEmails(userId) + + // Get locked inboxes + let lockedInboxes = [] + if (inboxLock) { + lockedInboxes = inboxLock.getUserLockedInboxes(userId) + } + + // Get API token info (without exposing the token itself) + let tokenInfo = null + if (apiTokenRepository) { + const token = apiTokenRepository.getByUserId(userId) + if (token) { + tokenInfo = { + hasToken: true, + createdAt: token.created_at, + lastUsed: token.last_used + } + } + } + + res.apiSuccess({ + userId: userId, + username: req.user.username, + createdAt: stats.created_at, + lastLogin: stats.last_login, + verifiedEmails: verifiedEmails, + lockedInboxes: lockedInboxes, + apiToken: tokenInfo + }) + } catch (error) { + next(error) + } + }) + + /** + * POST /verify-email - Add forwarding email (triggers verification) + */ + router.post('/verify-email', + requireAuth, + body('email').isEmail().normalizeEmail(), + async(req, res, next) => { + try { + const errors = validationResult(req) + if (!errors.isEmpty()) { + return res.apiError('Invalid email address', 'VALIDATION_ERROR', 400) + } + + const { email } = req.body + const userId = req.user.id + + // Check if user already has max verified emails + const count = userRepository.countVerifiedEmails(userId) + if (count >= config.user.maxVerifiedEmails) { + return res.apiError( + `Maximum ${config.user.maxVerifiedEmails} verified emails allowed`, + 'MAX_EMAILS_REACHED', + 400 + ) + } + + // Add email (will be marked as verified immediately for API) + // In a real implementation, you'd send a verification email + userRepository.addVerifiedEmail(userId, email) + + res.apiSuccess({ + message: 'Email added successfully', + email: email + }, 201) + } catch (error) { + if (error.message.includes('UNIQUE')) { + return res.apiError('Email already verified', 'DUPLICATE_EMAIL', 400) + } + next(error) + } + } + ) + + /** + * DELETE /verify-email/:id - Remove forwarding email + */ + router.delete('/verify-email/:id', requireAuth, async(req, res, next) => { + try { + const emailId = parseInt(req.params.id) + const userId = req.user.id + + if (isNaN(emailId)) { + return res.apiError('Invalid email ID', 'VALIDATION_ERROR', 400) + } + + const result = userRepository.removeVerifiedEmail(userId, emailId) + + if (result.changes === 0) { + return res.apiError('Email not found or unauthorized', 'NOT_FOUND', 404) + } + + res.apiSuccess({ message: 'Email removed successfully' }) + } catch (error) { + next(error) + } + }) + + /** + * POST /change-password - Change password + */ + router.post('/change-password', + requireAuth, + body('currentPassword').notEmpty(), + body('newPassword').isLength({ min: 8 }), + async(req, res, next) => { + try { + const errors = validationResult(req) + if (!errors.isEmpty()) { + return res.apiError('Invalid password format', 'VALIDATION_ERROR', 400) + } + + const { currentPassword, newPassword } = req.body + const userId = req.user.id + + // Verify current password + const isValid = userRepository.checkPassword(userId, currentPassword) + if (!isValid) { + return res.apiError('Current password is incorrect', 'INVALID_PASSWORD', 401) + } + + // Validate new password + const validation = authService.validatePassword(newPassword) + if (!validation.isValid) { + return res.apiError(validation.error, 'WEAK_PASSWORD', 400) + } + + // Change password + userRepository.changePassword(userId, newPassword) + + res.apiSuccess({ message: 'Password changed successfully' }) + } catch (error) { + next(error) + } + } + ) + + /** + * DELETE /account - Delete account + */ + router.delete('/', + requireAuth, + body('password').notEmpty(), + async(req, res, next) => { + try { + const errors = validationResult(req) + if (!errors.isEmpty()) { + return res.apiError('Password is required', 'VALIDATION_ERROR', 400) + } + + const { password } = req.body + const userId = req.user.id + + // Verify password + const isValid = userRepository.checkPassword(userId, password) + if (!isValid) { + return res.apiError('Incorrect password', 'INVALID_PASSWORD', 401) + } + + // Delete user (cascades to tokens, emails, locks) + userRepository.deleteUser(userId) + + // Destroy session + req.session.destroy((err) => { + if (err) { + return next(err) + } + + res.apiSuccess({ message: 'Account deleted successfully' }) + }) + } catch (error) { + next(error) + } + } + ) + + /** + * GET /token - Get API token info (not the token itself) + */ + router.get('/token', requireAuth, async(req, res, next) => { + try { + const userId = req.user.id + + const token = apiTokenRepository.getByUserId(userId) + + if (!token) { + return res.apiSuccess({ + hasToken: false + }) + } + + res.apiSuccess({ + hasToken: true, + createdAt: token.created_at, + lastUsed: token.last_used + }) + } catch (error) { + next(error) + } + }) + + /** + * POST /token - Generate or regenerate API token + */ + router.post('/token', requireAuth, async(req, res, next) => { + try { + const userId = req.user.id + + // Generate new token (replaces existing if any) + const newToken = apiTokenRepository.create(userId) + + res.apiSuccess({ + token: newToken, + message: 'API token generated successfully. Save this token - it will not be shown again.' + }, 201) + } catch (error) { + next(error) + } + }) + + /** + * DELETE /token - Revoke API token + */ + router.delete('/token', requireAuth, async(req, res, next) => { + try { + const userId = req.user.id + + const revoked = apiTokenRepository.revoke(userId) + + if (!revoked) { + return res.apiError('No token to revoke', 'NOT_FOUND', 404) + } + + res.apiSuccess({ message: 'API token revoked successfully' }) + } catch (error) { + next(error) + } + }) + + return router +} + +module.exports = createAccountRouter diff --git a/api/routes/auth.api.md b/api/routes/auth.api.md new file mode 100644 index 0000000..5dbcf1a --- /dev/null +++ b/api/routes/auth.api.md @@ -0,0 +1,68 @@ +# Authentication API + +## Overview +User registration, login, logout, and session management. + +--- + +## Endpoints + +### POST `/api/auth/register` +Register a new user. +- **Body:** + - `username`: string (3-20 chars, alphanumeric/underscore) + - `password`: string (min 8 chars) +- **Response:** + - `userId`, `username`, `message` +- **Errors:** + - `VALIDATION_ERROR`, `REGISTRATION_FAILED`, `AUTH_DISABLED` + +### POST `/api/auth/login` +Login user. +- **Body:** + - `username`, `password` +- **Response:** + - `userId`, `username`, `message` +- **Errors:** + - `VALIDATION_ERROR`, `AUTH_DISABLED` + +### POST `/api/auth/logout` +Logout user. +- **Response:** + - Success or error + +### GET `/api/auth/session` +Get current session info. +- **Response:** + - `userId`, `username`, `isAuthenticated`, `createdAt` + +--- + +## Response Format +``` +{ + success: true|false, + data: ..., + error?: ..., + code?: ... +} +``` + +## Error Codes +- `AUTH_DISABLED`: Authentication is disabled +- `VALIDATION_ERROR`: Invalid input +- `REGISTRATION_FAILED`: Registration failed + +--- + +## Example Response +``` +{ + "success": true, + "data": { + "userId": "abc123", + "username": "user1", + "message": "Registration successful" + } +} +``` diff --git a/api/routes/auth.js b/api/routes/auth.js new file mode 100644 index 0000000..eec16ef --- /dev/null +++ b/api/routes/auth.js @@ -0,0 +1,150 @@ +const express = require('express') +const { body, validationResult } = require('express-validator') +const { ApiError } = require('../middleware/error-handler') + +/** + * Authentication API Routes + * POST /register - Register new user + * POST /login - Login user + * POST /logout - Logout user + * GET /session - Get current session info + */ +function createAuthRouter(dependencies) { + const router = express.Router() + const { authService, config } = dependencies + + // Check if auth is enabled + if (!authService || !config.user.authEnabled) { + router.all('*', (req, res) => { + res.apiError('Authentication is disabled', 'AUTH_DISABLED', 503) + }) + return router + } + + /** + * POST /register - Register new user + */ + router.post('/register', + body('username').trim().isLength({ min: 3, max: 20 }).matches(/^[a-zA-Z0-9_]+$/), + body('password').isLength({ min: 8 }), + async(req, res, next) => { + try { + const errors = validationResult(req) + if (!errors.isEmpty()) { + return res.apiError('Invalid username or password format', 'VALIDATION_ERROR', 400) + } + + const { username, password } = req.body + + // Attempt to create user + const result = await authService.register(username, password) + + if (!result.success) { + return res.apiError(result.error, 'REGISTRATION_FAILED', 400) + } + + // Create session + req.session.userId = result.user.id + req.session.username = username + req.session.isAuthenticated = true + req.session.createdAt = Date.now() + + res.apiSuccess({ + userId: result.user.id, + username: username, + message: 'Registration successful' + }, 201) + } catch (error) { + next(error) + } + } + ) + + /** + * POST /login - Login user + */ + router.post('/login', + body('username').trim().notEmpty(), + body('password').notEmpty(), + async(req, res, next) => { + try { + const errors = validationResult(req) + if (!errors.isEmpty()) { + return res.apiError('Username and password are required', 'VALIDATION_ERROR', 400) + } + + const { username, password } = req.body + + // Authenticate user + const result = await authService.login(username, password) + + if (!result.success) { + return res.apiError('Invalid username or password', 'INVALID_CREDENTIALS', 401) + } + + // Regenerate session to prevent fixation + req.session.regenerate((err) => { + if (err) { + return next(err) + } + + req.session.userId = result.user.id + req.session.username = username + req.session.isAuthenticated = true + req.session.createdAt = Date.now() + + res.apiSuccess({ + userId: result.user.id, + username: username, + message: 'Login successful' + }) + }) + } catch (error) { + next(error) + } + } + ) + + /** + * POST /logout - Logout user + */ + router.post('/logout', (req, res, next) => { + try { + if (!req.session || !req.session.isAuthenticated) { + return res.apiError('Not logged in', 'NOT_AUTHENTICATED', 401) + } + + req.session.destroy((err) => { + if (err) { + return next(err) + } + + res.apiSuccess({ message: 'Logout successful' }) + }) + } catch (error) { + next(error) + } + }) + + /** + * GET /session - Get current session info + */ + router.get('/session', (req, res) => { + if (req.session && req.session.isAuthenticated && req.session.userId) { + res.apiSuccess({ + authenticated: true, + userId: req.session.userId, + username: req.session.username, + createdAt: req.session.createdAt + }) + } else { + res.apiSuccess({ + authenticated: false + }) + } + }) + + return router +} + +module.exports = createAuthRouter diff --git a/api/routes/config.api.md b/api/routes/config.api.md new file mode 100644 index 0000000..940b013 --- /dev/null +++ b/api/routes/config.api.md @@ -0,0 +1,45 @@ +# Configuration API + +## Overview +Public endpoints for configuration, domains, limits, and features. + +--- + +## Endpoints + +### GET `/api/config/domains` +Get allowed email domains. +- **Response:** + - `domains`: array of strings + +### GET `/api/config/limits` +Get rate limits and constraints. +- **Response:** + - `api.rateLimit`, `email.purgeTime`, `email.purgeUnit`, `email.maxForwardedPerRequest`, `user.maxVerifiedEmails`, `user.maxLockedInboxes`, `user.lockReleaseHours` + +### GET `/api/config/features` +Get enabled features. +- **Response:** + - `authentication`, `forwarding`, `statistics`, `inboxLocking` + +--- + +## Response Format +``` +{ + success: true|false, + data: ... +} +``` + +--- + +## Example Response +``` +{ + "success": true, + "data": { + "domains": ["example.com", "demo.com"] + } +} +``` diff --git a/api/routes/config.js b/api/routes/config.js new file mode 100644 index 0000000..6351a5a --- /dev/null +++ b/api/routes/config.js @@ -0,0 +1,68 @@ +const express = require('express') + +/** + * Configuration API Routes (Public) + * GET /domains - Get allowed email domains + * GET /limits - Get rate limits and constraints + * GET /features - Get enabled features + */ +function createConfigRouter(dependencies) { + // API enabled toggle + router.use((req, res, next) => { + if (!config.apiEnabled) { + return res.apiError('API is disabled', 'API_DISABLED', 503); + } + next(); + }); + const router = express.Router() + const { config } = dependencies + + /** + * GET /domains - Get allowed email domains + */ + router.get('/domains', (req, res) => { + res.apiSuccess({ + domains: config.email.domains + }) + }) + + /** + * GET /limits - Get rate limits and constraints + */ + router.get('/limits', (req, res) => { + res.apiSuccess({ + api: { + rateLimit: { + requests: 100, + window: '1 minute' + } + }, + email: { + purgeTime: config.email.purgeTime, + purgeUnit: config.email.purgeUnit, + maxForwardedPerRequest: 25 + }, + user: { + maxVerifiedEmails: config.user.maxVerifiedEmails || 5, + maxLockedInboxes: config.user.maxLockedInboxes || 5, + lockReleaseHours: config.user.lockReleaseHours || 168 + } + }) + }) + + /** + * GET /features - Get enabled features + */ + router.get('/features', (req, res) => { + res.apiSuccess({ + authentication: config.user.authEnabled, + forwarding: config.smtp.enabled, + statistics: config.http.statisticsEnabled, + inboxLocking: config.user.authEnabled + }) + }) + + return router +} + +module.exports = createConfigRouter \ No newline at end of file diff --git a/api/routes/inbox.api.md b/api/routes/inbox.api.md new file mode 100644 index 0000000..0cf5e1e --- /dev/null +++ b/api/routes/inbox.api.md @@ -0,0 +1,80 @@ +# Inbox & Mail Retrieval API + +## Overview +Endpoints for listing emails, retrieving full/raw emails, and downloading attachments. + +--- + +## Endpoints + +### GET `/api/inbox/:address` +List mail summaries for an inbox. +- **Auth:** Optional +- **Response:** + - Array of mail summary objects + +### GET `/api/inbox/:address/:uid` +Get full email by UID. +- **Auth:** Optional +- **Response:** + - `uid`, `to`, `from`, `date`, `subject`, `text`, `html`, `attachments` +- **Errors:** + - `VALIDATION_ERROR`, `NOT_FOUND` + +### GET `/api/inbox/:address/:uid/raw` +Get raw email source. +- **Auth:** Optional +- **Response:** + - Raw email string +- **Errors:** + - `VALIDATION_ERROR`, `NOT_FOUND` + +### GET `/api/inbox/:address/:uid/attachment/:checksum` +Download attachment by checksum. +- **Auth:** Optional +- **Response:** + - Attachment file +- **Errors:** + - `VALIDATION_ERROR`, `NOT_FOUND` + +--- + +## Response Format +``` +{ + success: true|false, + data: ..., + error?: ..., + code?: ... +} +``` + +## Error Codes +- `VALIDATION_ERROR`: Invalid input +- `NOT_FOUND`: Resource not found + +--- + +## Example Response +``` +{ + "success": true, + "data": { + "uid": 123, + "to": "user@example.com", + "from": "sender@example.com", + "date": "2026-01-05T12:00:00Z", + "subject": "Hello", + "text": "Plain text body", + "html": "

Hello

", + "attachments": [ + { + "filename": "file.txt", + "contentType": "text/plain", + "size": 1024, + "checksum": "abc123" + } + ] + } +} +``` diff --git a/api/routes/inbox.js b/api/routes/inbox.js new file mode 100644 index 0000000..84fc123 --- /dev/null +++ b/api/routes/inbox.js @@ -0,0 +1,141 @@ +const express = require('express') +const createAuthenticator = require('../middleware/authenticator') + +/** + * Inbox & Mail Retrieval API Routes + * GET /:address - List emails in inbox + * GET /:address/:uid - Get full email by UID + * GET /:address/:uid/raw - Get raw email source + * GET /:address/:uid/attachment/:checksum - Download attachment + */ +function createInboxRouter(dependencies) { + const router = express.Router() + const { mailProcessingService, apiTokenRepository } = dependencies + + const { optionalAuth } = createAuthenticator(apiTokenRepository) + + /** + * GET /:address - List mail summaries for an inbox + */ + router.get('/:address', optionalAuth, async(req, res, next) => { + try { + const address = req.params.address.toLowerCase() + + // Get mail summaries + const mails = mailProcessingService.getMailSummaries(address) + + res.apiList(mails) + } catch (error) { + next(error) + } + }) + + /** + * GET /:address/:uid - Get full email by UID + */ + router.get('/:address/:uid', optionalAuth, async(req, res, next) => { + try { + const address = req.params.address.toLowerCase() + const uid = parseInt(req.params.uid) + + if (isNaN(uid)) { + return res.apiError('Invalid UID', 'VALIDATION_ERROR', 400) + } + + // Get full email + const mail = await mailProcessingService.getOneFullMail(address, uid, false) + + if (!mail) { + return res.apiError('Email not found', 'NOT_FOUND', 404) + } + + // Format response + const response = { + uid: uid, + to: mail.to, + from: mail.from, + date: mail.date, + subject: mail.subject, + text: mail.text, + html: mail.html, + attachments: mail.attachments ? mail.attachments.map(att => ({ + filename: att.filename, + contentType: att.contentType, + size: att.content ? att.content.length : 0, + checksum: att.checksum + })) : [] + } + + res.apiSuccess(response) + } catch (error) { + next(error) + } + }) + + /** + * GET /:address/:uid/raw - Get raw email source + */ + router.get('/:address/:uid/raw', optionalAuth, async(req, res, next) => { + try { + const address = req.params.address.toLowerCase() + const uid = parseInt(req.params.uid) + + if (isNaN(uid)) { + return res.apiError('Invalid UID', 'VALIDATION_ERROR', 400) + } + + // Get raw email + const rawMail = await mailProcessingService.getOneFullMail(address, uid, true) + + if (!rawMail) { + return res.apiError('Email not found', 'NOT_FOUND', 404) + } + + // Return as plain text + res.setHeader('Content-Type', 'text/plain') + res.send(rawMail) + } catch (error) { + next(error) + } + }) + + /** + * GET /:address/:uid/attachment/:checksum - Download attachment + */ + router.get('/:address/:uid/attachment/:checksum', optionalAuth, async(req, res, next) => { + try { + const address = req.params.address.toLowerCase() + const uid = parseInt(req.params.uid) + const checksum = req.params.checksum + + if (isNaN(uid)) { + return res.apiError('Invalid UID', 'VALIDATION_ERROR', 400) + } + + // Get full email to access attachments + const mail = await mailProcessingService.getOneFullMail(address, uid, false) + + if (!mail || !mail.attachments) { + return res.apiError('Email or attachment not found', 'NOT_FOUND', 404) + } + + // Find attachment by checksum + const attachment = mail.attachments.find(att => att.checksum === checksum) + + if (!attachment) { + return res.apiError('Attachment not found', 'NOT_FOUND', 404) + } + + // Send attachment + res.setHeader('Content-Type', attachment.contentType || 'application/octet-stream') + res.setHeader('Content-Disposition', `attachment; filename="${attachment.filename}"`) + res.send(attachment.content) + } catch (error) { + next(error) + } + }) + + return router +} + +module.exports = createInboxRouter diff --git a/api/routes/locks.api.md b/api/routes/locks.api.md new file mode 100644 index 0000000..12c7ef8 --- /dev/null +++ b/api/routes/locks.api.md @@ -0,0 +1,90 @@ +# Inbox Lock Management API + +## Overview +APIs for managing locked inboxes for users. All responses include a `templateContext` for UI integration. + +--- + +## Endpoints + +### GET `/api/locks/` +List all inboxes locked by the authenticated user. +- **Auth:** Required +- **Response:** + - `success`: true + - `data`: array of locked inboxes + - `templateContext`: `{ userId, config: { maxLockedInboxes } }` + +### POST `/api/locks/` +Lock an inbox for the authenticated user. +- **Auth:** Required +- **Body:** + - `address`: string (email, required) + - `password`: string (optional) +- **Response:** + - `success`: true + - `data`: `{ message, address }` + - `templateContext`: `{ userId, address }` +- **Errors:** + - Validation error: `VALIDATION_ERROR` + - Max locks reached: `MAX_LOCKS_REACHED` + - Already locked: `ALREADY_LOCKED` + - Locked by other: `LOCKED_BY_OTHER` + - All errors include `templateContext` + +### DELETE `/api/locks/:address` +Unlock/release a locked inbox. +- **Auth:** Required +- **Response:** + - `success`: true + - `data`: `{ message }` + - `templateContext`: `{ userId, address }` +- **Errors:** + - Not found/unauthorized: `NOT_FOUND` (includes `templateContext`) + +### GET `/api/locks/:address/status` +Check if an inbox is locked and if owned by the user. +- **Auth:** Optional +- **Response:** + - `success`: true + - `data`: `{ address, locked, ownedByYou? }` + - `templateContext`: `{ address, isLocked, ownedByYou? }` + +--- + +## Response Format +All responses include a `templateContext` field for UI rendering context. + +``` +{ + success: true|false, + data: ..., + error?: ..., + code?: ..., + templateContext: {...} +} +``` + +## Error Codes +- `FEATURE_DISABLED`: Inbox locking is disabled +- `VALIDATION_ERROR`: Invalid email address +- `MAX_LOCKS_REACHED`: Maximum locked inboxes reached +- `ALREADY_LOCKED`: User already owns the lock +- `LOCKED_BY_OTHER`: Inbox locked by another user +- `NOT_FOUND`: Lock not found or unauthorized + +--- + +## Example Response +``` +{ + "success": true, + "data": ["user1@example.com", "user2@example.com"], + "count": 2, + "total": 2, + "templateContext": { + "userId": "abc123", + "config": { "maxLockedInboxes": 3 } + } +} +``` diff --git a/api/routes/locks.js b/api/routes/locks.js new file mode 100644 index 0000000..32da828 --- /dev/null +++ b/api/routes/locks.js @@ -0,0 +1,136 @@ +const express = require('express') +const { body, validationResult } = require('express-validator') +const createAuthenticator = require('../middleware/authenticator') + +/** + * Inbox Lock Management API Routes + * GET / - List user's locked inboxes + * POST / - Lock an inbox + * DELETE /:address - Unlock/release inbox + * GET /:address/status - Check if inbox is locked + */ +function createLocksRouter(dependencies) { + const router = express.Router() + const { inboxLock, apiTokenRepository, config } = dependencies + + if (!inboxLock || !config.user.authEnabled) { + router.all('*', (req, res) => { + res.apiError('Inbox locking is disabled', 'FEATURE_DISABLED', 503) + }) + return router + } + + const { requireAuth, optionalAuth } = createAuthenticator(apiTokenRepository) + + /** + * GET / - List user's locked inboxes + */ + router.get('/', requireAuth, async(req, res, next) => { + try { + const userId = req.user.id; + const locks = inboxLock.getUserLockedInboxes(userId); + const templateContext = { userId, config: { maxLockedInboxes: config.user.maxLockedInboxes } }; + res.apiList(locks, null, 200, templateContext); + } catch (error) { + next(error); + } + }) + + /** + * POST / - Lock an inbox + */ + router.post('/', + requireAuth, + body('address').isEmail().normalizeEmail(), + body('password').optional().isString(), + async(req, res, next) => { + try { + const errors = validationResult(req) + if (!errors.isEmpty()) { + return res.apiError('Invalid email address', 'VALIDATION_ERROR', 400, { userId: req.user.id }) + } + + const { address } = req.body + const userId = req.user.id + + // Check if user can lock more inboxes + if (!inboxLock.canLockMore(userId)) { + return res.apiError( + `Maximum ${config.user.maxLockedInboxes} locked inboxes allowed`, + 'MAX_LOCKS_REACHED', + 400, { userId, config: { maxLockedInboxes: config.user.maxLockedInboxes } } + ) + } + + // Check if inbox is already locked + if (inboxLock.isLocked(address)) { + const isOwner = inboxLock.isOwner(address, userId) + if (isOwner) { + return res.apiError('You already own this lock', 'ALREADY_LOCKED', 400, { userId }) + } + return res.apiError('Inbox is locked by another user', 'LOCKED_BY_OTHER', 403) + return res.apiError('Inbox is locked by another user', 'LOCKED_BY_OTHER', 403, { userId }) + } + + // Lock inbox + inboxLock.lock(userId, address, '') + + res.apiSuccess({ + message: 'Inbox locked successfully', + address: address + }, 201, { userId, address }) + } catch (error) { + next(error) + } + } + ) + + /** + * DELETE /:address - Unlock/release inbox + */ + router.delete('/:address', requireAuth, async(req, res, next) => { + try { + const address = req.params.address.toLowerCase() + const userId = req.user.id + + // Check if user owns this lock + if (!inboxLock.isOwner(address, userId)) { + return res.apiError('Lock not found or unauthorized', 'NOT_FOUND', 404, { userId, address }) + } + + // Release lock + inboxLock.release(userId, address) + + res.apiSuccess({ message: 'Inbox unlocked successfully' }, 200, { userId, address }) + } catch (error) { + next(error) + } + }) + + /** + * GET /:address/status - Check if inbox is locked + */ + router.get('/:address/status', optionalAuth, async(req, res, next) => { + try { + const address = req.params.address.toLowerCase(); + const isLocked = inboxLock.isLocked(address); + const templateContext = { address, isLocked }; + const response = { + address: address, + locked: isLocked + }; + // If user is authenticated, check if they own the lock + if (req.user && isLocked) { + response.ownedByYou = inboxLock.isOwner(address, req.user.id); + templateContext.ownedByYou = response.ownedByYou; + } + res.apiSuccess(response, 200, templateContext); + } catch (error) { + next(error) + } + }) + + return router +} + +module.exports = createLocksRouter \ No newline at end of file diff --git a/api/routes/mail.api.md b/api/routes/mail.api.md new file mode 100644 index 0000000..fa20c2c --- /dev/null +++ b/api/routes/mail.api.md @@ -0,0 +1,74 @@ +# Mail Operations API + +## Overview +Endpoints for deleting emails and forwarding mail. + +--- + +## Endpoints + +### DELETE `/api/mail/inbox/:address/:uid` +Delete a single email by UID. +- **Auth:** Optional +- **Response:** + - Success message +- **Errors:** + - `VALIDATION_ERROR`, `NOT_FOUND` + +### DELETE `/api/mail/inbox/:address` +Delete all emails in an inbox (requires `?confirm=true`). +- **Auth:** Optional +- **Response:** + - Success message, deleted count +- **Errors:** + - `CONFIRMATION_REQUIRED`, `NOT_FOUND` + +### POST `/api/mail/forward` +Forward a single email. +- **Auth:** Required +- **Body:** + - `address`, `uid`, `to` +- **Response:** + - Success message +- **Errors:** + - `VALIDATION_ERROR`, `NOT_FOUND`, `FORWARD_FAILED` + +### POST `/api/mail/forward-all` +Forward all emails in an inbox. +- **Auth:** Required +- **Body:** + - `address`, `to` +- **Response:** + - Success message +- **Errors:** + - `VALIDATION_ERROR`, `NOT_FOUND`, `FORWARD_FAILED` + +--- + +## Response Format +``` +{ + success: true|false, + data: ..., + error?: ..., + code?: ... +} +``` + +## Error Codes +- `VALIDATION_ERROR`: Invalid input +- `NOT_FOUND`: Resource not found +- `CONFIRMATION_REQUIRED`: Confirmation required for bulk delete +- `FORWARD_FAILED`: Forwarding failed + +--- + +## Example Response +``` +{ + "success": true, + "data": { + "message": "Email deleted successfully" + } +} +``` diff --git a/api/routes/mail.js b/api/routes/mail.js new file mode 100644 index 0000000..f2ab4c6 --- /dev/null +++ b/api/routes/mail.js @@ -0,0 +1,228 @@ +const express = require('express') +const { body, validationResult } = require('express-validator') +const createAuthenticator = require('../middleware/authenticator') +const { ApiError } = require('../middleware/error-handler') + +/** + * Mail Operations API Routes + * DELETE /inbox/:address/:uid - Delete single email + * DELETE /inbox/:address - Delete all emails in inbox + * POST /forward - Forward single email + * POST /forward-all - Forward all emails in inbox + */ +function createMailRouter(dependencies) { + const router = express.Router() + const { + mailProcessingService, + apiTokenRepository, + userRepository, + config + } = dependencies + + const { requireAuth, optionalAuth } = createAuthenticator(apiTokenRepository) + + /** + * DELETE /inbox/:address/:uid - Delete single email + */ + router.delete('/inbox/:address/:uid', optionalAuth, async(req, res, next) => { + try { + const address = req.params.address.toLowerCase() + const uid = parseInt(req.params.uid) + + if (isNaN(uid)) { + return res.apiError('Invalid UID', 'VALIDATION_ERROR', 400) + } + + // Check if email exists + const mails = mailProcessingService.getMailSummaries(address) + const mailExists = mails.some(m => m.uid === uid) + + if (!mailExists) { + return res.apiError('Email not found', 'NOT_FOUND', 404) + } + + // Delete email + await mailProcessingService.deleteSpecificEmail(address, uid) + + res.apiSuccess({ message: 'Email deleted successfully' }) + } catch (error) { + next(error) + } + }) + + /** + * DELETE /inbox/:address - Delete all emails in inbox + */ + router.delete('/inbox/:address', optionalAuth, async(req, res, next) => { + try { + const address = req.params.address.toLowerCase() + const { confirm } = req.query + + if (confirm !== 'true') { + return res.apiError( + 'Confirmation required. Add ?confirm=true to delete all emails', + 'CONFIRMATION_REQUIRED', + 400 + ) + } + + // Get all mail UIDs + const mails = mailProcessingService.getMailSummaries(address) + + if (mails.length === 0) { + return res.apiSuccess({ + message: 'No emails to delete', + deleted: 0 + }) + } + + // Delete all emails + for (const mail of mails) { + await mailProcessingService.deleteSpecificEmail(address, mail.uid) + } + + res.apiSuccess({ + message: `Deleted ${mails.length} email(s)`, + deleted: mails.length + }) + } catch (error) { + next(error) + } + }) + + /** + * POST /forward - Forward single email + */ + router.post('/forward', + requireAuth, + body('sourceAddress').isEmail().normalizeEmail(), + body('uid').isInt({ min: 1 }), + body('destinationEmail').isEmail().normalizeEmail(), + async(req, res, next) => { + try { + const errors = validationResult(req) + if (!errors.isEmpty()) { + return res.apiError('Invalid input parameters', 'VALIDATION_ERROR', 400) + } + + if (!config.smtp.enabled) { + return res.apiError('Email forwarding is disabled', 'FEATURE_DISABLED', 503) + } + + const { sourceAddress, uid, destinationEmail } = req.body + const userId = req.user.id + + // Check if destination email is verified + const forwardEmails = userRepository.getForwardEmails(userId) + const isVerified = forwardEmails.some(e => e.email.toLowerCase() === destinationEmail.toLowerCase()) + + if (!isVerified) { + return res.apiError( + 'Destination email must be verified. Add it in your account settings first.', + 'EMAIL_NOT_VERIFIED', + 400 + ) + } + + // Forward email + const result = await mailProcessingService.forwardMail( + sourceAddress.toLowerCase(), + parseInt(uid), + destinationEmail.toLowerCase() + ) + + if (!result.success) { + return res.apiError(result.error || 'Forward failed', 'FORWARD_FAILED', 400) + } + + res.apiSuccess({ + message: 'Email forwarded successfully', + destination: destinationEmail + }) + } catch (error) { + next(error) + } + } + ) + + /** + * POST /forward-all - Forward all emails in inbox (max 25) + */ + router.post('/forward-all', + requireAuth, + body('sourceAddress').isEmail().normalizeEmail(), + body('destinationEmail').isEmail().normalizeEmail(), + async(req, res, next) => { + try { + const errors = validationResult(req) + if (!errors.isEmpty()) { + return res.apiError('Invalid input parameters', 'VALIDATION_ERROR', 400) + } + + if (!config.smtp.enabled) { + return res.apiError('Email forwarding is disabled', 'FEATURE_DISABLED', 503) + } + + const { sourceAddress, destinationEmail } = req.body + const userId = req.user.id + + // Check if destination email is verified + const forwardEmails = userRepository.getForwardEmails(userId) + const isVerified = forwardEmails.some(e => e.email.toLowerCase() === destinationEmail.toLowerCase()) + + if (!isVerified) { + return res.apiError( + 'Destination email must be verified. Add it in your account settings first.', + 'EMAIL_NOT_VERIFIED', + 400 + ) + } + + // Get all mails (max 25) + const mails = mailProcessingService.getMailSummaries(sourceAddress.toLowerCase()) + const mailsToForward = mails.slice(0, 25) + + if (mailsToForward.length === 0) { + return res.apiSuccess({ + message: 'No emails to forward', + forwarded: 0 + }) + } + + // Forward all emails + let successCount = 0 + const errors = [] + + for (const mail of mailsToForward) { + try { + const result = await mailProcessingService.forwardMail( + sourceAddress.toLowerCase(), + mail.uid, + destinationEmail.toLowerCase() + ) + if (result.success) { + successCount++ + } else { + errors.push({ uid: mail.uid, error: result.error }) + } + } catch (error) { + errors.push({ uid: mail.uid, error: error.message }) + } + } + + res.apiSuccess({ + message: `Forwarded ${successCount} of ${mailsToForward.length} email(s)`, + forwarded: successCount, + total: mailsToForward.length, + errors: errors.length > 0 ? errors : undefined + }) + } catch (error) { + next(error) + } + } + ) + + return router +} + +module.exports = createMailRouter diff --git a/api/routes/stats.api.md b/api/routes/stats.api.md new file mode 100644 index 0000000..07481ed --- /dev/null +++ b/api/routes/stats.api.md @@ -0,0 +1,55 @@ +# Statistics API + +## Overview +Endpoints for retrieving statistics and historical data. + +--- + +## Endpoints + +### GET `/api/stats/` +Get lightweight statistics (no historical analysis). +- **Response:** + - `currentCount`, `allTimeTotal`, `last24Hours` (object with `receives`, `deletes`, `forwards`, `timeline`) + +### GET `/api/stats/enhanced` +Get full statistics with historical data and predictions. +- **Response:** + - `currentCount`, `allTimeTotal`, `last24Hours`, `historical`, `prediction`, `enhanced` + +--- + +## Response Format +``` +{ + success: true|false, + data: ..., + error?: ..., + code?: ... +} +``` + +## Error Codes +- `FEATURE_DISABLED`: Statistics are disabled + +--- + +## Example Response +``` +{ + "success": true, + "data": { + "currentCount": 123, + "allTimeTotal": 4567, + "last24Hours": { + "receives": 10, + "deletes": 2, + "forwards": 1, + "timeline": [ ... ] + }, + "historical": [ ... ], + "prediction": [ ... ], + "enhanced": { ... } + } +} +``` diff --git a/api/routes/stats.js b/api/routes/stats.js new file mode 100644 index 0000000..ee91272 --- /dev/null +++ b/api/routes/stats.js @@ -0,0 +1,54 @@ +const express = require('express') + +/** + * Statistics API Routes + * GET / - Get lightweight statistics + * GET /enhanced - Get full statistics with historical data + */ +function createStatsRouter(dependencies) { + const router = express.Router() + const { statisticsStore, mailProcessingService, imapService, config } = dependencies + + if (!config.http.statisticsEnabled) { + router.all('*', (req, res) => { + res.apiError('Statistics are disabled', 'FEATURE_DISABLED', 503) + }) + return router + } + + /** + * GET / - Get lightweight statistics (no historical analysis) + */ + router.get('/', async(req, res, next) => { + try { + const stats = statisticsStore.getLightweightStats() + + res.apiSuccess(stats) + } catch (error) { + next(error) + } + }) + + /** + * GET /enhanced - Get full statistics with historical data + */ + router.get('/enhanced', async(req, res, next) => { + try { + // Analyze all existing emails for historical data + if (mailProcessingService) { + const allMails = mailProcessingService.getAllMailSummaries() + statisticsStore.analyzeHistoricalData(allMails) + } + + const stats = statisticsStore.getEnhancedStats() + + res.apiSuccess(stats) + } catch (error) { + next(error) + } + }) + + return router +} + +module.exports = createStatsRouter diff --git a/app.js b/app.js index a042b27..857801f 100644 --- a/app.js +++ b/app.js @@ -37,6 +37,7 @@ const VerificationStore = require('./domain/verification-store') const UserRepository = require('./domain/user-repository') const MockUserRepository = require('./application/mocks/mock-user-repository') const StatisticsStore = require('./domain/statistics-store') +const ApiTokenRepository = require('./domain/api-token-repository') const clientNotification = new ClientNotification() debug('Client notification service initialized') @@ -61,6 +62,7 @@ app.set('config', config) // Initialize user repository and auth service (if enabled) let inboxLock = null let statisticsStore = null +let apiTokenRepository = null if (config.user.authEnabled && !config.uxDebugMode) { // Migrate legacy database files for backwards compatibility @@ -70,6 +72,11 @@ if (config.user.authEnabled && !config.uxDebugMode) { debug('User repository initialized') app.set('userRepository', userRepository) + // Initialize API token repository with same database connection + apiTokenRepository = new ApiTokenRepository(userRepository.db) + debug('API token repository initialized') + app.set('apiTokenRepository', apiTokenRepository) + // Initialize statistics store with database connection statisticsStore = new StatisticsStore(userRepository.db) debug('Statistics store initialized with database persistence') @@ -122,6 +129,7 @@ if (config.user.authEnabled && !config.uxDebugMode) { } else { debug('Statistics store initialized (in-memory only, no database)') app.set('userRepository', null) + app.set('apiTokenRepository', null) app.set('authService', null) app.set('inboxLock', null) debug('User authentication system disabled') diff --git a/application/config.js b/application/config.js index b307277..48dccaf 100644 --- a/application/config.js +++ b/application/config.js @@ -37,6 +37,7 @@ function parseBool(v) { } const config = { + apiEnabled: parseBool(process.env.API_ENABLED) !== false, // default true // UX Debug Mode uxDebugMode: parseBool(process.env.UX_DEBUG_MODE) || false, @@ -139,4 +140,4 @@ if (!config.email.domains.length) { debug(`Configuration validated successfully: ${config.email.domains.length} domains${config.uxDebugMode ? ' (UX DEBUG MODE)' : ''}`) -module.exports = config; +module.exports = config; \ No newline at end of file diff --git a/domain/api-token-repository.js b/domain/api-token-repository.js new file mode 100644 index 0000000..5c0fcb0 --- /dev/null +++ b/domain/api-token-repository.js @@ -0,0 +1,108 @@ +const Helper = require('../application/helper') +const helper = new Helper() + +class ApiTokenRepository { + constructor(db) { + if (!db) { + throw new Error('ApiTokenRepository requires a database connection') + } + this.db = db + } + + /** + * Generate and store a new API token for a user + * If user already has a token, it will be replaced + * @param {number} userId + * @returns {string} The generated token + */ + create(userId) { + const token = helper.generateVerificationToken() // 64 chars hex + const now = Date.now() + + // Delete existing token if any (one token per user) + this.db.prepare('DELETE FROM api_tokens WHERE user_id = ?').run(userId) + + // Insert new token + this.db.prepare(` + INSERT INTO api_tokens (user_id, token, created_at) + VALUES (?, ?, ?) + `).run(userId, token, now) + + return token + } + + /** + * Get token information by token string + * @param {string} token + * @returns {object|null} Token info with user data + */ + getByToken(token) { + return this.db.prepare(` + SELECT + t.id, + t.user_id, + t.token, + t.created_at, + t.last_used, + u.username + FROM api_tokens t + JOIN users u ON t.user_id = u.id + WHERE t.token = ? + `).get(token) + } + + /** + * Get token information by user ID + * @param {number} userId + * @returns {object|null} Token info (without sensitive data in some contexts) + */ + getByUserId(userId) { + return this.db.prepare(` + SELECT id, user_id, token, created_at, last_used + FROM api_tokens + WHERE user_id = ? + `).get(userId) + } + + /** + * Check if user has an API token + * @param {number} userId + * @returns {boolean} + */ + hasToken(userId) { + const result = this.db.prepare(` + SELECT COUNT(*) as count + FROM api_tokens + WHERE user_id = ? + `).get(userId) + return result.count > 0 + } + + /** + * Revoke (delete) user's API token + * @param {number} userId + * @returns {boolean} True if token was deleted + */ + revoke(userId) { + const result = this.db.prepare(` + DELETE FROM api_tokens + WHERE user_id = ? + `).run(userId) + return result.changes > 0 + } + + /** + * Update the last_used timestamp for a token + * @param {string} token + */ + updateLastUsed(token) { + const now = Date.now() + this.db.prepare(` + UPDATE api_tokens + SET last_used = ? + WHERE token = ? + `).run(now, token) + } +} + +module.exports = ApiTokenRepository diff --git a/domain/statistics-store.js b/domain/statistics-store.js index 1c68ba9..2493676 100644 --- a/domain/statistics-store.js +++ b/domain/statistics-store.js @@ -650,4 +650,4 @@ class StatisticsStore { } } -module.exports = StatisticsStore +module.exports = StatisticsStore \ No newline at end of file diff --git a/infrastructure/web/web.js b/infrastructure/web/web.js index 6d13e72..767784a 100644 --- a/infrastructure/web/web.js +++ b/infrastructure/web/web.js @@ -11,6 +11,7 @@ const helmet = require('helmet') const socketio = require('socket.io') const config = require('../../application/config') +const createApiRouter = require('../../api/router') const inboxRouter = require('./routes/inbox') const loginRouter = require('./routes/login') const errorRouter = require('./routes/error') @@ -152,6 +153,26 @@ app.use((req, res, next) => { next() }) +// Mount API router (v1) +app.use('/api/v1', (req, res, next) => { + const apiTokenRepository = req.app.get('apiTokenRepository') + const dependencies = { + apiTokenRepository, + mailProcessingService: req.app.get('mailProcessingService'), + authService: req.app.get('authService'), + userRepository: req.app.get('userRepository'), + imapService: req.app.get('imapService'), + inboxLock: req.app.get('inboxLock'), + statisticsStore: req.app.get('statisticsStore'), + smtpService: req.app.get('smtpService'), + verificationStore: req.app.get('verificationStore'), + config: req.app.get('config') + } + const apiRouter = createApiRouter(dependencies) + apiRouter(req, res, next) +}) + +// Web routes app.use('/', loginRouter) if (config.user.authEnabled) { app.use('/', authRouter) diff --git a/package-lock.json b/package-lock.json index eb71315..56df33c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "48hr.email", - "version": "1.8.1", + "version": "2.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "48hr.email", - "version": "1.8.1", + "version": "2.1.0", "license": "GPL-3.0", "dependencies": { "async-retry": "^1.3.3", @@ -14,6 +14,7 @@ "better-sqlite3": "^12.5.0", "compression": "^1.8.1", "cookie-parser": "^1.4.7", + "cors": "^2.8.5", "debug": "^4.4.3", "dotenv": "^17.2.3", "express": "^4.22.1", diff --git a/package.json b/package.json index 63dd1c2..0e702b9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "48hr.email", - "version": "2.2.1", + "version": "2.3.0", "private": false, "description": "48hr.email is your favorite open-source tempmail client.", "keywords": [ @@ -37,6 +37,7 @@ "better-sqlite3": "^12.5.0", "compression": "^1.8.1", "cookie-parser": "^1.4.7", + "cors": "^2.8.5", "debug": "^4.4.3", "dotenv": "^17.2.3", "express": "^4.22.1", diff --git a/schema.sql b/schema.sql index 60481de..2e6ba3a 100644 --- a/schema.sql +++ b/schema.sql @@ -44,6 +44,19 @@ CREATE INDEX IF NOT EXISTS idx_locked_inboxes_user_id ON user_locked_inboxes(use CREATE INDEX IF NOT EXISTS idx_locked_inboxes_address ON user_locked_inboxes(inbox_address); CREATE INDEX IF NOT EXISTS idx_locked_inboxes_last_accessed ON user_locked_inboxes(last_accessed); +-- API tokens (one per user for programmatic access) +CREATE TABLE IF NOT EXISTS api_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL UNIQUE, + token TEXT NOT NULL UNIQUE, + created_at INTEGER NOT NULL, + last_used INTEGER, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_api_tokens_token ON api_tokens(token); +CREATE INDEX IF NOT EXISTS idx_api_tokens_user_id ON api_tokens(user_id); + -- Statistics storage for persistence across restarts CREATE TABLE IF NOT EXISTS statistics ( id INTEGER PRIMARY KEY CHECK (id = 1), -- Single row table