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

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