/* * Pruefungen fuer die Postfachanbindung: `npm test` im Verzeichnis server/. * * Absichtlich ohne Test-Framework - reines Node, kein zusaetzliches Paket. Es * geht um zwei Teile, die unbeaufsichtigt laufen und sich von Hand kaum pruefen * lassen: * * 1. extract.js - was aus einer Mail als Kontakt herausfaellt. Fehler hier * landen still in der Kontaktliste. * 2. graph.js - Token, Wiederholungen, Fehlerbehandlung. Der echte Dienst * liefert 429 und 401 erst dann, wenn es unpassend ist. * * Alles, was eine Datenbank braucht (store.js, contacts.js, sync.js), ist hier * nicht abgedeckt. */ process.env.MS_TENANT_ID ||= 'test-tenant'; process.env.MS_CLIENT_ID ||= 'test-client'; process.env.MS_CLIENT_SECRET ||= 'test-secret'; process.env.MS_MAILBOX ||= 'crm@perinco.de'; const { htmlToText, sanitizeHtml } = await import('../src/mail/html.js'); const { extractCandidates, parsePersonName, parseSignature, isFunctionalAddress } = await import( '../src/mail/extract.js' ); const { accessToken, graphRequest, resetTokenCache, testConnection, GraphError, mailboxPath } = await import('../src/mail/graph.js'); let failures = 0; let currentGroup = ''; function group(name) { currentGroup = name; console.log(`\n${name}`); } function check(name, condition, detail = '') { if (condition) { console.log(` ok ${name}${detail ? ` - ${detail}` : ''}`); } else { console.log(` FAIL ${name}${detail ? ` - ${detail}` : ''}`); failures += 1; } } const OPTIONS = { internalDomains: ['perinco.de', 'perinco.onmicrosoft.com'], mailbox: 'crm@perinco.de', }; const byEmail = (candidates, email) => candidates.find((entry) => entry.email === email); const byName = (candidates, name) => candidates.find((entry) => [entry.firstName, entry.lastName].filter(Boolean).join(' ') === name); // --------------------------------------------------------------------------- group('HTML in Text'); const outlookHtml = `
Hallo Herr Wolf,

vielen Dank für das Gespräch.

