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

@@ -161,6 +161,12 @@ async function setupCollections() {
db.createBooleanAttribute(DB_ID, 'email_accounts', 'isActive', true));
await ensureAttribute('email_accounts', 'lastSync', () =>
db.createDatetimeAttribute(DB_ID, 'email_accounts', 'lastSync', false));
await ensureAttribute('email_accounts', 'imapHost', () =>
db.createStringAttribute(DB_ID, 'email_accounts', 'imapHost', 256, false));
await ensureAttribute('email_accounts', 'imapPort', () =>
db.createIntegerAttribute(DB_ID, 'email_accounts', 'imapPort', false));
await ensureAttribute('email_accounts', 'imapSecure', () =>
db.createBooleanAttribute(DB_ID, 'email_accounts', 'imapSecure', false));
// ==================== Email Stats ====================
await ensureCollection('email_stats', 'Email Stats', PERM_AUTHENTICATED);

View File

@@ -75,6 +75,18 @@ export const config = {
emailAccounts: 1,
autoSchedule: false, // manual only
},
// Admin: comma-separated list of emails with admin rights (e.g. support)
adminEmails: (process.env.ADMIN_EMAILS || '')
.split(',')
.map((e) => e.trim().toLowerCase())
.filter(Boolean),
// Gitea Webhook (Deployment)
gitea: {
webhookSecret: process.env.GITEA_WEBHOOK_SECRET || '',
webhookAuthToken: process.env.GITEA_WEBHOOK_AUTH_TOKEN || process.env.GITEA_WEBHOOK_SECRET || '',
},
}
/**
@@ -141,4 +153,12 @@ export const features = {
ai: () => Boolean(config.mistral.apiKey),
}
/**
* Check if an email has admin rights (support, etc.)
*/
export function isAdmin(email) {
if (!email || typeof email !== 'string') return false
return config.adminEmails.includes(email.trim().toLowerCase())
}
export default config

View File

@@ -65,6 +65,23 @@ MICROSOFT_CLIENT_ID=xxx-xxx-xxx
MICROSOFT_CLIENT_SECRET=xxx
MICROSOFT_REDIRECT_URI=http://localhost:3000/api/oauth/outlook/callback
# ─────────────────────────────────────────────────────────────────────────────
# Admin (OPTIONAL)
# ─────────────────────────────────────────────────────────────────────────────
# Comma-separated list of admin emails (e.g. support@webklar.com). Used by isAdmin().
# ADMIN_EMAILS=support@webklar.com
# Initial password for admin user when running: npm run create-admin
# ADMIN_INITIAL_PASSWORD=your-secure-password
# ─────────────────────────────────────────────────────────────────────────────
# Gitea Webhook (OPTIONAL Deployment bei Push)
# ─────────────────────────────────────────────────────────────────────────────
# Secret für X-Gitea-Signature und optional Bearer-Header (gleicher Wert möglich)
# GITEA_WEBHOOK_SECRET=dein_webhook_secret
# Optional: anderer Token nur für Authorization: Bearer (sonst wird GITEA_WEBHOOK_SECRET verwendet)
# GITEA_WEBHOOK_AUTH_TOKEN=
# ─────────────────────────────────────────────────────────────────────────────
# Rate Limiting (OPTIONAL)
# ─────────────────────────────────────────────────────────────────────────────

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)

View File

