feat: Gitea Webhook, IMAP, Settings & Deployment docs

- Webhook route and Gitea integration
- IMAP service and Nextcloud/Porkbun setup docs
- Settings UI improvements and API updates
- SSH/Webhook fix prompt for emailsorter.webklar.com
- Bootstrap, config and AI sorter updates
This commit is contained in:
2026-01-31 15:00:00 +01:00
parent 7e7ec1013b
commit cbb225c001
24 changed files with 2173 additions and 32 deletions

View File

@@ -11,7 +11,7 @@ import { dirname, join } from 'path'
// Config & Middleware
import { config, validateConfig } from './config/index.mjs'
import { errorHandler, asyncHandler, NotFoundError, ValidationError } from './middleware/errorHandler.mjs'
import { errorHandler, asyncHandler, NotFoundError, ValidationError, AuthorizationError } from './middleware/errorHandler.mjs'
import { respond } from './utils/response.mjs'
import { logger, log } from './middleware/logger.mjs'
import { limiters } from './middleware/rateLimit.mjs'
@@ -22,6 +22,7 @@ import emailRoutes from './routes/email.mjs'
import stripeRoutes from './routes/stripe.mjs'
import apiRoutes from './routes/api.mjs'
import analyticsRoutes from './routes/analytics.mjs'
import webhookRoutes from './routes/webhook.mjs'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
@@ -56,6 +57,11 @@ app.use('/api', limiters.api)
// Static files
app.use(express.static(join(__dirname, '..', 'public')))
// Gitea webhook: raw body for X-Gitea-Signature verification (must be before JSON parser)
// Limit 2mb so large Gitea payloads (full repo JSON) don't get rejected and cause 502
app.use('/api/webhook', express.raw({ type: 'application/json', limit: '2mb' }))
app.use('/api/webhook', webhookRoutes)
// Body parsing (BEFORE routes, AFTER static)
// Note: Stripe webhook needs raw body, handled in stripe routes
app.use('/api', express.json({ limit: '1mb' }))
@@ -84,6 +90,19 @@ app.use('/api', apiRoutes)
// Preferences endpoints (inline for simplicity)
import { userPreferences } from './services/database.mjs'
import { isAdmin } from './config/index.mjs'
/**
* GET /api/me?email=xxx
* Returns current user context (e.g. isAdmin) for the given email.
*/
app.get('/api/me', asyncHandler(async (req, res) => {
const { email } = req.query
if (!email || typeof email !== 'string') {
throw new ValidationError('email is required')
}
respond.success(res, { isAdmin: isAdmin(email) })
}))
app.get('/api/preferences', asyncHandler(async (req, res) => {
const { userId } = req.query
@@ -207,6 +226,69 @@ app.delete('/api/preferences/company-labels/:id', asyncHandler(async (req, res)
respond.success(res, null, 'Company label deleted')
}))
/**
* GET /api/preferences/name-labels
* Get name labels (worker labels). Admin only.
*/
app.get('/api/preferences/name-labels', asyncHandler(async (req, res) => {
const { userId, email } = req.query
if (!userId) throw new ValidationError('userId is required')
if (!email || typeof email !== 'string') throw new ValidationError('email is required')
if (!isAdmin(email)) throw new AuthorizationError('Admin access required for name labels')
const prefs = await userPreferences.getByUser(userId)
const preferences = prefs?.preferences || userPreferences.getDefaults()
respond.success(res, preferences.nameLabels || [])
}))
/**
* POST /api/preferences/name-labels
* Save/Update name label (worker). Admin only.
*/
app.post('/api/preferences/name-labels', asyncHandler(async (req, res) => {
const { userId, email, nameLabel } = req.body
if (!userId) throw new ValidationError('userId is required')
if (!email || typeof email !== 'string') throw new ValidationError('email is required')
if (!isAdmin(email)) throw new AuthorizationError('Admin access required for name labels')
if (!nameLabel) throw new ValidationError('nameLabel is required')
const prefs = await userPreferences.getByUser(userId)
const preferences = prefs?.preferences || userPreferences.getDefaults()
const nameLabels = preferences.nameLabels || []
if (!nameLabel.id) {
nameLabel.id = `namelabel_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
}
const existingIndex = nameLabels.findIndex(l => l.id === nameLabel.id)
if (existingIndex >= 0) {
nameLabels[existingIndex] = nameLabel
} else {
nameLabels.push(nameLabel)
}
await userPreferences.upsert(userId, { nameLabels })
respond.success(res, nameLabel, 'Name label saved')
}))
/**
* DELETE /api/preferences/name-labels/:id
* Delete name label. Admin only.
*/
app.delete('/api/preferences/name-labels/:id', asyncHandler(async (req, res) => {
const { userId, email } = req.query
const { id } = req.params
if (!userId) throw new ValidationError('userId is required')
if (!email || typeof email !== 'string') throw new ValidationError('email is required')
if (!isAdmin(email)) throw new AuthorizationError('Admin access required for name labels')
if (!id) throw new ValidationError('label id is required')
const prefs = await userPreferences.getByUser(userId)
const preferences = prefs?.preferences || userPreferences.getDefaults()
const nameLabels = (preferences.nameLabels || []).filter(l => l.id !== id)
await userPreferences.upsert(userId, { nameLabels })
respond.success(res, null, 'Name label deleted')
}))
// Legacy Stripe webhook endpoint
app.use('/stripe', stripeRoutes)