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:
@@ -78,12 +78,20 @@ router.post('/connect',
|
||||
validate({
|
||||
body: {
|
||||
userId: [rules.required('userId')],
|
||||
provider: [rules.required('provider'), rules.isIn('provider', ['gmail', 'outlook', 'demo'])],
|
||||
provider: [rules.required('provider'), rules.isIn('provider', ['gmail', 'outlook', 'demo', 'imap'])],
|
||||
email: [rules.required('email'), rules.email()],
|
||||
},
|
||||
}),
|
||||
asyncHandler(async (req, res) => {
|
||||
const { userId, provider, email, accessToken, refreshToken, expiresAt } = req.body
|
||||
const { userId, provider, email, accessToken, refreshToken, expiresAt, password, imapHost, imapPort, imapSecure } = req.body
|
||||
|
||||
// IMAP: require password (or accessToken as password)
|
||||
if (provider === 'imap') {
|
||||
const imapPassword = password || accessToken
|
||||
if (!imapPassword) {
|
||||
throw new ValidationError('IMAP account requires a password or app password', { password: ['Required for IMAP'] })
|
||||
}
|
||||
}
|
||||
|
||||
// Check if account already exists
|
||||
const existingAccounts = await emailAccounts.getByUser(userId)
|
||||
@@ -95,17 +103,44 @@ router.post('/connect',
|
||||
})
|
||||
}
|
||||
|
||||
// IMAP: verify connection before saving
|
||||
if (provider === 'imap') {
|
||||
const { ImapService } = await import('../services/imap.mjs')
|
||||
const imapPassword = password || accessToken
|
||||
const imap = new ImapService({
|
||||
host: imapHost || 'imap.porkbun.com',
|
||||
port: imapPort != null ? Number(imapPort) : 993,
|
||||
secure: imapSecure !== false,
|
||||
user: email,
|
||||
password: imapPassword,
|
||||
})
|
||||
try {
|
||||
await imap.connect()
|
||||
await imap.listEmails(1)
|
||||
await imap.close()
|
||||
} catch (err) {
|
||||
log.warn('IMAP connection test failed', { email, error: err.message })
|
||||
throw new ValidationError('IMAP connection failed. Check email and password (use app password if 2FA is on).', { password: [err.message || 'Connection failed'] })
|
||||
}
|
||||
}
|
||||
|
||||
// Create account
|
||||
const account = await emailAccounts.create({
|
||||
const accountData = {
|
||||
userId,
|
||||
provider,
|
||||
email,
|
||||
accessToken: accessToken || '',
|
||||
refreshToken: refreshToken || '',
|
||||
expiresAt: expiresAt || 0,
|
||||
accessToken: provider === 'imap' ? (password || accessToken) : (accessToken || ''),
|
||||
refreshToken: provider === 'imap' ? '' : (refreshToken || ''),
|
||||
expiresAt: provider === 'imap' ? 0 : (expiresAt || 0),
|
||||
isActive: true,
|
||||
lastSync: null,
|
||||
})
|
||||
}
|
||||
if (provider === 'imap') {
|
||||
if (imapHost != null) accountData.imapHost = String(imapHost)
|
||||
if (imapPort != null) accountData.imapPort = Number(imapPort)
|
||||
if (imapSecure !== undefined) accountData.imapSecure = Boolean(imapSecure)
|
||||
}
|
||||
const account = await emailAccounts.create(accountData)
|
||||
|
||||
log.success(`Email account connected: ${email} (${provider})`)
|
||||
|
||||
@@ -487,6 +522,24 @@ router.post('/sort',
|
||||
}
|
||||
}
|
||||
|
||||
// Create name labels (workers) – personal labels per team member
|
||||
const nameLabelMap = {}
|
||||
if (preferences.nameLabels?.length) {
|
||||
for (const nl of preferences.nameLabels) {
|
||||
if (!nl.enabled) continue
|
||||
try {
|
||||
const labelName = `EmailSorter/Team/${nl.name}`
|
||||
const label = await gmail.createLabel(labelName, '#4a86e8')
|
||||
if (label) {
|
||||
nameLabelMap[nl.id || nl.name] = label.id
|
||||
if (nl.name !== (nl.id || nl.name)) nameLabelMap[nl.name] = label.id
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn(`Failed to create name label: ${nl.name}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch and process ALL emails with pagination
|
||||
let pageToken = null
|
||||
let totalProcessed = 0
|
||||
@@ -518,6 +571,7 @@ router.post('/sort',
|
||||
|
||||
let category = null
|
||||
let companyLabel = null
|
||||
let assignedTo = null
|
||||
let skipAI = false
|
||||
|
||||
// PRIORITY 1: Check custom company labels
|
||||
@@ -548,6 +602,7 @@ router.post('/sort',
|
||||
if (!skipAI) {
|
||||
const classification = await sorter.categorize(emailData, preferences)
|
||||
category = classification.category
|
||||
assignedTo = classification.assignedTo || null
|
||||
|
||||
// If category is disabled, fallback to review
|
||||
if (!enabledCategories.includes(category)) {
|
||||
@@ -559,6 +614,7 @@ router.post('/sort',
|
||||
email,
|
||||
category,
|
||||
companyLabel,
|
||||
assignedTo,
|
||||
})
|
||||
|
||||
// Collect samples for suggested rules (first run only, max 50)
|
||||
@@ -573,7 +629,7 @@ router.post('/sort',
|
||||
}
|
||||
|
||||
// Apply labels/categories and actions
|
||||
for (const { email, category, companyLabel } of processedEmails) {
|
||||
for (const { email, category, companyLabel, assignedTo } of processedEmails) {
|
||||
const action = sorter.getCategoryAction(category, preferences)
|
||||
|
||||
try {
|
||||
@@ -585,6 +641,11 @@ router.post('/sort',
|
||||
labelsToAdd.push(companyLabelMap[companyLabel])
|
||||
}
|
||||
|
||||
// Add name label (worker) if AI assigned email to a person
|
||||
if (assignedTo && nameLabelMap[assignedTo]) {
|
||||
labelsToAdd.push(nameLabelMap[assignedTo])
|
||||
}
|
||||
|
||||
// Add category label/category
|
||||
if (labelMap[category]) {
|
||||
labelsToAdd.push(labelMap[category])
|
||||
@@ -794,6 +855,160 @@ router.post('/sort',
|
||||
throw new ValidationError(`Outlook error: ${err.message}. Please reconnect account.`)
|
||||
}
|
||||
}
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// IMAP (Porkbun, Nextcloud mail backend, etc.)
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
else if (account.provider === 'imap') {
|
||||
if (!features.ai()) {
|
||||
throw new ValidationError('AI sorting is not configured. Please set MISTRAL_API_KEY.')
|
||||
}
|
||||
|
||||
if (!account.accessToken) {
|
||||
throw new ValidationError('IMAP account needs to be reconnected (password missing)')
|
||||
}
|
||||
|
||||
log.info(`IMAP sorting started for ${account.email}`)
|
||||
|
||||
const { ImapService, getFolderNameForCategory } = await import('../services/imap.mjs')
|
||||
const imap = new ImapService({
|
||||
host: account.imapHost || 'imap.porkbun.com',
|
||||
port: account.imapPort != null ? account.imapPort : 993,
|
||||
secure: account.imapSecure !== false,
|
||||
user: account.email,
|
||||
password: account.accessToken,
|
||||
})
|
||||
|
||||
try {
|
||||
await imap.connect()
|
||||
|
||||
const enabledCategories = sorter.getEnabledCategories(preferences)
|
||||
// Name labels (workers): create Team subfolders for IMAP/Nextcloud
|
||||
const nameLabelMap = {}
|
||||
if (preferences.nameLabels?.length) {
|
||||
for (const nl of preferences.nameLabels) {
|
||||
if (!nl.enabled) continue
|
||||
const folderName = `Team/${nl.name}`
|
||||
try {
|
||||
await imap.ensureFolder(folderName)
|
||||
nameLabelMap[nl.id || nl.name] = folderName
|
||||
if (nl.name !== (nl.id || nl.name)) nameLabelMap[nl.name] = folderName
|
||||
} catch (err) {
|
||||
log.warn(`IMAP name label folder failed: ${nl.name}`, { error: err.message })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let pageToken = null
|
||||
let totalProcessed = 0
|
||||
const batchSize = 100
|
||||
|
||||
do {
|
||||
const { messages, nextPageToken } = await imap.listEmails(batchSize, pageToken)
|
||||
pageToken = nextPageToken
|
||||
|
||||
if (!messages?.length) break
|
||||
|
||||
const emails = await imap.batchGetEmails(messages.map((m) => m.id))
|
||||
const processedEmails = []
|
||||
|
||||
for (const email of emails) {
|
||||
const emailData = {
|
||||
from: email.headers?.from || '',
|
||||
subject: email.headers?.subject || '',
|
||||
snippet: email.snippet || '',
|
||||
}
|
||||
|
||||
let category = null
|
||||
let companyLabel = null
|
||||
let assignedTo = null
|
||||
let skipAI = false
|
||||
|
||||
if (preferences.companyLabels?.length) {
|
||||
for (const companyLabelConfig of preferences.companyLabels) {
|
||||
if (!companyLabelConfig.enabled) continue
|
||||
if (sorter.matchesCompanyLabel(emailData, companyLabelConfig)) {
|
||||
category = companyLabelConfig.category || 'promotions'
|
||||
companyLabel = companyLabelConfig.name
|
||||
skipAI = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!skipAI && preferences.autoDetectCompanies) {
|
||||
const detected = sorter.detectCompany(emailData)
|
||||
if (detected) {
|
||||
category = 'promotions'
|
||||
companyLabel = detected.label
|
||||
skipAI = true
|
||||
}
|
||||
}
|
||||
|
||||
if (!skipAI) {
|
||||
const classification = await sorter.categorize(emailData, preferences)
|
||||
category = classification.category
|
||||
assignedTo = classification.assignedTo || null
|
||||
if (!enabledCategories.includes(category)) category = 'review'
|
||||
}
|
||||
|
||||
processedEmails.push({ email, category, companyLabel, assignedTo })
|
||||
|
||||
if (isFirstRun && emailSamples.length < 50) {
|
||||
emailSamples.push({
|
||||
from: emailData.from,
|
||||
subject: emailData.subject,
|
||||
snippet: emailData.snippet,
|
||||
category,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const actionMap = sorter.getCategoryAction ? (cat) => sorter.getCategoryAction(cat, preferences) : () => 'inbox'
|
||||
|
||||
for (const { email, category, companyLabel, assignedTo } of processedEmails) {
|
||||
try {
|
||||
const action = actionMap(category)
|
||||
// If AI assigned to a worker, move to Team/<Name> folder; else use category folder
|
||||
const folderName = (assignedTo && nameLabelMap[assignedTo])
|
||||
? nameLabelMap[assignedTo]
|
||||
: getFolderNameForCategory(companyLabel ? (preferences.companyLabels?.find((c) => c.name === companyLabel)?.category || 'promotions') : category)
|
||||
|
||||
await imap.moveToFolder(email.id, folderName)
|
||||
|
||||
if (action === 'archive_read') {
|
||||
try {
|
||||
await imap.markAsRead(email.id)
|
||||
} catch {
|
||||
// already moved; mark as read optional
|
||||
}
|
||||
}
|
||||
|
||||
sortedCount++
|
||||
results.byCategory[category] = (results.byCategory[category] || 0) + 1
|
||||
} catch (err) {
|
||||
log.warn(`IMAP sort failed: ${email.id}`, { error: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
totalProcessed += emails.length
|
||||
log.info(`IMAP processed ${totalProcessed} emails so far...`)
|
||||
|
||||
if (totalProcessed >= effectiveMax) break
|
||||
if (pageToken) await new Promise((r) => setTimeout(r, 200))
|
||||
} while (pageToken && processAll)
|
||||
|
||||
await imap.close()
|
||||
log.success(`IMAP sorting completed: ${sortedCount} emails processed`)
|
||||
} catch (err) {
|
||||
try {
|
||||
await imap.close()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
log.error('IMAP sorting failed', { error: err.message })
|
||||
throw new ValidationError(`IMAP error: ${err.message}. Check credentials or reconnect.`)
|
||||
}
|
||||
}
|
||||
|
||||
// Update last sync
|
||||
await emailAccounts.updateLastSync(accountId)
|
||||
|
||||
125
server/routes/webhook.mjs
Normal file
125
server/routes/webhook.mjs
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Webhook Routes (Gitea etc.)
|
||||
* Production: https://emailsorter.webklar.com/api/webhook/gitea
|
||||
* POST /api/webhook/gitea - Deployment on push (validates Bearer or X-Gitea-Signature)
|
||||
*/
|
||||
|
||||
import express from 'express'
|
||||
import crypto from 'crypto'
|
||||
import { asyncHandler, AuthorizationError } from '../middleware/errorHandler.mjs'
|
||||
import { config } from '../config/index.mjs'
|
||||
import { log } from '../middleware/logger.mjs'
|
||||
|
||||
const router = express.Router()
|
||||
const secret = config.gitea.webhookSecret
|
||||
const authToken = config.gitea.webhookAuthToken
|
||||
|
||||
/**
|
||||
* Validate Gitea webhook request:
|
||||
* - Authorization: Bearer <secret|authToken> (Gitea 1.19+ or manual calls)
|
||||
* - X-Gitea-Signature: HMAC-SHA256 hex of raw body (Gitea default)
|
||||
*/
|
||||
function validateGiteaWebhook(req) {
|
||||
const rawBody = req.body
|
||||
if (!rawBody || !Buffer.isBuffer(rawBody)) {
|
||||
throw new AuthorizationError('Raw body fehlt (Webhook-Route muss vor JSON-Parser registriert sein)')
|
||||
}
|
||||
|
||||
// 1) Bearer token (Header)
|
||||
const authHeader = req.get('Authorization')
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
const token = authHeader.slice(7).trim()
|
||||
const expected = authToken || secret
|
||||
if (expected && token === expected) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// 2) X-Gitea-Signature (HMAC-SHA256 hex)
|
||||
const signatureHeader = req.get('X-Gitea-Signature')
|
||||
if (signatureHeader && secret) {
|
||||
try {
|
||||
const expectedHex = crypto.createHmac('sha256', secret).update(rawBody).digest('hex')
|
||||
const received = signatureHeader.trim()
|
||||
const receivedHex = received.startsWith('sha256=') ? received.slice(7) : received
|
||||
if (expectedHex.length === receivedHex.length && expectedHex.length > 0) {
|
||||
const a = Buffer.from(expectedHex, 'hex')
|
||||
const b = Buffer.from(receivedHex, 'hex')
|
||||
if (a.length === b.length && crypto.timingSafeEqual(a, b)) return true
|
||||
}
|
||||
} catch (_) {
|
||||
// invalid hex or comparison error – fall through to reject
|
||||
}
|
||||
}
|
||||
|
||||
if (!secret && !authToken) {
|
||||
throw new AuthorizationError('GITEA_WEBHOOK_SECRET nicht konfiguriert')
|
||||
}
|
||||
throw new AuthorizationError('Ungültige Webhook-Signatur oder fehlender Authorization-Header')
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/webhook/gitea
|
||||
* Gitea push webhook – validates Bearer or X-Gitea-Signature, then accepts event
|
||||
*/
|
||||
router.post('/gitea', asyncHandler(async (req, res) => {
|
||||
try {
|
||||
validateGiteaWebhook(req)
|
||||
} catch (err) {
|
||||
if (err.name === 'AuthorizationError' || err.statusCode === 401) throw err
|
||||
log.error('Gitea Webhook: Validierung fehlgeschlagen', { error: err.message })
|
||||
return res.status(401).json({ error: 'Webhook validation failed' })
|
||||
}
|
||||
|
||||
let payload
|
||||
try {
|
||||
const raw = req.body && typeof req.body.toString === 'function' ? req.body.toString('utf8') : ''
|
||||
payload = raw ? JSON.parse(raw) : {}
|
||||
} catch (e) {
|
||||
log.warn('Gitea Webhook: ungültiges JSON', { error: e.message })
|
||||
return res.status(400).json({ error: 'Invalid JSON body' })
|
||||
}
|
||||
|
||||
const ref = payload.ref || ''
|
||||
const branch = ref.replace(/^refs\/heads\//, '')
|
||||
const event = req.get('X-Gitea-Event') || 'push'
|
||||
log.info('Gitea Webhook empfangen', { ref, branch, event })
|
||||
|
||||
// Optional: trigger deploy script in background (do not block response)
|
||||
setImmediate(async () => {
|
||||
try {
|
||||
const { spawn } = await import('child_process')
|
||||
const { fileURLToPath } = await import('url')
|
||||
const { dirname, join } = await import('path')
|
||||
const { existsSync } = await import('fs')
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const deployScript = join(__dirname, '..', '..', 'scripts', 'deploy-to-server.mjs')
|
||||
if (existsSync(deployScript)) {
|
||||
const child = spawn('node', [deployScript], {
|
||||
cwd: join(__dirname, '..', '..'),
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: true,
|
||||
})
|
||||
child.unref()
|
||||
child.stdout?.on('data', (d) => log.info('Deploy stdout:', d.toString().trim()))
|
||||
child.stderr?.on('data', (d) => log.warn('Deploy stderr:', d.toString().trim()))
|
||||
}
|
||||
} catch (_) {}
|
||||
})
|
||||
|
||||
res.status(202).json({ received: true, ref, branch })
|
||||
}))
|
||||
|
||||
/**
|
||||
* GET /api/webhook/status
|
||||
* Simple status for webhook endpoint (e.g. health check)
|
||||
*/
|
||||
router.get('/status', (req, res) => {
|
||||
res.json({
|
||||
ok: true,
|
||||
webhook: 'gitea',
|
||||
configured: Boolean(secret || authToken),
|
||||
})
|
||||
})
|
||||
|
||||
export default router
|
||||
Reference in New Issue
Block a user