Mit freundlichen Grüßen
Dr. Michael Wagner
Head of IT Sourcing
Beispiel Consulting GmbH
Musterstraße 12a
60311 Frankfurt am Main
Tel.: +49 69 1234-567
Mobil: +49 171 9876543
michael.wagner@beispiel-consulting.de
LinkedIn
`; const text = htmlToText(outlookHtml); check('Zeilenumbrueche bleiben erhalten', text.split('\n').length > 5, `${text.split('\n').length} Zeilen`); check('Style-Block ist entfernt', !text.includes('margin:0')); check('Entitaeten sind aufgeloest', text.includes('Grüßen') && text.includes('Musterstraße')); check( 'Linkziel steht hinter dem Linktext', text.includes('LinkedIn (https://www.linkedin.com/in/mwagner)') ); check('Keine drei Leerzeilen in Folge', !/\n\s*\n\s*\n/.test(text)); const dirty = `
Text
Klick`; const clean = sanitizeHtml(dirty); check('Skript ist entfernt', !clean.includes(' entry.confidence >= 70) ); // Automat: darf nichts liefern. const automated = extractCandidates( { fromName: 'freelancermap Newsletter', fromEmail: 'no-reply@news.freelancermap.de', to: [{ name: null, email: 'crm@perinco.de' }], cc: [], bcc: [], bodyText: 'Neue Projekte für Sie.\n\nAbmelden: https://news.freelancermap.de/unsubscribe', }, OPTIONS ); check('Automat liefert keinen Kontakt', automated.length === 0, `${automated.length}`); // Personen im Fliesstext - nur mit Anrede, und nie automatisch. const mentioned = extractCandidates( { fromName: 'Petra Klein', fromEmail: 'p.klein@vermittler.de', to: [{ name: null, email: 'crm@perinco.de' }], cc: [], bcc: [], bodyText: 'Guten Tag,\n\nbitte melden Sie sich bei Herrn Max Mustermann Tel. 0221/3456789.\n' + 'Zweiter Ansprechpartner ist Frau Julia Berg, 0170 1122334.\n' + 'Das Angebot Neue Leistung Zwei liegt bei 0221/111111.\n\n' + 'Mit freundlichen Grüßen\nPetra Klein\nVermittler GmbH\nTel: 030/998877', }, OPTIONS ); check('Anrede-Treffer erkannt', Boolean(byName(mentioned, 'Max Mustermann') && byName(mentioned, 'Julia Berg'))); check('Beschriftung nicht im Namen', !mentioned.some((entry) => /Tel/.test(entry.lastName ?? ''))); check( 'Wortfolge ohne Anrede wird nicht zum Kontakt', !byName(mentioned, 'Neue Leistung Zwei') && !byName(mentioned, 'Zweiter Ansprechpartner') ); check( 'Textfunde bleiben zur Durchsicht', mentioned.filter((entry) => entry.origin === 'text').every((entry) => entry.confidence < 70) ); check('Absender behaelt seine Rufnummer', byEmail(mentioned, 'p.klein@vermittler.de')?.phone === '030/998877'); // --------------------------------------------------------------------------- group('Graph-Client'); let calls = []; let handlers = []; const originalFetch = globalThis.fetch; globalThis.fetch = async (url, options) => { calls.push({ url: String(url), method: options?.method ?? 'GET' }); const handler = handlers.shift(); if (!handler) throw new Error(`Kein Handler mehr fuer ${url}`); return typeof handler === 'function' ? handler(String(url), options) : handler; }; const json = (status, body, headers = {}) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json', ...headers }, }); const tokenOk = () => json(200, { access_token: 'TOKEN-A', expires_in: 3600 }); const networkError = () => { throw new TypeError('fetch failed'); }; function arrange(list) { calls = []; handlers = list; resetTokenCache(); } arrange([tokenOk()]); const first = await accessToken(); const second = await accessToken(); check('Token wird zwischengespeichert', calls.length === 1 && first === second, `${calls.length} Aufruf(e)`); arrange([json(400, { error: 'invalid_client', error_description: 'AADSTS7000215: Invalid client secret provided.' })]); try { await accessToken(); check('Anmeldefehler wird gemeldet', false); } catch (err) { check( 'Anmeldefehler nennt den Grund', err instanceof GraphError && err.message.includes('AADSTS7000215'), err.message ); } arrange([tokenOk(), json(429, { error: {} }, { 'retry-after': '0' }), json(200, { value: [1, 2] })]); const throttled = await graphRequest('/users/x/messages'); check('429 wird wiederholt', throttled.value.length === 2 && calls.length === 3, `${calls.length} Aufruf(e)`); arrange([tokenOk(), json(401, { error: { code: 'InvalidAuthenticationToken' } }), tokenOk(), json(200, { ok: true })]); const renewed = await graphRequest('/users/x/messages'); check('401 holt ein neues Token', renewed.ok === true && calls.length === 4, `${calls.length} Aufruf(e)`); arrange([ tokenOk(), json(401, { error: { code: 'InvalidAuthenticationToken' } }), tokenOk(), json(401, { error: { code: 'InvalidAuthenticationToken', message: 'weg' } }), ]); try { await graphRequest('/users/x/messages'); check('Zweites 401 bricht ab', false); } catch (err) { check('Zweites 401 bricht ab', err.status === 401 && calls.length === 4, `${calls.length} Aufruf(e)`); } arrange([tokenOk(), json(403, { error: { code: 'ErrorAccessDenied', message: 'Access is denied.' } })]); try { await graphRequest('/users/x/messages'); check('403 wird durchgereicht', false); } catch (err) { check('403 wird nicht wiederholt', err.code === 'ErrorAccessDenied' && calls.length === 2, `${calls.length} Aufruf(e)`); } arrange([tokenOk(), json(200, { value: [] })]); const deltaUrl = 'https://graph.microsoft.com/v1.0/users/x/messages/delta?$deltatoken=abc'; await graphRequest(deltaUrl); check('Absolute Delta-URL bleibt unveraendert', calls[1].url === deltaUrl, calls[1].url); check( 'Postfachpfad kodiert die Adresse', mailboxPath('/messages') === '/users/crm%40perinco.de/messages', mailboxPath('/messages') ); arrange([tokenOk(), json(403, { error: { code: 'ErrorAccessDenied', message: 'Access is denied.' } })]); const denied = await testConnection(); check( 'Test erkennt fehlende Berechtigung', denied.ok === false && denied.stage === 'mailbox' && denied.hint.includes('Mail.Read') ); arrange([tokenOk(), json(200, { displayName: 'Posteingang', totalItemCount: 1842, unreadItemCount: 3 })]); const granted = await testConnection(); check('Test bestaetigt Postfachzugriff', granted.ok === true && granted.totalItems === 1842); arrange([tokenOk(), networkError, networkError, networkError, networkError]); try { await graphRequest('/users/x/messages'); check('Netzwerkfehler wird gemeldet', false); } catch (err) { check('Netzwerkfehler wird gemeldet', err instanceof GraphError && err.status === 0, err.message); } globalThis.fetch = originalFetch; // --------------------------------------------------------------------------- console.log( failures === 0 ? '\nAlle Pruefungen bestanden.' : `\n${failures} Pruefung(en) fehlgeschlagen (letzte Gruppe: ${currentGroup}).` ); process.exit(failures === 0 ? 0 : 1);