@@ -233,6 +233,12 @@
"zod-to-json-schema": "^3.24.1"
}
},
"node_modules/@pinojs/redact": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
"license": "MIT"
},
"node_modules/@types/node": {
"version": "25.0.8",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.8.tgz",
@@ -242,6 +248,17 @@
"undici-types": "~7.16.0"
}
},
"node_modules/@zone-eu/mailsplit": {
"version": "5.4.8",
"resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.8.tgz",
"integrity": "sha512-eEyACj4JZ7sjzRvy26QhLgKEMWwQbsw1+QZnlLX+/gihcNH07lVPOcnwf5U6UAL7gkc//J3jVd76o/WS+taUiA==",
"license": "(MIT OR EUPL-1.1+)",
"dependencies": {
"libbase64": "1.3.0",
"libmime": "5.3.7",
"libqp": "2.1.1"
}
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -270,6 +287,15 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/atomic-sleep": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
"license": "MIT",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -555,6 +581,15 @@
"node": ">= 0.8"
}
},
"node_modules/encoding-japanese": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.2.0.tgz",
"integrity": "sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==",
"license": "MIT",
"engines": {
"node": ">=8.10.0"
}
},
"node_modules/entities": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
@@ -1028,12 +1063,54 @@
"node": ">=0.10.0"
}
},
"node_modules/imapflow": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.2.8.tgz",
"integrity": "sha512-ym7FF2tKOlOzfRvxehs4eLkhjP8Mme3sSp2tcxEbyoeJuJwtEWxaVDv12+DnaMG2LXm0zuQGWZiClq31FLPUNg==",
"license": "MIT",
"dependencies": {
"@zone-eu/mailsplit": "5.4.8",
"encoding-japanese": "2.2.0",
"iconv-lite": "0.7.2",
"libbase64": "1.3.0",
"libmime": "5.3.7",
"libqp": "2.1.1",
"nodemailer": "7.0.13",
"pino": "10.3.0",
"socks": "2.8.7"
}
},
"node_modules/imapflow/node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ip-address": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -1160,6 +1237,42 @@
"safe-buffer": "^5.0.1"
}
},
"node_modules/libbase64": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/libbase64/-/libbase64-1.3.0.tgz",
"integrity": "sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==",
"license": "MIT"
},
"node_modules/libmime": {
"version": "5.3.7",
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.7.tgz",
"integrity": "sha512-FlDb3Wtha8P01kTL3P9M+ZDNDWPKPmKHWaU/cG/lg5pfuAwdflVpZE+wm9m7pKmC5ww6s+zTxBKS1p6yl3KpSw==",
"license": "MIT",
"dependencies": {
"encoding-japanese": "2.2.0",
"iconv-lite": "0.6.3",
"libbase64": "1.3.0",
"libqp": "2.1.1"
}
},
"node_modules/libmime/node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/libqp": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/libqp/-/libqp-2.1.1.tgz",
"integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==",
"license": "MIT"
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
@@ -1360,6 +1473,15 @@
"webidl-conversions": "^3.0.0"
}
},
"node_modules/nodemailer": {
"version": "7.0.13",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz",
"integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -1381,6 +1503,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-exit-leak-free": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
"integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@@ -1421,6 +1552,59 @@
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT"
},
"node_modules/pino": {
"version": "10.3.0",
"resolved": "https://registry.npmjs.org/pino/-/pino-10.3.0.tgz",
"integrity": "sha512-0GNPNzHXBKw6U/InGe79A3Crzyk9bcSyObF9/Gfo9DLEf5qj5RF50RSjsu0W1rZ6ZqRGdzDFCRBQvi9/rSGPtA==",
"license": "MIT",
"dependencies": {
"@pinojs/redact": "^0.4.0",
"atomic-sleep": "^1.0.0",
"on-exit-leak-free": "^2.1.0",
"pino-abstract-transport": "^3.0.0",
"pino-std-serializers": "^7.0.0",
"process-warning": "^5.0.0",
"quick-format-unescaped": "^4.0.3",
"real-require": "^0.2.0",
"safe-stable-stringify": "^2.3.1",
"sonic-boom": "^4.0.1",
"thread-stream": "^4.0.0"
},
"bin": {
"pino": "bin.js"
}
},
"node_modules/pino-abstract-transport": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz",
"integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==",
"license": "MIT",
"dependencies": {
"split2": "^4.0.0"
}
},
"node_modules/pino-std-serializers": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
"license": "MIT"
},
"node_modules/process-warning": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
"integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -1459,6 +1643,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/quick-format-unescaped": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
"license": "MIT"
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@@ -1483,6 +1673,15 @@
"node": ">= 0.8"
}
},
"node_modules/real-require": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
"integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
"license": "MIT",
"engines": {
"node": ">= 12.13.0"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@@ -1513,6 +1712,15 @@
],
"license": "MIT"
},
"node_modules/safe-stable-stringify": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
@@ -1667,6 +1875,39 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/smart-buffer": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
"license": "MIT",
"engines": {
"node": ">= 6.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/socks": {
"version": "2.8.7",
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz",
"integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==",
"license": "MIT",
"dependencies": {
"ip-address": "^10.0.1",
"smart-buffer": "^4.2.0"
},
"engines": {
"node": ">= 10.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/sonic-boom": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.0.tgz",
"integrity": "sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==",
"license": "MIT",
"dependencies": {
"atomic-sleep": "^1.0.0"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -1677,6 +1918,15 @@
"node": ">=0.10.0"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -1706,6 +1956,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/thread-stream": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz",
"integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==",
"license": "MIT",
"dependencies": {
"real-require": "^0.2.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/tldts": {
"version": "7.0.19",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.19.tgz",

263
server/package-lock.json generated
View File

@@ -16,6 +16,7 @@
"express": "^4.21.2",
"google-auth-library": "^9.14.2",
"googleapis": "^144.0.0",
"imapflow": "^1.2.8",
"node-appwrite": "^14.1.0",
"stripe": "^17.4.0"
},
@@ -255,6 +256,12 @@
"zod-to-json-schema": "^3.24.1"
}
},
"node_modules/@pinojs/redact": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
"license": "MIT"
},
"node_modules/@types/node": {
"version": "25.0.8",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.8.tgz",
@@ -264,6 +271,17 @@
"undici-types": "~7.16.0"
}
},
"node_modules/@zone-eu/mailsplit": {
"version": "5.4.8",
"resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.8.tgz",
"integrity": "sha512-eEyACj4JZ7sjzRvy26QhLgKEMWwQbsw1+QZnlLX+/gihcNH07lVPOcnwf5U6UAL7gkc//J3jVd76o/WS+taUiA==",
"license": "(MIT OR EUPL-1.1+)",
"dependencies": {
"libbase64": "1.3.0",
"libmime": "5.3.7",
"libqp": "2.1.1"
}
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -292,6 +310,15 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/atomic-sleep": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
"license": "MIT",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -577,6 +604,15 @@
"node": ">= 0.8"
}
},
"node_modules/encoding-japanese": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.2.0.tgz",
"integrity": "sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==",
"license": "MIT",
"engines": {
"node": ">=8.10.0"
}
},
"node_modules/entities": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
@@ -1050,12 +1086,54 @@
"node": ">=0.10.0"
}
},
"node_modules/imapflow": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.2.8.tgz",
"integrity": "sha512-ym7FF2tKOlOzfRvxehs4eLkhjP8Mme3sSp2tcxEbyoeJuJwtEWxaVDv12+DnaMG2LXm0zuQGWZiClq31FLPUNg==",
"license": "MIT",
"dependencies": {
"@zone-eu/mailsplit": "5.4.8",
"encoding-japanese": "2.2.0",
"iconv-lite": "0.7.2",
"libbase64": "1.3.0",
"libmime": "5.3.7",
"libqp": "2.1.1",
"nodemailer": "7.0.13",
"pino": "10.3.0",
"socks": "2.8.7"
}
},
"node_modules/imapflow/node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ip-address": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -1182,6 +1260,42 @@
"safe-buffer": "^5.0.1"
}
},
"node_modules/libbase64": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/libbase64/-/libbase64-1.3.0.tgz",
"integrity": "sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==",
"license": "MIT"
},
"node_modules/libmime": {
"version": "5.3.7",
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.7.tgz",
"integrity": "sha512-FlDb3Wtha8P01kTL3P9M+ZDNDWPKPmKHWaU/cG/lg5pfuAwdflVpZE+wm9m7pKmC5ww6s+zTxBKS1p6yl3KpSw==",
"license": "MIT",
"dependencies": {
"encoding-japanese": "2.2.0",
"iconv-lite": "0.6.3",
"libbase64": "1.3.0",
"libqp": "2.1.1"
}
},
"node_modules/libmime/node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/libqp": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/libqp/-/libqp-2.1.1.tgz",
"integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==",
"license": "MIT"
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
@@ -1382,6 +1496,15 @@
"webidl-conversions": "^3.0.0"
}
},
"node_modules/nodemailer": {
"version": "7.0.13",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz",
"integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -1403,6 +1526,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-exit-leak-free": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
"integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@@ -1443,6 +1575,59 @@
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT"
},
"node_modules/pino": {
"version": "10.3.0",
"resolved": "https://registry.npmjs.org/pino/-/pino-10.3.0.tgz",
"integrity": "sha512-0GNPNzHXBKw6U/InGe79A3Crzyk9bcSyObF9/Gfo9DLEf5qj5RF50RSjsu0W1rZ6ZqRGdzDFCRBQvi9/rSGPtA==",
"license": "MIT",
"dependencies": {
"@pinojs/redact": "^0.4.0",
"atomic-sleep": "^1.0.0",
"on-exit-leak-free": "^2.1.0",
"pino-abstract-transport": "^3.0.0",
"pino-std-serializers": "^7.0.0",
"process-warning": "^5.0.0",
"quick-format-unescaped": "^4.0.3",
"real-require": "^0.2.0",
"safe-stable-stringify": "^2.3.1",
"sonic-boom": "^4.0.1",
"thread-stream": "^4.0.0"
},
"bin": {
"pino": "bin.js"
}
},
"node_modules/pino-abstract-transport": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz",
"integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==",
"license": "MIT",
"dependencies": {
"split2": "^4.0.0"
}
},
"node_modules/pino-std-serializers": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
"license": "MIT"
},
"node_modules/process-warning": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
"integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -1481,6 +1666,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/quick-format-unescaped": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
"license": "MIT"
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@@ -1505,6 +1696,15 @@
"node": ">= 0.8"
}
},
"node_modules/real-require": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
"integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
"license": "MIT",
"engines": {
"node": ">= 12.13.0"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@@ -1535,6 +1735,15 @@
],
"license": "MIT"
},
"node_modules/safe-stable-stringify": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
@@ -1689,6 +1898,39 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/smart-buffer": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
"license": "MIT",
"engines": {
"node": ">= 6.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/socks": {
"version": "2.8.7",
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz",
"integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==",
"license": "MIT",
"dependencies": {
"ip-address": "^10.0.1",
"smart-buffer": "^4.2.0"
},
"engines": {
"node": ">= 10.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/sonic-boom": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.0.tgz",
"integrity": "sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==",
"license": "MIT",
"dependencies": {
"atomic-sleep": "^1.0.0"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -1699,6 +1941,15 @@
"node": ">=0.10.0"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -1728,6 +1979,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/thread-stream": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz",
"integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==",
"license": "MIT",
"dependencies": {
"real-require": "^0.2.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/tldts": {
"version": "7.0.19",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.19.tgz",

View File

@@ -16,6 +16,7 @@
"test": "node e2e-test.mjs",
"test:frontend": "node test-frontend.mjs",
"verify": "node verify-setup.mjs",
"create-admin": "node scripts/create-admin-user.mjs",
"cleanup": "node cleanup.mjs",
"lint": "eslint --ext .mjs ."
},
@@ -38,6 +39,7 @@
"express": "^4.21.2",
"google-auth-library": "^9.14.2",
"googleapis": "^144.0.0",
"imapflow": "^1.2.8",
"node-appwrite": "^14.1.0",
"stripe": "^17.4.0"
},

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)

125
server/routes/webhook.mjs Normal file
View 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

View File

@@ -0,0 +1,75 @@
/**
* Create admin user in Appwrite (e.g. support@webklar.com).
* Requires: APPWRITE_* env vars. Optionally ADMIN_INITIAL_PASSWORD (otherwise one is generated).
* After creation, add the email to ADMIN_EMAILS in .env so the backend treats them as admin.
*
* Usage: node scripts/create-admin-user.mjs [email]
* Default email: support@webklar.com
*/
import 'dotenv/config'
import { Client, Users, ID } from 'node-appwrite'
const ADMIN_EMAIL = process.argv[2] || 'support@webklar.com'
const ADMIN_NAME = 'Support (Admin)'
const required = ['APPWRITE_ENDPOINT', 'APPWRITE_PROJECT_ID', 'APPWRITE_API_KEY']
for (const k of required) {
if (!process.env[k]) {
console.error(`Missing env: ${k}`)
process.exit(1)
}
}
let password = process.env.ADMIN_INITIAL_PASSWORD
if (!password || password.length < 8) {
const bytes = new Uint8Array(12)
if (typeof globalThis.crypto !== 'undefined' && globalThis.crypto.getRandomValues) {
globalThis.crypto.getRandomValues(bytes)
} else {
const { randomFillSync } = await import('node:crypto')
randomFillSync(bytes)
}
password =
Array.from(bytes).map((b) => 'abcdefghjkmnpqrstuvwxyz23456789'[b % 32]).join('') + 'A1!'
console.log('No ADMIN_INITIAL_PASSWORD set using generated password (save it!):')
console.log(' ' + password)
console.log('')
}
const client = new Client()
.setEndpoint(process.env.APPWRITE_ENDPOINT)
.setProject(process.env.APPWRITE_PROJECT_ID)
.setKey(process.env.APPWRITE_API_KEY)
const users = new Users(client)
async function main() {
try {
const existing = await users.list([], ADMIN_EMAIL)
const found = existing.users?.find((u) => u.email?.toLowerCase() === ADMIN_EMAIL.toLowerCase())
if (found) {
console.log(`User already exists: ${ADMIN_EMAIL} (ID: ${found.$id})`)
console.log('Add to server/.env: ADMIN_EMAILS=' + ADMIN_EMAIL)
return
}
const user = await users.create(ID.unique(), ADMIN_EMAIL, undefined, password, ADMIN_NAME)
console.log('Admin user created:')
console.log(' Email:', user.email)
console.log(' ID:', user.$id)
console.log(' Name:', user.name)
console.log('')
console.log('Add to server/.env: ADMIN_EMAILS=' + ADMIN_EMAIL)
console.log('Then the backend will treat this user as admin (isAdmin() returns true).')
} catch (err) {
console.error('Error:', err.message || err)
if (err.code === 409) {
console.error('User with this email may already exist. Check Appwrite Console → Auth → Users.')
}
process.exit(1)
}
}
main()

View File

@@ -417,7 +417,8 @@ Subject: ${subject}
Preview: ${snippet?.substring(0, 500) || 'No preview'}
RESPONSE FORMAT (JSON ONLY):
{"category": "category_key", "confidence": 0.0-1.0, "reason": "brief explanation"}
{"category": "category_key", "confidence": 0.0-1.0, "reason": "brief explanation", "assignedTo": "name_label_id_or_name_or_null"}
If the email is clearly FOR a specific worker (e.g. "für Max", "an Anna", subject/body mentions them), set assignedTo to that worker's id or name. Otherwise set assignedTo to null or omit it.
Respond ONLY with the JSON object.`
@@ -438,6 +439,15 @@ Respond ONLY with the JSON object.`
result.category = 'review'
}
// Validate assignedTo against name labels (id or name)
if (result.assignedTo && preferences.nameLabels?.length) {
const match = preferences.nameLabels.find(
l => l.enabled && (l.id === result.assignedTo || l.name === result.assignedTo)
)
if (!match) result.assignedTo = null
else result.assignedTo = match.id || match.name
}
return result
} catch (error) {
log.error('AI categorization failed', { error: error.message })
@@ -484,7 +494,8 @@ EMAILS:
${emailList}
RESPONSE FORMAT (JSON ARRAY ONLY):
[{"index": 0, "category": "key"}, {"index": 1, "category": "key"}, ...]
[{"index": 0, "category": "key", "assignedTo": "id_or_name_or_null"}, ...]
If an email is clearly FOR a specific worker, set assignedTo to that worker's id or name. Otherwise omit or null.
Respond ONLY with the JSON array.`
@@ -515,9 +526,16 @@ Respond ONLY with the JSON array.`
return emails.map((email, i) => {
const result = parsed.find(r => r.index === i)
const category = result?.category && CATEGORIES[result.category] ? result.category : 'review'
let assignedTo = result?.assignedTo || null
if (assignedTo && preferences.nameLabels?.length) {
const match = preferences.nameLabels.find(
l => l.enabled && (l.id === assignedTo || l.name === assignedTo)
)
assignedTo = match ? (match.id || match.name) : null
}
return {
email,
classification: { category, confidence: 0.8, reason: 'Batch' },
classification: { category, confidence: 0.8, reason: 'Batch', assignedTo },
}
})
} catch (error) {
@@ -578,6 +596,14 @@ Respond ONLY with the JSON array.`
}
}
// Name labels (workers) assign email to a person when clearly for them
if (preferences.nameLabels?.length) {
const activeNameLabels = preferences.nameLabels.filter(l => l.enabled)
if (activeNameLabels.length > 0) {
parts.push(`NAME LABELS (workers) assign email to ONE person when the email is clearly FOR that person (e.g. "für Max", "an Anna", "Max bitte prüfen", subject/body mentions them):\n${activeNameLabels.map(l => `- id: "${l.id}", name: "${l.name}"${l.keywords?.length ? `, keywords: ${JSON.stringify(l.keywords)}` : ''}`).join('\n')}\nIf the email is for a specific worker, set "assignedTo" to that label's id or name. Otherwise omit assignedTo.`)
}
}
return parts.length > 0 ? `USER PREFERENCES:\n${parts.join('\n')}\n` : ''
}

