KC@FlessingLabs 1.0.0: Testmodus ohne Lizenz, Installationsanleitung, Nutzungsbedingungen
Testmodus (ohne gültige Lizenz): 1 Personal-Konto, 2 Kunden; Discord-Bot, E-Mail-Posteingang (IMAP), DATEV-Export und Backups auf externe Ziele nur mit Lizenz. Mit Lizenz gelten deren Grenzen und Module (Lizenz ohne Modulliste = voller Umfang). Bestehende Daten bleiben bei Ablauf vollständig nutzbar, nur Neuanlagen über die Grenzen und die Zusatzfunktionen pausieren. - Edition zentral in @kc/platform/license (getEdition, hasFeature, assertCustomerCapacity, assertStaffCapacity), auch in der CLI durchgesetzt - Einstellungen → Lizenz zeigt die Edition, Hinweisbanner im Testmodus - README mit Installation, Update und Betrieb; systemd-Units unter ops/systemd - .env.example bereinigt (Kommentare nicht mehr hinter Werten, veraltete SMTP/Discord-Variablen entfernt; beides wird in der Oberfläche eingestellt) - LICENSE.md: Nutzungsbedingungen Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
fdd51b18e2
commit
17267b7c05
26 changed files with 368 additions and 73 deletions
|
|
@ -3,6 +3,7 @@ import { hash } from '@node-rs/argon2';
|
|||
import '../core/config.js';
|
||||
import { pool, one, run } from '../core/db.js';
|
||||
import { audit } from '../core/audit.js';
|
||||
import { assertStaffCapacity } from '../core/license.js';
|
||||
|
||||
const [cmd, ...args] = process.argv.slice(2);
|
||||
const opt = (n: string) => args.find((a) => a.startsWith(`--${n}=`))?.slice(n.length + 3);
|
||||
|
|
@ -41,6 +42,8 @@ if (cmd === 'create-superadmin') {
|
|||
const email = opt('email')?.toLowerCase(), name = opt('name') ?? 'Superadmin';
|
||||
if (!email) { console.error('Nutzung: create-superadmin --email=… [--name=…] (Passwort wird danach abgefragt, nie als Argument)'); process.exit(2); }
|
||||
if (await one('SELECT 1 AS x FROM users WHERE email = ?', [email])) { console.error('E-Mail existiert bereits (für einen Reset stattdessen: reset-password --email=…)'); process.exit(1); }
|
||||
const cap = await assertStaffCapacity();
|
||||
if (!cap.allowed) { console.error(cap.reason); process.exit(1); }
|
||||
const pw = await readPassword().catch((e) => { console.error((e as Error).message); process.exit(1); });
|
||||
if (pw.length < 12) { console.error('Passwort zu kurz (min. 12 Zeichen)'); process.exit(2); }
|
||||
const id = randomUUID();
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { passwordMeta, passwordProblem, setBackupPassword, MIN_PASSWORD } from '
|
|||
import { loadTargets, testTarget } from '../../ops/targets.js';
|
||||
import { clientIp, requirePermission } from '../../core/auth.js';
|
||||
import { badRequest, conflict, forbidden, notFound } from '../../core/errors.js';
|
||||
import { featureRequiresLicense, hasFeature } from '../../core/license.js';
|
||||
import type { KcModule } from '../../core/module.js';
|
||||
|
||||
const statusFile = () => process.env.BACKUP_STATUS_FILE ?? '/var/lib/kundencenter/backup-status.json';
|
||||
|
|
@ -111,6 +112,7 @@ export const backupModule: KcModule = {
|
|||
|
||||
app.post('/admin/backup/targets', async (req) => {
|
||||
const a = requirePermission(req, 'backup.secrets');
|
||||
if (!(await hasFeature('backup_remote'))) throw forbidden(featureRequiresLicense('backup_remote'), 'LICENSE_REQUIRED');
|
||||
const b = targetSchema.parse(req.body);
|
||||
if (await one('SELECT 1 AS x FROM backup_targets WHERE name = ?', [b.name])) throw conflict('Der Name ist bereits vergeben.', 'NAME_EXISTS');
|
||||
const { cfg, sec, path } = split(b); const id = randomUUID();
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { audit } from '../../core/audit.js';
|
|||
import { encrypt, decrypt, randomToken } from '../../core/crypto.js';
|
||||
import { clientIp, requireAuth, requirePermission } from '../../core/auth.js';
|
||||
import { badRequest } from '../../core/errors.js';
|
||||
import { featureRequiresLicense, hasFeature } from '../../core/license.js';
|
||||
import { rl, config } from '../../core/config.js';
|
||||
import type { KcModule } from '../../core/module.js';
|
||||
|
||||
|
|
@ -37,6 +38,7 @@ export const discordModule: KcModule = {
|
|||
enabled: z.boolean().default(false),
|
||||
clientId: z.string().trim().regex(ID).nullable(), clientSecret: z.string().max(200).nullable().optional(),
|
||||
}).parse(req.body);
|
||||
if (b.enabled && !(await hasFeature('discord'))) throw badRequest(featureRequiresLicense('discord'), 'LICENSE_REQUIRED');
|
||||
const sets = ['guild_id = ?', 'admin_channel_id = ?', 'ticket_channel_id = ?', 'enabled = ?', 'client_id = ?', 'updated_by = ?'];
|
||||
const params: unknown[] = [b.guildId, b.adminChannelId, b.ticketChannelId, b.enabled ? 1 : 0, b.clientId, a.user.id];
|
||||
if (b.ticketCategoryId !== undefined) { sets.push('ticket_category_id = ?'); params.push(b.ticketCategoryId); }
|
||||
|
|
@ -103,6 +105,7 @@ export const discordModule: KcModule = {
|
|||
/** Startet den Discord-OAuth-Fluss: legt einen kurzlebigen Zustand an und leitet den Browser zu Discord weiter. */
|
||||
app.get('/discord/oauth/start', async (req, reply) => {
|
||||
const a = requireAuth(req);
|
||||
if (!(await hasFeature('discord'))) throw badRequest(featureRequiresLicense('discord'), 'LICENSE_REQUIRED');
|
||||
const s = await one('SELECT client_id FROM discord_settings WHERE id = 1');
|
||||
if (!s?.client_id) throw badRequest('Discord-Anmeldung ist noch nicht eingerichtet.', 'DISCORD_OAUTH_NOT_CONFIGURED');
|
||||
const state = randomToken(32);
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { one, query, run, tx } from '../../core/db.js';
|
|||
import { audit } from '../../core/audit.js';
|
||||
import { COOKIE, clientIp, createSession, requireAuth, requirePermission } from '../../core/auth.js';
|
||||
import { badRequest, conflict, forbidden, notFound, unauthorized } from '../../core/errors.js';
|
||||
import { assertStaffCapacity } from '../../core/license.js';
|
||||
import { decrypt, encrypt, sha256 } from '../../core/crypto.js';
|
||||
import { can, staffPermissions } from '../../core/policy.js';
|
||||
import { createInvitedUser, createToken, mailInvite, inviteLink, resetLink } from '../../core/accounts.js';
|
||||
|
|
@ -239,6 +240,8 @@ export const identityModule: KcModule = {
|
|||
const b = z.object({ email, name: z.string().min(1).max(150), staffRole: z.enum(['support', 'accounting', 'admin', 'superadmin']) }).parse(req.body);
|
||||
if ((b.staffRole === 'admin' || b.staffRole === 'superadmin') && !can(a.principal, 'users.write_privileged')) throw forbidden('Nur Superadministratoren dürfen Administratoren anlegen', 'PRIVILEGED_ONLY');
|
||||
if (await one('SELECT 1 AS x FROM users WHERE email = ?', [b.email])) throw conflict('E-Mail bereits vergeben', 'EMAIL_EXISTS');
|
||||
const cap = await assertStaffCapacity();
|
||||
if (!cap.allowed) throw forbidden(cap.reason!, 'STAFF_LIMIT_REACHED');
|
||||
const inv = await tx((c) => createInvitedUser(c, { email: b.email, name: b.name, kind: 'staff', staffRole: b.staffRole }));
|
||||
const mail = await mailInvite(b.email, b.name, inv.token);
|
||||
await audit({ actorType: 'user', actorId: a.user.id, action: 'user.create', resourceType: 'user', resourceId: inv.userId, correlationId: req.correlationId, ip: clientIp(req), after: { email: b.email, kind: 'staff', staffRole: b.staffRole } });
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { audit } from '../../core/audit.js';
|
|||
import { enqueue } from '../../core/jobs.js';
|
||||
import { clientIp, requireAuth, requirePermission } from '../../core/auth.js';
|
||||
import { badRequest, conflict, forbidden, notFound } from '../../core/errors.js';
|
||||
import { featureRequiresLicense, hasFeature } from '../../core/license.js';
|
||||
import { can, canInOrg } from '../../core/policy.js';
|
||||
import type { KcModule } from '../../core/module.js';
|
||||
import { config } from '../../core/config.js';
|
||||
|
|
@ -487,6 +488,7 @@ export const invoicesModule: KcModule = {
|
|||
* Steuerschlüssel durch das Erlöskonto selbst festgelegt ist (kein geratener BU-Schlüssel nötig). */
|
||||
app.get('/admin/invoices/export.datev', async (req, reply) => {
|
||||
const a = requirePermission(req, 'invoices.read');
|
||||
if (!(await hasFeature('datev'))) throw forbidden(featureRequiresLicense('datev'), 'LICENSE_REQUIRED');
|
||||
const q = exportQuery.parse(req.query);
|
||||
const d = await datevSettings();
|
||||
if (!datevComplete(d)) throw badRequest('DATEV-Stammdaten unvollständig: Beraternummer und Mandantennummer müssen unter Einstellungen → Firma → DATEV-Export hinterlegt sein.', 'DATEV_SETTINGS_INCOMPLETE');
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { one } from '../../core/db.js';
|
|||
import { audit } from '../../core/audit.js';
|
||||
import { clientIp, requirePermission } from '../../core/auth.js';
|
||||
import { badRequest } from '../../core/errors.js';
|
||||
import { checkLicenseNow, getEntitlement, setLicenseKey, LicenseCheckError } from '../../core/license.js';
|
||||
import { checkLicenseNow, getEdition, getEntitlement, setLicenseKey, FEATURE_LABEL, LICENSED_FEATURES, LicenseCheckError } from '../../core/license.js';
|
||||
import type { KcModule } from '../../core/module.js';
|
||||
|
||||
/** Eigene Lizenz der Kundencenter-Installation gegenüber licensing.flessinglabs.com. Nicht zu verwechseln mit
|
||||
|
|
@ -20,7 +20,10 @@ export const licenseModule: KcModule = {
|
|||
const ent = await getEntitlement();
|
||||
const row = await one('SELECT instance_id, license_key_enc IS NOT NULL AS has_key, last_error, last_attempt_at FROM license_state WHERE id = 1');
|
||||
const customers = await one("SELECT COUNT(*) AS n FROM organizations WHERE status IN ('active','suspended')");
|
||||
return { ...ent, instanceId: row?.instance_id ?? null, hasKey: !!row?.has_key, lastError: row?.last_error ?? null, lastAttemptAt: row?.last_attempt_at ?? null, customerCount: Number(customers?.n ?? 0) };
|
||||
const staff = await one("SELECT COUNT(*) AS n FROM users WHERE kind = 'staff' AND status IN ('active','invited')");
|
||||
const ed = await getEdition();
|
||||
const edition = { ...ed, staffCount: Number(staff?.n ?? 0), allFeatures: LICENSED_FEATURES.map((f) => ({ key: f, label: FEATURE_LABEL[f], enabled: ed.features.includes(f) })) };
|
||||
return { ...ent, edition, instanceId: row?.instance_id ?? null, hasKey: !!row?.has_key, lastError: row?.last_error ?? null, lastAttemptAt: row?.last_attempt_at ?? null, customerCount: Number(customers?.n ?? 0) };
|
||||
});
|
||||
|
||||
app.put('/admin/license/settings', async (req) => {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { audit } from '../../core/audit.js';
|
|||
import { encrypt, decrypt } from '../../core/crypto.js';
|
||||
import { clientIp, requirePermission } from '../../core/auth.js';
|
||||
import { badRequest, notFound } from '../../core/errors.js';
|
||||
import { featureRequiresLicense, hasFeature } from '../../core/license.js';
|
||||
import { rl } from '../../core/config.js';
|
||||
import { renderTemplateText, renderTemplateHtml, sendMail } from '../../core/mail.js';
|
||||
import type { KcModule } from '../../core/module.js';
|
||||
|
|
@ -124,6 +125,7 @@ export const mailModule: KcModule = {
|
|||
secureMode: z.enum(['tls', 'starttls']).default('tls'), username: z.string().trim().max(200).nullable(),
|
||||
password: z.string().max(500).nullable().optional(), folder: z.string().trim().min(1).max(200).default('INBOX'), enabled: z.boolean().default(false),
|
||||
}).parse(req.body);
|
||||
if (b.enabled && !(await hasFeature('imap'))) throw badRequest(featureRequiresLicense('imap'), 'LICENSE_REQUIRED');
|
||||
const sets = ['host = ?', 'port = ?', 'secure_mode = ?', 'username = ?', 'folder = ?', 'enabled = ?', 'updated_by = ?'];
|
||||
const params: unknown[] = [b.host, b.port, b.secureMode, b.username, b.folder, b.enabled ? 1 : 0, a.user.id];
|
||||
if (b.password !== undefined) { sets.push('secrets_enc = ?'); params.push(b.password ? encrypt(JSON.stringify({ password: b.password })) : null); }
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { STORE_ROOT as ATTACHMENT_DIR } from '../modules/tickets/files.js';
|
|||
import { fileName, parseName, selectDeletions, type Keep } from './retention.js';
|
||||
import { getBackupPassword } from './settings.js';
|
||||
import { buildRemote, loadTargets, friendly, type Remote } from './targets.js';
|
||||
import { hasFeature } from '../core/license.js';
|
||||
|
||||
/** Konfiguration aus /etc/kundencenter/backup.env (nur Namen/Pfade, keine Zugangsdaten der Ziele; die liegen in der rclone-Konfiguration). */
|
||||
export interface BackupConfig { dir: string; recipient: string | null; identity: string | null; remotes: string[]; rclone: string; rcloneConfig: string | null; keep: Keep; statusFile: string; envDir: string }
|
||||
|
|
@ -119,9 +120,11 @@ export async function runBackup(c: BackupConfig, now = new Date()): Promise<NonN
|
|||
await writeFile(`${out}.sha256`, `${await sha256(out)} ${name}\n`, { mode: 0o600 });
|
||||
targets.push({ name: `lokal (${c.dir})`, ok: true });
|
||||
// Ziele: in der Oberfläche definierte (Datenbank) plus ggf. Altbestand aus BACKUP_REMOTES
|
||||
try { for (const t of await loadTargets(true)) { try { const r = await buildRemote(t, c.rclone); built.push(r); dests.push({ label: t.name, remote: r.remote, env: r.env }); } catch (e) { targets.push({ name: t.name, ok: false, error: friendly(e) }); } } }
|
||||
const remoteAllowed = await hasFeature('backup_remote');
|
||||
if (!remoteAllowed && ((await loadTargets(true).catch(() => [])).length || c.remotes.length)) targets.push({ name: 'Externe Ziele', ok: true, error: 'übersprungen: nur mit Lizenz' });
|
||||
if (remoteAllowed) try { for (const t of await loadTargets(true)) { try { const r = await buildRemote(t, c.rclone); built.push(r); dests.push({ label: t.name, remote: r.remote, env: r.env }); } catch (e) { targets.push({ name: t.name, ok: false, error: friendly(e) }); } } }
|
||||
catch (e) { targets.push({ name: 'Ziele laden', ok: false, error: friendly(e) }); }
|
||||
for (const r of c.remotes) dests.push({ label: r, remote: r, env: c.rcloneConfig ? { RCLONE_CONFIG: c.rcloneConfig } : {} });
|
||||
if (remoteAllowed) for (const r of c.remotes) dests.push({ label: r, remote: r, env: c.rcloneConfig ? { RCLONE_CONFIG: c.rcloneConfig } : {} });
|
||||
const okDests: typeof dests = [];
|
||||
for (const d of dests) {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ interface Status {
|
|||
trial: boolean; expiresAt: string | null; configured: boolean; source: 'live' | 'offline-grace' | 'unchecked' | 'unconfigured';
|
||||
checkedAt: string | null; message: string | null; instanceId: string | null; hasKey: boolean; lastError: string | null; lastAttemptAt: string | null;
|
||||
customerCount: number;
|
||||
edition: { mode: 'licensed' | 'trial'; reason: string | null; customerLimit: number | null; staffLimit: number | null; staffCount: number; allFeatures: { key: string; label: string; enabled: boolean }[] };
|
||||
}
|
||||
const SOURCE_LABEL: Record<Status['source'], string> = { live: 'aktuell geprüft', 'offline-grace': 'Offline-Gnadenzeit', unchecked: 'noch nicht geprüft', unconfigured: 'nicht eingerichtet' };
|
||||
const MODULE_LABEL: Record<string, string> = { billing: 'Rechnungen', domains: 'Domains', provisioning: 'Provisionierung', discord: 'Discord', automation: 'Automatisierung' };
|
||||
|
|
@ -38,13 +39,21 @@ export default function LizenzPage() {
|
|||
<h2>Lizenz</h2>
|
||||
<p className="muted">Die Lizenz dieser Kundencenter-Installation bei licensing.flessinglabs.com (nicht zu verwechseln mit Lizenzen, die über die Verbindung „Lizenzsystem“ an eigene Kunden weiterverkauft werden).</p>
|
||||
{err && <Alert kind="err">{err}</Alert>}{ok && <Alert kind="ok">{ok}</Alert>}
|
||||
{!s.configured && <Alert kind="warn">Noch kein Lizenzschlüssel hinterlegt. Solange keiner hinterlegt ist, gilt keine Kunden-/Modulgrenze.</Alert>}
|
||||
{s.edition.mode === 'trial' && <Alert kind="warn"><strong>Testmodus</strong> ({s.edition.reason}): höchstens {s.edition.staffLimit} Personal-Konto und {s.edition.customerLimit} Kunden; {s.edition.allFeatures.filter((f) => !f.enabled).map((f) => f.label).join(', ')} nur mit Lizenz. Bestehende Daten bleiben vollständig nutzbar.</Alert>}
|
||||
{s.configured && s.source === 'unchecked' && <Alert kind="info">Lizenzschlüssel hinterlegt, erste Prüfung steht noch aus.</Alert>}
|
||||
{s.configured && s.source !== 'unchecked' && !s.valid && <Alert kind="err">Lizenz ungültig: {s.message ?? s.lastError ?? 'unbekannter Grund'}. Neue Kunden und kommerzielle Aktionen sind pausiert, bestehende Daten bleiben zugänglich.</Alert>}
|
||||
{s.valid && s.customerLimit !== null && s.customerCount >= s.customerLimit && <Alert kind="err">Kundengrenze erreicht ({s.customerCount}/{s.customerLimit}). Neue Kunden können erst nach einem Upgrade angelegt werden.</Alert>}
|
||||
{s.valid && s.customerLimit !== null && s.customerCount < s.customerLimit && s.customerCount >= s.customerLimit * 0.9 && <Alert kind="warn">Kundengrenze bald erreicht ({s.customerCount}/{s.customerLimit}). Ein Upgrade lohnt sich bald.</Alert>}
|
||||
{s.valid && s.trial && s.expiresAt && new Date(s.expiresAt).getTime() - Date.now() < 7 * 86400000 && <Alert kind="warn">Testzeitraum endet bald ({fmt(s.expiresAt)}). Danach sind neue Kunden und kommerzielle Aktionen pausiert, bis ein Plan gewählt wird.</Alert>}
|
||||
|
||||
<div className="card">
|
||||
<h3>Edition: {s.edition.mode === 'licensed' ? 'Lizenziert' : 'Testmodus'}</h3>
|
||||
<dl className="row" style={{ flexWrap: 'wrap', gap: '8px 32px' }}>
|
||||
<div><dt className="small muted">Personal-Konten</dt><dd>{s.edition.staffCount}{s.edition.staffLimit === null ? ' (unbegrenzt)' : ` / ${s.edition.staffLimit}`}</dd></div>
|
||||
<div><dt className="small muted">Kunden</dt><dd>{s.customerCount}{s.edition.customerLimit === null ? ' (unbegrenzt)' : ` / ${s.edition.customerLimit}`}</dd></div>
|
||||
{s.edition.allFeatures.map((f) => <div key={f.key}><dt className="small muted">{f.label}</dt><dd>{f.enabled ? <span className="badge ok">enthalten</span> : <span className="badge lock">nur mit Lizenz</span>}</dd></div>)}
|
||||
</dl>
|
||||
</div>
|
||||
<div className="card">
|
||||
<h3>Status</h3>
|
||||
<dl className="row" style={{ flexWrap: 'wrap', gap: '8px 32px' }}>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import { ThemeToggle } from '@/components/ThemeToggle';
|
|||
|
||||
export default function AppLayout({ children }: { children: ReactNode }) {
|
||||
const { me, loading, can, reload } = useSession(); const r = useRouter(); const path = usePathname(); const [open, setOpen] = useState(false);
|
||||
const [trial, setTrial] = useState<string | null>(null);
|
||||
useEffect(() => { if (me?.kind === 'staff' && can('license.read')) api<{ edition: { mode: string; staffLimit: number | null; customerLimit: number | null } }>('GET', '/admin/license/status').then((s) => setTrial(s.edition.mode === 'trial' ? `Testmodus: höchstens ${s.edition.staffLimit} Personal-Konto und ${s.edition.customerLimit} Kunden, ohne Discord-Bot, E-Mail-Posteingang, DATEV-Export und externe Backups.` : null)).catch(() => undefined); }, [me, can]);
|
||||
const enrollNeeded = !!me?.mfaEnrollRequired;
|
||||
useEffect(() => { if (!loading && (!me || me.pendingMfa)) r.replace('/login'); }, [me, loading, r]);
|
||||
useEffect(() => { if (enrollNeeded && path !== '/konto') r.replace('/konto'); }, [enrollNeeded, path, r]);
|
||||
|
|
@ -53,7 +55,7 @@ export default function AppLayout({ children }: { children: ReactNode }) {
|
|||
)}
|
||||
<div className="shell">
|
||||
<div className="topbar"><strong>Kundencenter</strong><ThemeToggle compact /></div>
|
||||
{nav}<main id="main" className="main">{children}</main>
|
||||
{nav}<main id="main" className="main">{trial && path !== '/einstellungen/lizenz' && <div className="alert warn" role="status" style={{ marginBottom: 16 }}>{trial} <Link href="/einstellungen/lizenz">Lizenz hinterlegen</Link></div>}{children}</main>
|
||||
{!enrollNeeded && <nav className="bottomnav" aria-label="Hauptnavigation (mobil)">
|
||||
{bottomLink('/dashboard', 'Übersicht')}
|
||||
{(me.kind === 'customer' || can('resources.read')) && bottomLink('/ressourcen', 'Ressourcen')}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { decrypt } from '@kc/platform/crypto';
|
|||
import { enqueue } from '@kc/platform/jobs';
|
||||
import { config } from '@kc/platform/config';
|
||||
import { audit } from '@kc/platform/audit';
|
||||
import { hasFeature } from '@kc/platform/license';
|
||||
|
||||
interface DiscordSettings {
|
||||
enabled: boolean; token: string | null; guildId: string | null; adminChannelId: string | null; ticketChannelId: string | null;
|
||||
|
|
@ -313,8 +314,8 @@ async function connect(s: DiscordSettings, log: (m: string) => void): Promise<vo
|
|||
/** Prüft, ob sich Token/Server/Aktivierung geändert haben, und verbindet bei Bedarf neu. Für periodischen Aufruf gedacht. */
|
||||
export async function syncDiscord(log: (m: string) => void): Promise<void> {
|
||||
const s = await loadSettings();
|
||||
cachedEnabled = s.enabled && !!s.token;
|
||||
const fp = `${s.enabled}|${s.token}|${s.guildId}`;
|
||||
cachedEnabled = s.enabled && !!s.token && (await hasFeature('discord')); // Discord-Bot nur mit Lizenz
|
||||
const fp = `${s.enabled}|${s.token}|${s.guildId}|${cachedEnabled}`; // Lizenzwechsel verbindet/trennt ebenfalls
|
||||
if (fp === lastFingerprint) return;
|
||||
lastFingerprint = fp;
|
||||
if (client) { await client.destroy().catch(() => undefined); client = null; }
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { simpleParser } from 'mailparser';
|
|||
import { pool } from '@kc/platform/db';
|
||||
import { decrypt } from '@kc/platform/crypto';
|
||||
import { enqueue } from '@kc/platform/jobs';
|
||||
import { hasFeature } from '@kc/platform/license';
|
||||
|
||||
/** Sehr einfache HTML->Text-Reduktion für Mails ohne Text-Teil. Nur zur Anzeige, nie als HTML gerendert. */
|
||||
function htmlToText(html: string): string {
|
||||
|
|
@ -27,6 +28,7 @@ export async function checkMailbox(log: (m: string) => void): Promise<void> {
|
|||
const [rows] = await pool.query('SELECT * FROM imap_settings WHERE id = 1') as any;
|
||||
const s = rows[0];
|
||||
if (!s?.enabled || !s?.host || !s?.username || !s?.secrets_enc) return;
|
||||
if (!(await hasFeature('imap'))) return; // E-Mail-Posteingang nur mit Lizenz
|
||||
const password = JSON.parse(decrypt(s.secrets_enc)).password as string;
|
||||
const client = new ImapFlow({ host: s.host, port: s.port, secure: s.secure_mode === 'tls', auth: { user: s.username, pass: password }, logger: false });
|
||||
try {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue