mirror of
https://github.com/Crazyco-xyz/48hr.email.git
synced 2026-01-09 11:19:36 +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 UserRepository = require('./domain/user-repository')
|
||||||
const MockUserRepository = require('./application/mocks/mock-user-repository')
|
const MockUserRepository = require('./application/mocks/mock-user-repository')
|
||||||
const StatisticsStore = require('./domain/statistics-store')
|
const StatisticsStore = require('./domain/statistics-store')
|
||||||
|
const ApiTokenRepository = require('./domain/api-token-repository')
|
||||||
|
|
||||||
const clientNotification = new ClientNotification()
|
const clientNotification = new ClientNotification()
|
||||||
debug('Client notification service initialized')
|
debug('Client notification service initialized')
|
||||||
|
|
@ -61,6 +62,7 @@ app.set('config', config)
|
||||||
// Initialize user repository and auth service (if enabled)
|
// Initialize user repository and auth service (if enabled)
|
||||||
let inboxLock = null
|
let inboxLock = null
|
||||||
let statisticsStore = null
|
let statisticsStore = null
|
||||||
|
let apiTokenRepository = null
|
||||||
|
|
||||||
if (config.user.authEnabled && !config.uxDebugMode) {
|
if (config.user.authEnabled && !config.uxDebugMode) {
|
||||||
// Migrate legacy database files for backwards compatibility
|
// Migrate legacy database files for backwards compatibility
|
||||||
|
|
@ -70,6 +72,11 @@ if (config.user.authEnabled && !config.uxDebugMode) {
|
||||||
debug('User repository initialized')
|
debug('User repository initialized')
|
||||||
app.set('userRepository', userRepository)
|
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
|
// Initialize statistics store with database connection
|
||||||
statisticsStore = new StatisticsStore(userRepository.db)
|
statisticsStore = new StatisticsStore(userRepository.db)
|
||||||
debug('Statistics store initialized with database persistence')
|
debug('Statistics store initialized with database persistence')
|
||||||
|
|
@ -122,6 +129,7 @@ if (config.user.authEnabled && !config.uxDebugMode) {
|
||||||
} else {
|
} else {
|
||||||
debug('Statistics store initialized (in-memory only, no database)')
|
debug('Statistics store initialized (in-memory only, no database)')
|
||||||
app.set('userRepository', null)
|
app.set('userRepository', null)
|
||||||
|
app.set('apiTokenRepository', null)
|
||||||
app.set('authService', null)
|
app.set('authService', null)
|
||||||
app.set('inboxLock', null)
|
app.set('inboxLock', null)
|
||||||
debug('User authentication system disabled')
|
debug('User authentication system disabled')
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ function parseBool(v) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
|
apiEnabled: parseBool(process.env.API_ENABLED) !== false, // default true
|
||||||
// UX Debug Mode
|
// UX Debug Mode
|
||||||
uxDebugMode: parseBool(process.env.UX_DEBUG_MODE) || false,
|
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)' : ''}`)
|
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 socketio = require('socket.io')
|
||||||
|
|
||||||
const config = require('../../application/config')
|
const config = require('../../application/config')
|
||||||
|
const createApiRouter = require('../../api/router')
|
||||||
const inboxRouter = require('./routes/inbox')
|
const inboxRouter = require('./routes/inbox')
|
||||||
const loginRouter = require('./routes/login')
|
const loginRouter = require('./routes/login')
|
||||||
const errorRouter = require('./routes/error')
|
const errorRouter = require('./routes/error')
|
||||||
|
|
@ -152,6 +153,26 @@ app.use((req, res, next) => {
|
||||||
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)
|
app.use('/', loginRouter)
|
||||||
if (config.user.authEnabled) {
|
if (config.user.authEnabled) {
|
||||||
app.use('/', authRouter)
|
app.use('/', authRouter)
|
||||||
|
|
|
||||||
5
package-lock.json
generated
5
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "48hr.email",
|
"name": "48hr.email",
|
||||||
"version": "1.8.1",
|
"version": "2.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "48hr.email",
|
"name": "48hr.email",
|
||||||
"version": "1.8.1",
|
"version": "2.1.0",
|
||||||
"license": "GPL-3.0",
|
"license": "GPL-3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"async-retry": "^1.3.3",
|
"async-retry": "^1.3.3",
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
"better-sqlite3": "^12.5.0",
|
"better-sqlite3": "^12.5.0",
|
||||||
"compression": "^1.8.1",
|
"compression": "^1.8.1",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
|
"cors": "^2.8.5",
|
||||||
"debug": "^4.4.3",
|
"debug": "^4.4.3",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"express": "^4.22.1",
|
"express": "^4.22.1",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "48hr.email",
|
"name": "48hr.email",
|
||||||
"version": "2.2.1",
|
"version": "2.3.0",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "48hr.email is your favorite open-source tempmail client.",
|
"description": "48hr.email is your favorite open-source tempmail client.",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
|
|
@ -37,6 +37,7 @@
|
||||||
"better-sqlite3": "^12.5.0",
|
"better-sqlite3": "^12.5.0",
|
||||||
"compression": "^1.8.1",
|
"compression": "^1.8.1",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
|
"cors": "^2.8.5",
|
||||||
"debug": "^4.4.3",
|
"debug": "^4.4.3",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"express": "^4.22.1",
|
"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_address ON user_locked_inboxes(inbox_address);
|
||||||
CREATE INDEX IF NOT EXISTS idx_locked_inboxes_last_accessed ON user_locked_inboxes(last_accessed);
|
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
|
-- Statistics storage for persistence across restarts
|
||||||
CREATE TABLE IF NOT EXISTS statistics (
|
CREATE TABLE IF NOT EXISTS statistics (
|
||||||
id INTEGER PRIMARY KEY CHECK (id = 1), -- Single row table
|
id INTEGER PRIMARY KEY CHECK (id = 1), -- Single row table
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue