mirror of
https://github.com/Crazyco-xyz/48hr.email.git
synced 2026-01-08 10:49:35 +01:00
[AI][Feat]: Add API
Also adding API docs <3
This commit is contained in:
parent
d06ac6210f
commit
fb3d8a60aa
27 changed files with 2136 additions and 5 deletions
97
api/middleware/authenticator.js
Normal file
97
api/middleware/authenticator.js
Normal 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
|
||||
74
api/middleware/error-handler.js
Normal file
74
api/middleware/error-handler.js
Normal 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
|
||||
}
|
||||
106
api/middleware/rate-limiter.js
Normal file
106
api/middleware/rate-limiter.js
Normal 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
|
||||
58
api/middleware/response-formatter.js
Normal file
58
api/middleware/response-formatter.js
Normal 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
58
api/router.js
Normal 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
102
api/routes/account.api.md
Normal 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
294
api/routes/account.js
Normal 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
68
api/routes/auth.api.md
Normal 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
150
api/routes/auth.js
Normal 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
45
api/routes/config.api.md
Normal 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
68
api/routes/config.js
Normal 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
80
api/routes/inbox.api.md
Normal 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
141
api/routes/inbox.js
Normal 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
90
api/routes/locks.api.md
Normal 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
136
api/routes/locks.js
Normal 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
74
api/routes/mail.api.md
Normal 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
228
api/routes/mail.js
Normal 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
55
api/routes/stats.api.md
Normal 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
54
api/routes/stats.js
Normal 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
8
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')
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
108
domain/api-token-repository.js
Normal file
108
domain/api-token-repository.js
Normal 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
|
||||
|
|
@ -650,4 +650,4 @@ class StatisticsStore {
|
|||
}
|
||||
}
|
||||
|
||||
module.exports = StatisticsStore
|
||||
module.exports = StatisticsStore
|
||||
|
|
@ -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
5
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
13
schema.sql
13
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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue