[AI][Feat]: Add API

Also adding API docs <3
This commit is contained in:
ClaraCrazy 2026-01-05 10:29:12 +01:00
parent d06ac6210f
commit fb3d8a60aa
No known key found for this signature in database
GPG key ID: EBBC896ACB497011
27 changed files with 2136 additions and 5 deletions

View file

@ -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

View file

@ -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
}

View file

@ -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

View file

@ -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

58
api/router.js Normal file
View file

@ -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

102
api/routes/account.api.md Normal file
View file

@ -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"
}
}
}
```

294
api/routes/account.js Normal file
View file

@ -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

68
api/routes/auth.api.md Normal file
View file

@ -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"
}
}
```

150
api/routes/auth.js Normal file
View file

@ -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

45
api/routes/config.api.md Normal file
View file

@ -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"]
}
}
```

68
api/routes/config.js Normal file
View file

@ -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

80
api/routes/inbox.api.md Normal file
View file

@ -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": "<p>Hello</p>",
"attachments": [
{
"filename": "file.txt",
"contentType": "text/plain",
"size": 1024,
"checksum": "abc123"
}
]
}
}
```

141
api/routes/inbox.js Normal file
View file

@ -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

90
api/routes/locks.api.md Normal file
View file

@ -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 }
}
}
```

136
api/routes/locks.js Normal file
View file

@ -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

74
api/routes/mail.api.md Normal file
View file

@ -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"
}
}
```

228
api/routes/mail.js Normal file
View file

@ -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

55
api/routes/stats.api.md Normal file
View file

@ -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": { ... }
}
}
```

54
api/routes/stats.js Normal file
View file

@ -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

8
app.js
View file

@ -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')

View file

@ -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;

View file

@ -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

View file

@ -650,4 +650,4 @@ class StatisticsStore {
}
}
module.exports = StatisticsStore
module.exports = StatisticsStore

View file

@ -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)

5
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -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