Files
tessera-ctl/apps/api/src/calendar/providers/exchange.provider.ts
T
schalli d8fb9ae07d refactor(quick-260921-m34): Aufgabe 3c - Randschicht beurteilt, drei Befunde gemeldet, 15 bleiben mit Urteil
httpntlm (exchange.provider, exchange-inbox.provider): NtlmOptions und
NtlmResponse beschreiben genau das, was uebergeben und gelesen wird. Die
ueberfluessige Zusicherung (httpntlm as any) faellt weg.

Graph-Rueckrufe (exchange.provider :157/:313): AuthProviderCallback aus dem
SDK selbst statt Handannotation - als import type, also ohne den dynamischen
Import zur Laufzeit zurueckzunehmen.

imapflow: streamToBuffer() nimmt Readable statt NodeJS.ReadableStream (alle
drei Aufrufer reichen client.download().content herein, imapflow deklariert
das als Readable) - damit traegt der Typ destroy() und die Zusicherung
faellt. node.parameters?.name war ebenfalls schon getypt.

nodemailer: ResolvedTransport.options wird SMTPTransport.Options; beide
Zweige bauen reine SMTP-Optionen, createTransport() nimmt sie ohne
Zusicherung.

node-forge: die vier let p7: any werden Captured<PkcsEnvelopedData |
PkcsSignedData> - der MITGELIEFERTE Typ. Die Lesestellen grenzen mit
'certificates' in p7 ein statt zuzusichern; verhaltensgleich, weil der
enveloped-Form das Feld fehlt und beide Schreibweisen dann die leere Liste
liefern. cert.siginfo war bereits getypt.

apps/web/src/test/setup.ts: expect.extend(matchers) traegt ohne Zusicherung
- geprueft im echten Typlauf (setup.ts liegt im include von
apps/web/tsconfig.json, mit einem absichtlichen Fehler nachgewiesen).

BEFUND 4 (D-03, gemeldet, NICHT repariert) imap.provider.ts:78 - der
Ausdruck (node as any).disposition?.parameters?.filename liest .parameters
von einer ZEICHENKETTE: imapflow deklariert disposition als string
(imap-flow.d.ts:448), die Parameter liegen in dispositionParameters (:450).
dispositionFilename ist damit zur Laufzeit immer ''. Folge: Outlook-Anhaenge,
die als application/octet-stream kommen, werden ueber den Dateinamen aus
Content-Disposition NICHT erkannt - nur ueber den aus Content-Type. Umbiegen
waere eine Verhaltensaenderung; die Zusicherung bleibt sichtbar stehen.

BEFUND 5 (D-03, gemeldet, NICHT repariert) imap.provider.ts:402 -
requireTLS kommt in imapflow 1.4.3 NIRGENDS vor, weder in ImapFlowOptions
noch im Laufzeitcode (beides durchsucht). Die Option wird still verworfen;
STARTTLS wird durch sie nicht erzwungen. Genau das } as any hat es
verdeckt. Bleibt stehen, damit der Befund in der Zaehlung sichtbar ist.

BEFUND 6 (D-03, gemeldet, Verhalten unveraendert) httpntlm liefert den
Rumpf als Zeichenkette, nicht als Buffer: httpreq setzt ihn nur bei
gesetzter Option binary auf Buffer (httpreq@1.1.1/lib/httpreq.js:391),
keiner der beiden Aufrufer setzt sie. Der Bestand rief unbesehen
.toString('utf-8') auf - das ging nur gut, weil String.toString() sein
Argument ignoriert. Die Testdoppel reichen dagegen wirklich Buffer herein.
NtlmResponse.body nennt jetzt beide Formen, die Fallunterscheidung liefert
fuer jede exakt dasselbe Ergebnis wie zuvor.

