2026-09-27 00:51:32 +02:00
import http from 'node:http' ;
import { env } from './env.js' ;
import { pool } from '@kc/platform/db' ;
import { discordEnabled , discordReady , startDiscord , stopDiscord } from './discord.js' ;
import { recoverStale , runOnce } from './jobs.js' ;
import { enqueue } from '@kc/platform/jobs' ;
import { processContractLifecycle , scheduleDueSyncs } from '@kc/connectors' ;
feat: IMAP-Posteingang fürs Ticketsystem (Mail-Antworten -> Tickets)
Neues Postfach lässt sich unter Einstellungen > E-Mail > IMAP-Posteingang
einrichten (Host/Port/Verschlüsselung/Zugangsdaten/Ordner, An/Aus,
Verbindungstest). Der Worker ruft es alle 2 Minuten ab (mailbox.ts) und
ordnet eingehende Mails zu:
1. Betreff enthält eine Ticketnummer (T-123) UND Absender gehört
nachweislich zum Kunden dieses Tickets -> wird als Kundennachricht
angehängt (Status pending_staff, Discord-Benachrichtigung).
2. Sonst, wenn der Absender ein bekannter aktiver Kunde ist -> neues
Ticket wird automatisch unter seinem Kundenkonto angelegt.
3. Sonst (unbekannter/nicht verifizierbarer Absender) -> landet im
neuen Posteingang (/admin/inbox, UI unter /admin/posteingang) zur
manuellen Zuordnung: Personal wählt einen Kunden (legt Ticket an),
hängt die Mail an ein bestehendes Ticket, oder ignoriert sie.
Beim ersten Aktivieren wird kein Mailbox-Bestand verarbeitet, nur ab
dann neu Eingehendes (last_uid wird auf den aktuellen Stand gesetzt).
Migration 024: imap_settings (Verbindung, verschlüsseltes Passwort,
last_uid/last_error), inbox_messages (Warteschlange). Rechte:
email.read/write für die IMAP-Verbindung (wie SMTP), tickets.read/write
für den Posteingang (Zuordnung ist eine Ticket-Aktion, daher im
Tickets-Modul statt eines eigenen Moduls, um keine Modulgrenzen zu
verletzen).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 17:35:58 +02:00
import { checkMailbox } from './mailbox.js' ;
2026-09-27 00:51:32 +02:00
const log = ( m : string ) = > console . log ( JSON . stringify ( { t : new Date ( ) . toISOString ( ) , svc : 'worker' , msg : m } ) ) ;
let lastLoop = Date . now ( ) ;
let stopping = false ;
// Health: Liveness (Schleife lebt) + Readiness (DB), Discord-Status nur informativ
http . createServer ( async ( req , res ) = > {
const alive = Date . now ( ) - lastLoop < 60 _000 ;
let db = false ; try { await pool . query ( 'SELECT 1' ) ; db = true ; } catch { /* db down */ }
const ok = alive && db ;
res . writeHead ( req . url === '/health' ? ( alive ? 200 : 503 ) : ( ok ? 200 : 503 ) , { 'content-type' : 'application/json' } ) ;
res . end ( JSON . stringify ( { status : ok ? 'ok' : 'degraded' , loop : alive , db , discord : discordEnabled ( ) ? ( discordReady ( ) ? 'connected' : 'connecting' ) : 'disabled' } ) ) ;
} ) . listen ( env . healthPort , '127.0.0.1' ) ;
await recoverStale ( log ) ;
startDiscord ( log ) . catch ( ( e ) = > log ( ` Discord-Start fehlgeschlagen: ${ ( e as Error ) . message } ` ) ) ;
setInterval ( ( ) = > recoverStale ( log ) . catch ( ( ) = > undefined ) , 60 _000 ) ;
// Regelmäßiger Abgleich: fällige Connector-Instanzen als Aufträge einplanen (idempotent pro Zeitfenster)
const schedule = ( ) = > scheduleDueSyncs ( ( t , p , o ) = > enqueue ( t , p , o ) ) . catch ( ( e ) = > log ( ` Planung fehlgeschlagen: ${ ( e as Error ) . message } ` ) ) ;
setInterval ( schedule , 30 _000 ) ; void schedule ( ) ;
// Verträge: Kündigungen wirksam machen, verlängern, auslaufen lassen (idempotent)
const lifecycle = ( ) = > processContractLifecycle ( new Date ( ) , ( t , p , o ) = > enqueue ( t , p , o ) ) . then ( ( r ) = > { if ( r . ended || r . renewed ) log ( ` Verträge: ${ r . ended } beendet, ${ r . renewed } verlängert ` ) ; } ) . catch ( ( e ) = > log ( ` Vertragslauf fehlgeschlagen: ${ ( e as Error ) . message } ` ) ) ;
setInterval ( lifecycle , 60 _000 ) ; void lifecycle ( ) ;
// Backup-Frische: fehlt ein erfolgreiches Backup seit >26 h, wird gemeldet (höchstens alle 12 h, ohne Nutzdaten)
import { readFile } from 'node:fs/promises' ;
const checkBackup = async ( ) = > {
try {
const st = JSON . parse ( await readFile ( process . env . BACKUP_STATUS_FILE ? ? '/var/lib/kundencenter/backup-status.json' , 'utf8' ) ) ;
const age = st . lastRun ? . at ? ( Date . now ( ) - new Date ( st . lastRun . at ) . getTime ( ) ) / 3600000 : Infinity ;
if ( age > 26 ) await enqueue ( 'discord.notify' , { event : 'backup.stale' , detail : Number.isFinite ( age ) ? ` letzter Lauf vor ${ Math . round ( age ) } Stunden ` : 'noch nie gelaufen' } , { idempotencyKey : ` backup.stale: ${ Math . floor ( Date . now ( ) / ( 12 * 3600000 ) ) } ` } ) ;
} catch { /* Backup nicht eingerichtet: keine Meldung */ }
} ;
setInterval ( ( ) = > void checkBackup ( ) , 3600 _000 ) ;
feat: IMAP-Posteingang fürs Ticketsystem (Mail-Antworten -> Tickets)
Neues Postfach lässt sich unter Einstellungen > E-Mail > IMAP-Posteingang
einrichten (Host/Port/Verschlüsselung/Zugangsdaten/Ordner, An/Aus,
Verbindungstest). Der Worker ruft es alle 2 Minuten ab (mailbox.ts) und
ordnet eingehende Mails zu:
1. Betreff enthält eine Ticketnummer (T-123) UND Absender gehört
nachweislich zum Kunden dieses Tickets -> wird als Kundennachricht
angehängt (Status pending_staff, Discord-Benachrichtigung).
2. Sonst, wenn der Absender ein bekannter aktiver Kunde ist -> neues
Ticket wird automatisch unter seinem Kundenkonto angelegt.
3. Sonst (unbekannter/nicht verifizierbarer Absender) -> landet im
neuen Posteingang (/admin/inbox, UI unter /admin/posteingang) zur
manuellen Zuordnung: Personal wählt einen Kunden (legt Ticket an),
hängt die Mail an ein bestehendes Ticket, oder ignoriert sie.
Beim ersten Aktivieren wird kein Mailbox-Bestand verarbeitet, nur ab
dann neu Eingehendes (last_uid wird auf den aktuellen Stand gesetzt).
Migration 024: imap_settings (Verbindung, verschlüsseltes Passwort,
last_uid/last_error), inbox_messages (Warteschlange). Rechte:
email.read/write für die IMAP-Verbindung (wie SMTP), tickets.read/write
für den Posteingang (Zuordnung ist eine Ticket-Aktion, daher im
Tickets-Modul statt eines eigenen Moduls, um keine Modulgrenzen zu
verletzen).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 17:35:58 +02:00
// Ticket-Posteingang: eingehende Mails abrufen und zuordnen (siehe mailbox.ts)
setInterval ( ( ) = > void checkMailbox ( log ) . catch ( ( e ) = > log ( ` IMAP-Lauf fehlgeschlagen: ${ ( e as Error ) . message } ` ) ) , 120 _000 ) ;
void checkMailbox ( log ) . catch ( ( e ) = > log ( ` IMAP-Lauf fehlgeschlagen: ${ ( e as Error ) . message } ` ) ) ;
2026-09-27 00:51:32 +02:00
for ( const sig of [ 'SIGTERM' , 'SIGINT' ] as const ) process . on ( sig , async ( ) = > { stopping = true ; await stopDiscord ( ) ; await pool . end ( ) ; process . exit ( 0 ) ; } ) ;
log ( 'Worker gestartet' ) ;
while ( ! stopping ) {
try { lastLoop = Date . now ( ) ; const worked = await runOnce ( log ) ; if ( ! worked ) await new Promise ( ( r ) = > setTimeout ( r , 2000 ) ) ; }
catch ( e ) { log ( ` Schleifenfehler: ${ ( e as Error ) . message } ` ) ; await new Promise ( ( r ) = > setTimeout ( r , 5000 ) ) ; }
}