View File

@@ -373,6 +373,7 @@ export const userPreferences = {
enabledCategories: ['vip', 'customers', 'invoices', 'newsletters', 'promotions', 'social', 'security', 'calendar', 'review'],
categoryActions: {},
companyLabels: [],
nameLabels: [],
autoDetectCompanies: true,
version: 1,
categoryAdvanced: {},
@@ -410,6 +411,7 @@ export const userPreferences = {
enabledCategories: preferences.enabledCategories || defaults.enabledCategories,
categoryActions: preferences.categoryActions || defaults.categoryActions,
companyLabels: preferences.companyLabels || defaults.companyLabels,
nameLabels: preferences.nameLabels || defaults.nameLabels,
autoDetectCompanies: preferences.autoDetectCompanies !== undefined ? preferences.autoDetectCompanies : defaults.autoDetectCompanies,
}
},

181
server/services/imap.mjs Normal file
View File

@@ -0,0 +1,181 @@
/**
* IMAP Service
* Generic IMAP (e.g. Porkbun, Nextcloud mail backend) connect, list, fetch, move to folder
*/
import { ImapFlow } from 'imapflow'
import { log } from '../middleware/logger.mjs'
const INBOX = 'INBOX'
const FOLDER_PREFIX = 'EmailSorter'
/** Map category key to IMAP folder name */
export function getFolderNameForCategory(category) {
const map = {
vip: 'VIP',
customers: 'Clients',
invoices: 'Invoices',
newsletters: 'Newsletters',
promotions: 'Promotions',
social: 'Social',
security: 'Security',
calendar: 'Calendar',
review: 'Review',
archive: 'Archive',
}
return map[category] || 'Review'
}
/**
* IMAP Service same conceptual interface as GmailService/OutlookService
*/
export class ImapService {
/**
* @param {object} opts
* @param {string} opts.host - e.g. imap.porkbun.com
* @param {number} opts.port - e.g. 993
* @param {boolean} opts.secure - true for SSL/TLS
* @param {string} opts.user - email address
* @param {string} opts.password - app password
*/
constructor(opts) {
const { host, port = 993, secure = true, user, password } = opts
this.client = new ImapFlow({
host: host || 'imap.porkbun.com',
port: port || 993,
secure: secure !== false,
auth: { user, pass: password },
logger: false,
})
this.lock = null
}
async connect() {
await this.client.connect()
}
async close() {
try {
if (this.lock) await this.lock.release().catch(() => {})
await this.client.logout()
} catch {
this.client.close()
}
}
/**
* List messages from INBOX (returns ids = UIDs for use with getEmail/batchGetEmails)
* @param {number} maxResults
* @param {string|null} _pageToken - reserved for future pagination
*/
async listEmails(maxResults = 50, _pageToken = null) {
const lock = await this.client.getMailboxLock(INBOX)
this.lock = lock
try {
const uids = await this.client.search({ all: true }, { uid: true })
const slice = uids.slice(0, maxResults)
const nextPageToken = uids.length > maxResults ? String(slice[slice.length - 1]) : null
return {
messages: slice.map((uid) => ({ id: String(uid) })),
nextPageToken,
}
} finally {
lock.release()
this.lock = null
}
}
/** Normalize ImapFlow message to same shape as Gmail/Outlook (id, headers.from, headers.subject, snippet) */
_normalize(msg) {
if (!msg || !msg.envelope) return null
const from = msg.envelope.from && msg.envelope.from[0] ? (msg.envelope.from[0].address || msg.envelope.from[0].name || '') : ''
const subject = msg.envelope.subject || ''
return {
id: String(msg.uid),
headers: { from, subject },
snippet: subject.slice(0, 200),
}
}
/**
* Get one message by id (UID string)
*/
async getEmail(messageId) {
const lock = await this.client.getMailboxLock(INBOX)
this.lock = lock
try {
const list = await this.client.fetchAll(String(messageId), { envelope: true }, { uid: true })
return this._normalize(list && list[0])
} finally {
lock.release()
this.lock = null
}
}
/**
* Batch get multiple messages by id (UID strings) single lock, one fetch
*/
async batchGetEmails(messageIds) {
if (!messageIds.length) return []
const lock = await this.client.getMailboxLock(INBOX)
this.lock = lock
try {
const uids = messageIds.map((id) => (typeof id === 'string' ? Number(id) : id)).filter((n) => !Number.isNaN(n))
if (!uids.length) return []
const list = await this.client.fetchAll(uids, { envelope: true }, { uid: true })
return (list || []).map((m) => this._normalize(m)).filter(Boolean)
} catch (e) {
log.warn('IMAP batchGetEmails failed', { error: e.message })
return []
} finally {
lock.release()
this.lock = null
}
}
/**
* Ensure folder exists (create if not). Use subfolder under EmailSorter to avoid clutter.
*/
async ensureFolder(folderName) {
const path = `${FOLDER_PREFIX}/${folderName}`
try {
await this.client.mailboxCreate(path)
log.info(`IMAP folder created: ${path}`)
} catch (err) {
if (err.code !== 'ALREADYEXISTS' && !/already exists/i.test(err.message)) {
throw err
}
}
return path
}
/**
* Move message (by UID) from INBOX to folder name (under EmailSorter/)
*/
async moveToFolder(messageId, folderName) {
const path = `${FOLDER_PREFIX}/${folderName}`
await this.ensureFolder(folderName)
const lock = await this.client.getMailboxLock(INBOX)
this.lock = lock
try {
await this.client.messageMove(String(messageId), path, { uid: true })
} finally {
lock.release()
this.lock = null
}
}
/**
* Mark message as read (\\Seen)
*/
async markAsRead(messageId) {
const lock = await this.client.getMailboxLock(INBOX)
this.lock = lock
try {
await this.client.messageFlagsAdd(String(messageId), ['\\Seen'], { uid: true })
} finally {
lock.release()
this.lock = null
}
}
}