Urteil BLEIBT mit Begruendung im Code an allen 15 verbleibenden Stellen:
3x addCronJob (require-Umweg aus 07-04), 5x node-forge (EC-Zweig und
extensions: any[] sind in @types/node-forge nicht beschrieben, 2x null as
any wo die Typen die Bibliothek nachweislich falsch beschreiben), 2x
imap-Befunde oben, 2x tx: any plus 2x Gefolge (Aufgabe 1), 1x
disposition-Befund.

noExplicitAny in apps/api/src: 31 -> 15 (Ausgang 288, Schranke 45), apps/web
1 -> 0. type-check 4/4, lint 5/5 (0 error), apps/api 72/1143, apps/web
73/531, rls-access-inventory 30/30. noNonNullAssertion 56, as unknown as 33,
ts-expect-error/ts-ignore 0/0, Unterdrueckungsmarker 1. biome.json, alle
package.json und pnpm-lock.yaml unveraendert.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
2026-09-21 17:29:28 +02:00

366 lines
11 KiB
TypeScript

import { Injectable, Logger } from '@nestjs/common';
import type { AuthProviderCallback } from '@microsoft/microsoft-graph-client';
import { CalendarEvent, CalendarProvider } from '../calendar.service';
/** Optionen, die ntlmPost() unten uebergibt — nichts darueber hinaus. */
interface NtlmOptions {
url: string;
username: string;
password: string;
domain: string;
workstation: string;
body: string;
headers: Record<string, string>;
}
/**
* Antwortform von httpntlm.post, beschrieben aus dem, was gelesen wird.
*
* `body` ist `Buffer | string`: httpreq (unter httpntlm) liefert eine
* Zeichenkette, solange `binary` nicht gesetzt ist (gemessen,
* httpreq@1.1.1/lib/httpreq.js:391) — hier wird es nicht gesetzt. Siehe die
* ausfuehrliche Begruendung in inbox/exchange-inbox.provider.ts.
*/
interface NtlmResponse {
statusCode: number;
body?: Buffer | string;
}
// eslint-disable-next-line @typescript-eslint/no-require-imports
const httpntlm = require('httpntlm') as {
post: (opts: NtlmOptions, cb: (err: Error | null, res: NtlmResponse) => void) => void;
};
const NS_SOAP = 'http://schemas.xmlsoap.org/soap/envelope/';
const NS_TYPES = 'http://schemas.microsoft.com/exchange/services/2006/types';
const NS_MESSAGES = 'http://schemas.microsoft.com/exchange/services/2006/messages';
function soapEnvelope(body: string): string {
return `<?xml version="1.0" encoding="utf-8"?>
<soap:Envelope xmlns:soap="${NS_SOAP}" xmlns:t="${NS_TYPES}" xmlns:m="${NS_MESSAGES}">
<soap:Body>${body}</soap:Body>
</soap:Envelope>`;
}
function escapeXml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function extractAll(xml: string, tag: string): string[] {
const results: string[] = [];
const open = `<${tag}`;
const close = `</${tag}>`;
let pos = 0;
while (pos < xml.length) {
const start = xml.indexOf(open, pos);
if (start === -1) break;
const end = xml.indexOf(close, start);
if (end === -1) break;
const innerStart = xml.indexOf('>', start) + 1;
results.push(xml.slice(innerStart, end));
pos = end + close.length;
}
return results;
}
function extractAttr(xml: string, tag: string, attr: string): string {
const tagStart = xml.indexOf(`<${tag}`);
if (tagStart === -1) return '';
const tagEnd = xml.indexOf('>', tagStart);
const tagStr = xml.slice(tagStart, tagEnd + 1);
const attrMatch = tagStr.match(new RegExp(`${attr}="([^"]*)"`));
return attrMatch ? attrMatch[1] : '';
}
function ntlmPost(opts: NtlmOptions): Promise<{ statusCode: number; body: string }> {
return new Promise((resolve, reject) => {
httpntlm.post(opts, (err, res) => {
if (err) return reject(err);
resolve({
statusCode: res.statusCode,
body: typeof res.body === 'string' ? res.body : (res.body?.toString('utf-8') ?? ''),
});
});
});
}
/**
* Exchange calendar provider — dispatches on exchangeMode ('graph' vs 'ews').
*
* - 'graph': Uses @microsoft/microsoft-graph-client for Exchange Online / M365
* - 'ews': Uses ews-javascript-api for on-premise Exchange Server
*
* Both modes gracefully degrade: on auth failure, returns empty array and
* surfaces a generic error (no credential details — Security V7 / T-05-13).
*/
@Injectable()
export class ExchangeProvider implements CalendarProvider {
private readonly logger = new Logger(ExchangeProvider.name);
/**
* Fetches events from Exchange, dispatching by exchangeMode.
* D-08: source TYPE is configurable and attempted — widget must not crash.
*/
async fetchEvents(
source: {
url: string;
username?: string;
password?: string;
exchangeMode?: string | null;
domain?: string;
id: string;
color?: string | null;
},
from: Date,
to: Date,
): Promise<CalendarEvent[]> {
const mode = source.exchangeMode || 'graph';
try {
if (mode === 'graph') {
return await this.fetchViaGraph(source, from, to);
} else {
return await this.fetchViaEws(source, from, to);
}
} catch (error) {
// Graceful degradation — T-05-13: no credential details in error
this.logger.error(
`Exchange (${mode}) fetch failed for source ${source.id}: ${(error as Error).message}`,
);
return [];
}
}
/**
* Tests connection to Exchange. Returns false on any auth/network failure.
*/
async testConnection(
source: {
url: string;
username?: string;
password?: string;
exchangeMode?: string | null;
domain?: string;
id: string;
},
): Promise<boolean> {
const mode = source.exchangeMode || 'graph';
try {
if (mode === 'graph') {
return await this.testGraphConnection(source);
} else {
return await this.testEwsConnection(source);
}
} catch {
return false;
}
}
/**
* Fetches events via Microsoft Graph API (Exchange Online / M365).
* Uses @microsoft/microsoft-graph-client with /me/calendarView.
*/
private async fetchViaGraph(
source: {
url: string;
username?: string;
password?: string;
id: string;
color?: string | null;
},
from: Date,
to: Date,
): Promise<CalendarEvent[]> {
// Dynamic import to avoid loading Graph SDK when not needed
const { Client: GraphClient } = await import(
'@microsoft/microsoft-graph-client'
);
const client = GraphClient.init({
authProvider: (done: AuthProviderCallback) => {
// Use the password as the access token (OAuth bearer token)
// Users configure their OAuth token in the password field for Graph API
done(null, source.password || '');
},
});
const result = await client
.api('/me/calendarView')
.query({
startDateTime: from.toISOString(),
endDateTime: to.toISOString(),
})
.select('id,subject,start,end,isAllDay,location,bodyPreview')
.orderby('start/dateTime')
.top(100)
.get();
const events: CalendarEvent[] = [];
if (result?.value) {
for (const item of result.value) {
events.push({
id: `${source.id}-${item.id}`,
sourceId: source.id,
title: item.subject || 'Untitled',
start: new Date(`${item.start?.dateTime}Z`),
end: new Date(`${item.end?.dateTime}Z`),
allDay: item.isAllDay || false,
location: item.location?.displayName || undefined,
description: item.bodyPreview || undefined,
color: source.color ?? undefined,
});
}
}
return events;
}
/**
* Fetches calendar events via EWS using NTLM authentication (on-premise Exchange).
* Uses raw SOAP + httpntlm — replaces ews-javascript-api which only supports Basic Auth.
*/
private async fetchViaEws(
source: {
url: string;
username?: string;
password?: string;
domain?: string;
id: string;
color?: string | null;
},
from: Date,
to: Date,
): Promise<CalendarEvent[]> {
const fromIso = from.toISOString();
const toIso = to.toISOString();
const findSoap = soapEnvelope(`
<m:FindItem Traversal="Shallow">
<m:ItemShape>
<t:BaseShape>IdOnly</t:BaseShape>
<t:AdditionalProperties>
<t:FieldURI FieldURI="item:Subject"/>
<t:FieldURI FieldURI="calendar:Start"/>
<t:FieldURI FieldURI="calendar:End"/>
<t:FieldURI FieldURI="calendar:IsAllDayEvent"/>
<t:FieldURI FieldURI="calendar:Location"/>
</t:AdditionalProperties>
</m:ItemShape>
<m:CalendarView StartDate="${escapeXml(fromIso)}" EndDate="${escapeXml(toIso)}" MaxEntriesReturned="100"/>
<m:ParentFolderIds>
<t:DistinguishedFolderId Id="calendar"/>
</m:ParentFolderIds>
</m:FindItem>`);
const res = await this.ewsNtlmPost(source, findSoap, 'FindItem');
if (res.statusCode !== 200) {
this.logger.warn(`EWS FindItem calendar returned HTTP ${res.statusCode}`);
return [];
}
const events: CalendarEvent[] = [];
const itemBlocks = this.splitItemBlocks(res.body, 't:CalendarItem');
for (const block of itemBlocks) {
const uid = extractAttr(block, 't:ItemId', 'Id');
const title = extractAll(block, 't:Subject')[0] ?? 'Untitled';
const startStr = extractAll(block, 't:Start')[0] ?? '';
const endStr = extractAll(block, 't:End')[0] ?? '';
const allDayStr = extractAll(block, 't:IsAllDayEvent')[0] ?? 'false';
const location = extractAll(block, 't:Location')[0] ?? undefined;
events.push({
id: `${source.id}-${uid || String(Date.now())}`,
sourceId: source.id,
title,
start: startStr ? new Date(startStr) : new Date(),
end: endStr ? new Date(endStr) : new Date(),
allDay: allDayStr === 'true',
location: location || undefined,
description: undefined,
color: source.color ?? undefined,
});
}
return events;
}
private splitItemBlocks(xml: string, tag: string): string[] {
const blocks: string[] = [];
const open = `<${tag}`;
const close = `</${tag}>`;
let pos = 0;
while (pos < xml.length) {
const start = xml.indexOf(open, pos);
if (start === -1) break;
const end = xml.indexOf(close, start);
if (end === -1) break;
blocks.push(xml.slice(start, end + close.length));
pos = end + close.length;
}
return blocks;
}
private async ewsNtlmPost(
source: { url: string; username?: string; password?: string; domain?: string },
soap: string,
action: string,
): Promise<{ statusCode: number; body: string }> {
return ntlmPost({
url: source.url,
username: source.username ?? '',
password: source.password ?? '',
domain: source.domain ?? '',
workstation: '',
body: soap,
headers: {
'Content-Type': 'text/xml; charset=utf-8',
'SOAPAction': `"http://schemas.microsoft.com/exchange/services/2006/messages/${action}"`,
},
});
}
/**
* Tests Graph API connection by requesting calendar list.
*/
private async testGraphConnection(
source: { url: string; password?: string },
): Promise<boolean> {
const { Client: GraphClient } = await import(
'@microsoft/microsoft-graph-client'
);
const client = GraphClient.init({
authProvider: (done: AuthProviderCallback) => {
done(null, source.password || '');
},
});
const result = await client.api('/me/calendars').top(1).get();
return !!result?.value;
}
/**
* Tests EWS connection using NTLM auth — GetFolder on calendar folder.
*/
private async testEwsConnection(
source: { url: string; username?: string; password?: string; domain?: string },
): Promise<boolean> {
const soap = soapEnvelope(`
<m:GetFolder>
<m:FolderShape>
<t:BaseShape>IdOnly</t:BaseShape>
</m:FolderShape>
<m:FolderIds>
<t:DistinguishedFolderId Id="calendar"/>
</m:FolderIds>
</m:GetFolder>`);
const res = await this.ewsNtlmPost(source, soap, 'GetFolder');
return res.statusCode === 200 && !res.body.includes('ResponseClass="Error"');
}
}