fix(07): NTLM support for Exchange EWS + crypto key init timing fix
- ExchangeInboxProvider rewritten to use httpntlm + raw EWS SOAP: FindItem / GetItem / GetAttachment via NTLM challenge-response. No longer requires Basic Auth on Exchange EWS virtual directory. Folder name mapped to EWS DistinguishedFolderId (Inbox/SentItems/etc). - CalendarCryptoService: move key init from onModuleInit to constructor so MailModule.forRootAsync() factory can call decrypt() before NestJS lifecycle hooks execute (startup crash when SmtpConfig row has password). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -20,14 +20,15 @@
|
|||||||
"@nestjs/passport": "^11.0.5",
|
"@nestjs/passport": "^11.0.5",
|
||||||
"@nestjs/platform-express": "^11.0.0",
|
"@nestjs/platform-express": "^11.0.0",
|
||||||
"@nestjs/schedule": "^6.1.3",
|
"@nestjs/schedule": "^6.1.3",
|
||||||
"cron": "4.4.0",
|
|
||||||
"@prisma/client": "^6.0.0",
|
"@prisma/client": "^6.0.0",
|
||||||
"@tessera/shared": "workspace:*",
|
"@tessera/shared": "workspace:*",
|
||||||
"argon2": "^0.44.0",
|
"argon2": "^0.44.0",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.15.1",
|
"class-validator": "^0.15.1",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
|
"cron": "4.4.0",
|
||||||
"ews-javascript-api": "0.15.3",
|
"ews-javascript-api": "0.15.3",
|
||||||
|
"httpntlm": "^1.8.13",
|
||||||
"imapflow": "^1.4.3",
|
"imapflow": "^1.4.3",
|
||||||
"ldapts": "^8.1.8",
|
"ldapts": "^8.1.8",
|
||||||
"node-ical": "0.26.1",
|
"node-ical": "0.26.1",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Injectable, OnModuleInit } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';
|
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';
|
||||||
|
|
||||||
@@ -9,14 +9,16 @@ import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';
|
|||||||
* Encrypted values are stored as `iv:authTag:ciphertext` (hex-joined).
|
* Encrypted values are stored as `iv:authTag:ciphertext` (hex-joined).
|
||||||
*
|
*
|
||||||
* Security: T-05-10 — credentials encrypted at rest, never returned in GET responses.
|
* Security: T-05-10 — credentials encrypted at rest, never returned in GET responses.
|
||||||
|
*
|
||||||
|
* Key is initialised in the constructor (not onModuleInit) so that async factory
|
||||||
|
* functions in other modules (e.g. MailModule.forRootAsync) can call decrypt()
|
||||||
|
* before NestJS lifecycle hooks run.
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CalendarCryptoService implements OnModuleInit {
|
export class CalendarCryptoService {
|
||||||
private key!: Buffer;
|
private readonly key: Buffer;
|
||||||
|
|
||||||
constructor(private readonly configService: ConfigService) {}
|
constructor(private readonly configService: ConfigService) {
|
||||||
|
|
||||||
onModuleInit() {
|
|
||||||
const hexKey = this.configService.get<string>('CALENDAR_ENCRYPTION_KEY');
|
const hexKey = this.configService.get<string>('CALENDAR_ENCRYPTION_KEY');
|
||||||
|
|
||||||
if (!hexKey) {
|
if (!hexKey) {
|
||||||
|
|||||||
@@ -1,247 +1,349 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import type { InboxConfig, InboxEmail } from './inbox-provider.interface';
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
|
const httpntlm = require('httpntlm') as { post: (opts: any, cb: (err: Error | null, res: any) => void) => void };
|
||||||
|
import type { InboxAttachment, InboxConfig, InboxEmail } from './inbox-provider.interface';
|
||||||
import type { InboxProvider } from './inbox-provider.interface';
|
import type { InboxProvider } from './inbox-provider.interface';
|
||||||
|
|
||||||
/**
|
const MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024; // 25 MB — T-07-05
|
||||||
* Max attachment size (bytes) accepted before buffering.
|
|
||||||
* Mirrors the same limit in ImapProvider (T-07-05 — PDF-bomb mitigation).
|
// ─── EWS SOAP namespace constants ────────────────────────────────────────────
|
||||||
*/
|
|
||||||
const MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024; // 25 MB
|
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';
|
||||||
|
|
||||||
|
// ─── SOAP envelope builders ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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 findItemSoap(folderId: string, maxResults: number, senderFilter?: string): string {
|
||||||
|
const restriction = senderFilter
|
||||||
|
? `<m:Restriction>
|
||||||
|
<t:Contains ContainmentMode="Substring" ContainmentComparison="IgnoreCase">
|
||||||
|
<t:FieldURI FieldURI="message:From"/>
|
||||||
|
<t:Constant Value="${escapeXml(senderFilter)}"/>
|
||||||
|
</t:Contains>
|
||||||
|
</m:Restriction>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return soapEnvelope(`
|
||||||
|
<m:FindItem Traversal="Shallow">
|
||||||
|
<m:ItemShape>
|
||||||
|
<t:BaseShape>IdOnly</t:BaseShape>
|
||||||
|
<t:AdditionalProperties>
|
||||||
|
<t:FieldURI FieldURI="item:Subject"/>
|
||||||
|
<t:FieldURI FieldURI="message:InternetMessageId"/>
|
||||||
|
<t:FieldURI FieldURI="message:From"/>
|
||||||
|
<t:FieldURI FieldURI="item:DateTimeReceived"/>
|
||||||
|
<t:FieldURI FieldURI="item:HasAttachments"/>
|
||||||
|
</t:AdditionalProperties>
|
||||||
|
</m:ItemShape>
|
||||||
|
<m:IndexedPageItemView MaxEntriesReturned="${maxResults}" Offset="0" BasePoint="Beginning"/>
|
||||||
|
${restriction}
|
||||||
|
<m:ParentFolderIds>
|
||||||
|
<t:DistinguishedFolderId Id="${escapeXml(folderId)}"/>
|
||||||
|
</m:ParentFolderIds>
|
||||||
|
</m:FindItem>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getItemSoap(itemIds: string[]): string {
|
||||||
|
const ids = itemIds.map((id) => `<t:ItemId Id="${escapeXml(id)}"/>`).join('');
|
||||||
|
return soapEnvelope(`
|
||||||
|
<m:GetItem>
|
||||||
|
<m:ItemShape>
|
||||||
|
<t:BaseShape>IdOnly</t:BaseShape>
|
||||||
|
<t:AdditionalProperties>
|
||||||
|
<t:FieldURI FieldURI="item:Subject"/>
|
||||||
|
<t:FieldURI FieldURI="message:InternetMessageId"/>
|
||||||
|
<t:FieldURI FieldURI="message:From"/>
|
||||||
|
<t:FieldURI FieldURI="item:DateTimeReceived"/>
|
||||||
|
<t:FieldURI FieldURI="item:Attachments"/>
|
||||||
|
</t:AdditionalProperties>
|
||||||
|
</m:ItemShape>
|
||||||
|
<m:ItemIds>${ids}</m:ItemIds>
|
||||||
|
</m:GetItem>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAttachmentSoap(attachmentId: string): string {
|
||||||
|
return soapEnvelope(`
|
||||||
|
<m:GetAttachment>
|
||||||
|
<m:AttachmentIds>
|
||||||
|
<t:AttachmentId Id="${escapeXml(attachmentId)}"/>
|
||||||
|
</m:AttachmentIds>
|
||||||
|
</m:GetAttachment>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── XML helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function escapeXml(s: string): string {
|
||||||
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract all text values of a tag name from an XML string (non-recursive, fast). */
|
||||||
|
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;
|
||||||
|
// Get inner text (content between > and </tag>)
|
||||||
|
const innerStart = xml.indexOf('>', start) + 1;
|
||||||
|
results.push(xml.slice(innerStart, end));
|
||||||
|
pos = end + close.length;
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract first value of attribute from a tag. */
|
||||||
|
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] : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract all matching tag attributes from repeated elements. */
|
||||||
|
function extractAttrs(xml: string, tag: string, attr: string): string[] {
|
||||||
|
const results: string[] = [];
|
||||||
|
const open = `<${tag}`;
|
||||||
|
let pos = 0;
|
||||||
|
while (pos < xml.length) {
|
||||||
|
const start = xml.indexOf(open, pos);
|
||||||
|
if (start === -1) break;
|
||||||
|
const tagEnd = xml.indexOf('>', start);
|
||||||
|
const tagStr = xml.slice(start, tagEnd + 1);
|
||||||
|
const match = tagStr.match(new RegExp(`${attr}="([^"]*)"`));
|
||||||
|
if (match) results.push(match[1]);
|
||||||
|
pos = tagEnd + 1;
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map configured folder name to EWS DistinguishedFolderId. */
|
||||||
|
function resolveDistinguishedFolder(folder?: string): string {
|
||||||
|
const name = (folder ?? 'INBOX').toLowerCase().replace(/[\s_-]/g, '');
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
inbox: 'inbox',
|
||||||
|
posteingang: 'inbox',
|
||||||
|
deleteditems: 'deleteditems',
|
||||||
|
gelöschteelemente: 'deleteditems',
|
||||||
|
trash: 'deleteditems',
|
||||||
|
sentitems: 'sentitems',
|
||||||
|
gesendet: 'sentitems',
|
||||||
|
drafts: 'drafts',
|
||||||
|
entwürfe: 'drafts',
|
||||||
|
junk: 'junkemail',
|
||||||
|
junkemail: 'junkemail',
|
||||||
|
spam: 'junkemail',
|
||||||
|
};
|
||||||
|
return map[name] ?? 'inbox';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── NTLM HTTP helper ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface NtlmOptions {
|
||||||
|
url: string;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
domain: string;
|
||||||
|
workstation: string;
|
||||||
|
body: string;
|
||||||
|
headers: Record<string, string>;
|
||||||
|
rejectUnauthorized?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ntlmPost(opts: NtlmOptions): Promise<{ statusCode: number; body: string }> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
(httpntlm as any).post(opts, (err: Error | null, res: any) => {
|
||||||
|
if (err) return reject(err);
|
||||||
|
resolve({ statusCode: res.statusCode, body: res.body?.toString('utf-8') ?? '' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Provider ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exchange inbox provider using ews-javascript-api.
|
* ExchangeInboxProvider — NTLM-authenticated EWS via raw SOAP over httpntlm.
|
||||||
*
|
*
|
||||||
* Implements InboxProvider so it is interchangeable with ImapProvider.
|
* Replaces the ews-javascript-api approach which only supports Basic Auth.
|
||||||
*
|
* Uses httpntlm to perform the NTLM challenge-response handshake transparently.
|
||||||
* Mirrors the ExchangeProvider calendar pattern (apps/api/src/calendar/providers/exchange.provider.ts)
|
|
||||||
* but uses email-specific EWS item types instead of calendar types.
|
|
||||||
*
|
*
|
||||||
* Security:
|
* Security:
|
||||||
* - T-07-03: Error messages are generic — credentials never appear in logs
|
* - T-07-03: credentials never logged — only generic error messages
|
||||||
* - T-07-05: Attachment size is checked before buffering (PDF-bomb mitigation)
|
* - T-07-05: attachment size checked before buffering (PDF-bomb mitigation)
|
||||||
*
|
* - EWS XML is escaped before insertion into SOAP envelopes
|
||||||
* Pitfall 6 avoidance:
|
|
||||||
* - Uses WellKnownFolderName.Inbox + FindItems + EmailMessage.Bind
|
|
||||||
* - Does NOT use FindAppointments / CalendarView (those are calendar-only EWS APIs)
|
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ExchangeInboxProvider implements InboxProvider {
|
export class ExchangeInboxProvider implements InboxProvider {
|
||||||
private readonly logger = new Logger(ExchangeInboxProvider.name);
|
private readonly logger = new Logger(ExchangeInboxProvider.name);
|
||||||
|
|
||||||
/**
|
|
||||||
* Connects to Exchange via EWS, searches the Inbox for emails from the
|
|
||||||
* configured sender, and downloads PDF attachments.
|
|
||||||
*
|
|
||||||
* @param config Decrypted inbox connection parameters
|
|
||||||
* @returns Emails with PDF attachments as Buffers (empty array on error)
|
|
||||||
*/
|
|
||||||
async fetchPdfAttachments(config: InboxConfig): Promise<InboxEmail[]> {
|
async fetchPdfAttachments(config: InboxConfig): Promise<InboxEmail[]> {
|
||||||
try {
|
try {
|
||||||
return await this.fetchViaEws(config);
|
return await this.fetchViaEws(config);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// T-07-03: generic error message — no credential details in log
|
this.logger.error(`EWS inbox fetch failed: ${(error as Error).message}`);
|
||||||
this.logger.error(
|
|
||||||
`EWS inbox fetch failed: ${(error as Error).message}`,
|
|
||||||
);
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Tests whether the Exchange connection can be established.
|
|
||||||
* Performs a minimal FindItems call on the Inbox with a result limit of 1.
|
|
||||||
*
|
|
||||||
* @param config Decrypted inbox connection parameters
|
|
||||||
* @returns true on success, false on any auth/network failure
|
|
||||||
*/
|
|
||||||
async testConnection(config: InboxConfig): Promise<{ success: boolean; message?: string }> {
|
async testConnection(config: InboxConfig): Promise<{ success: boolean; message?: string }> {
|
||||||
try {
|
try {
|
||||||
// Dynamic import — ews-javascript-api is JS-only, no .d.ts (follows ExchangeProvider pattern)
|
const folder = resolveDistinguishedFolder(config.folder);
|
||||||
const ews: any = await import('ews-javascript-api');
|
const soap = findItemSoap(folder, 1);
|
||||||
const service = this.buildService(ews, config);
|
const res = await this.ewsPost(config, soap, 'FindItem');
|
||||||
|
|
||||||
// Minimal FindItems — just confirm we can connect
|
if (res.statusCode === 401) {
|
||||||
const itemView = new ews.ItemView(1);
|
return { success: false, message: '401 Unauthorized — credentials rejected or NTLM not allowed' };
|
||||||
const folder = this.resolveFolder(ews, config.folder);
|
}
|
||||||
await service.FindItems(folder, itemView);
|
if (res.statusCode !== 200) {
|
||||||
|
return { success: false, message: `HTTP ${res.statusCode}` };
|
||||||
|
}
|
||||||
|
if (res.body.includes('ResponseClass="Error"')) {
|
||||||
|
const msg = extractAll(res.body, 'm:MessageText')[0] ?? extractAll(res.body, 'MessageText')[0] ?? 'EWS error';
|
||||||
|
return { success: false, message: msg };
|
||||||
|
}
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = (err as Error).message;
|
const message = (err as Error).message;
|
||||||
// T-07-03: log without credentials; return message to admin for diagnosis
|
|
||||||
this.logger.error(`EWS connection test failed: ${message}`);
|
this.logger.error(`EWS connection test failed: ${message}`);
|
||||||
return { success: false, message };
|
return { success: false, message };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ─── Private ───────────────────────────────────────────────────────────────
|
||||||
* Internal EWS fetch implementation.
|
|
||||||
* Separated from fetchPdfAttachments so the try/catch wraps the entire flow.
|
|
||||||
*/
|
|
||||||
private async fetchViaEws(config: InboxConfig): Promise<InboxEmail[]> {
|
private async fetchViaEws(config: InboxConfig): Promise<InboxEmail[]> {
|
||||||
// Dynamic import — ews-javascript-api is JS-only, no .d.ts
|
const folder = resolveDistinguishedFolder(config.folder);
|
||||||
// Follows the same pattern as ExchangeProvider (calendar) lines 154–155
|
|
||||||
const ews: any = await import('ews-javascript-api');
|
|
||||||
const service = this.buildService(ews, config);
|
|
||||||
|
|
||||||
// Pitfall 6: use WellKnownFolderName with FindItems (NOT FindAppointments)
|
// 1. FindItem — get IDs of emails with attachments
|
||||||
// FindAppointments is Calendar-only — inbox emails use FindItems
|
const findSoap = findItemSoap(folder, 50, config.senderFilter);
|
||||||
const itemView = new ews.ItemView(50);
|
const findRes = await this.ewsPost(config, findSoap, 'FindItem');
|
||||||
const folder = this.resolveFolder(ews, config.folder);
|
if (findRes.statusCode !== 200) {
|
||||||
|
this.logger.warn(`EWS FindItem returned HTTP ${findRes.statusCode}`);
|
||||||
let findResults: any;
|
return [];
|
||||||
if (config.senderFilter) {
|
|
||||||
// Filter server-side by sender address (reduces data transfer)
|
|
||||||
const senderFilter = new ews.SearchFilter.ContainsSubstring(
|
|
||||||
ews.EmailMessageSchema.From,
|
|
||||||
config.senderFilter,
|
|
||||||
);
|
|
||||||
findResults = await service.FindItems(folder, senderFilter, itemView);
|
|
||||||
} else {
|
|
||||||
findResults = await service.FindItems(folder, itemView);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse item IDs and HasAttachments flag from FindItem response
|
||||||
|
const rawIds = extractAttrs(findRes.body, 't:ItemId', 'Id');
|
||||||
|
if (rawIds.length === 0) return [];
|
||||||
|
|
||||||
|
// Only fetch items that have attachments
|
||||||
|
const hasAttachFlags = extractAll(findRes.body, 't:HasAttachments');
|
||||||
|
const itemIds = rawIds.filter((_, i) => hasAttachFlags[i] === 'true');
|
||||||
|
if (itemIds.length === 0) return [];
|
||||||
|
|
||||||
const results: InboxEmail[] = [];
|
const results: InboxEmail[] = [];
|
||||||
if (!findResults?.Items?.length) {
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bind each email to load properties including attachments
|
// 2. GetItem in batches of 10 to load attachment metadata
|
||||||
// EmailMessage.Bind loads the full message with all properties
|
for (let i = 0; i < itemIds.length; i += 10) {
|
||||||
const propertySet = new ews.PropertySet(
|
const batch = itemIds.slice(i, i + 10);
|
||||||
ews.BasePropertySet.FirstClassProperties,
|
const getRes = await this.ewsPost(config, getItemSoap(batch), 'GetItem');
|
||||||
ews.EmailMessageSchema.Attachments,
|
if (getRes.statusCode !== 200) continue;
|
||||||
);
|
|
||||||
|
|
||||||
for (const item of findResults.Items) {
|
// Parse each Message element from GetItem response
|
||||||
let message: any;
|
const messageBlocks = this.splitMessageBlocks(getRes.body);
|
||||||
try {
|
|
||||||
message = await ews.EmailMessage.Bind(service, item.Id, propertySet);
|
|
||||||
} catch (bindErr) {
|
|
||||||
this.logger.warn(
|
|
||||||
`Failed to bind EmailMessage (id: ${String(item.Id?.UniqueId ?? 'unknown')}): ${(bindErr as Error).message}`,
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter client-side by sender if not already filtered server-side
|
for (const block of messageBlocks) {
|
||||||
// (server-side ContainsSubstring may not be case-sensitive on all servers)
|
const uid = extractAttr(block, 't:ItemId', 'Id');
|
||||||
if (config.senderFilter && message.From?.Address) {
|
const subject = extractAll(block, 't:Subject')[0] ?? '';
|
||||||
const senderLower = String(message.From.Address).toLowerCase();
|
const messageId = extractAll(block, 't:InternetMessageId')[0] ?? '';
|
||||||
if (!senderLower.includes(config.senderFilter.toLowerCase())) {
|
const from = (extractAttr(block, 't:Mailbox', 'SmtpAddress') ||
|
||||||
|
extractAll(block, 't:EmailAddress')[0]) ?? '';
|
||||||
|
const dateStr = extractAll(block, 't:DateTimeReceived')[0] ?? '';
|
||||||
|
const date = dateStr ? new Date(dateStr) : new Date();
|
||||||
|
|
||||||
|
// Filter by sender if provided (client-side fallback for case-sensitivity)
|
||||||
|
if (config.senderFilter && from &&
|
||||||
|
!from.toLowerCase().includes(config.senderFilter.toLowerCase())) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Collect PDF attachment IDs
|
||||||
|
const attachmentIds = extractAttrs(block, 't:FileAttachment', 'Id')
|
||||||
|
.filter((_, idx) => {
|
||||||
|
const types = extractAll(block, 't:ContentType');
|
||||||
|
return (types[idx] ?? '').toLowerCase().includes('pdf');
|
||||||
|
});
|
||||||
|
|
||||||
|
if (attachmentIds.length === 0) continue;
|
||||||
|
|
||||||
|
// 3. GetAttachment for each PDF
|
||||||
|
const attachments: InboxAttachment[] = [];
|
||||||
|
for (const attId of attachmentIds) {
|
||||||
|
const attRes = await this.ewsPost(config, getAttachmentSoap(attId), 'GetAttachment');
|
||||||
|
if (attRes.statusCode !== 200) continue;
|
||||||
|
|
||||||
|
const name = extractAll(attRes.body, 't:Name')[0] ?? 'attachment.pdf';
|
||||||
|
const content = extractAll(attRes.body, 't:Content')[0] ?? '';
|
||||||
|
if (!content) continue;
|
||||||
|
|
||||||
|
const buffer = Buffer.from(content, 'base64');
|
||||||
|
if (buffer.length > MAX_ATTACHMENT_BYTES) {
|
||||||
|
this.logger.warn(`Skipped EWS attachment "${name}" — exceeds size limit (T-07-05)`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
attachments.push({ filename: name, contentType: 'application/pdf', buffer });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attachments.length === 0) continue;
|
||||||
|
|
||||||
|
results.push({ uid, messageId, subject, from, date, attachments });
|
||||||
}
|
}
|
||||||
|
|
||||||
const pdfAttachments = await this.extractPdfAttachments(message);
|
|
||||||
if (pdfAttachments.length === 0) continue;
|
|
||||||
|
|
||||||
results.push({
|
|
||||||
uid: String(item.Id?.UniqueId ?? String(Date.now())),
|
|
||||||
messageId: String(message.InternetMessageId ?? ''),
|
|
||||||
subject: String(message.Subject ?? ''),
|
|
||||||
from: String(message.From?.Address ?? ''),
|
|
||||||
date: message.DateTimeReceived
|
|
||||||
? new Date(String(message.DateTimeReceived))
|
|
||||||
: new Date(),
|
|
||||||
attachments: pdfAttachments,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Split a GetItem response body into per-message XML blocks. */
|
||||||
* Extracts and downloads PDF FileAttachments from an EmailMessage.
|
private splitMessageBlocks(xml: string): string[] {
|
||||||
* Enforces MAX_ATTACHMENT_BYTES before buffering (T-07-05).
|
const blocks: string[] = [];
|
||||||
*/
|
const open = '<m:Items>';
|
||||||
private async extractPdfAttachments(
|
const close = '</m:Items>';
|
||||||
message: any,
|
let pos = 0;
|
||||||
): Promise<Array<{ filename: string; contentType: string; buffer: Buffer }>> {
|
while (pos < xml.length) {
|
||||||
const attachments = message.Attachments;
|
const start = xml.indexOf(open, pos);
|
||||||
if (!attachments?.Count) return [];
|
if (start === -1) break;
|
||||||
|
const end = xml.indexOf(close, start);
|
||||||
const pdfs: Array<{ filename: string; contentType: string; buffer: Buffer }> = [];
|
if (end === -1) break;
|
||||||
|
blocks.push(xml.slice(start + open.length, end));
|
||||||
for (let i = 0; i < attachments.Count; i++) {
|
pos = end + close.length;
|
||||||
const attachment = attachments.GetAttachment(i);
|
|
||||||
|
|
||||||
// Only process FileAttachments (not ItemAttachments which are embedded messages)
|
|
||||||
if (!attachment || !attachment.ContentType) continue;
|
|
||||||
|
|
||||||
const contentType: string = String(attachment.ContentType).toLowerCase();
|
|
||||||
if (contentType !== 'application/pdf') continue;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Load attachment content from Exchange server
|
|
||||||
await attachment.Load();
|
|
||||||
|
|
||||||
const content: Buffer | Uint8Array | null = attachment.Content;
|
|
||||||
if (!content) continue;
|
|
||||||
|
|
||||||
// T-07-05: enforce max attachment size before buffering
|
|
||||||
if (content.length > MAX_ATTACHMENT_BYTES) {
|
|
||||||
this.logger.warn(
|
|
||||||
`Skipped EWS PDF attachment "${String(attachment.Name ?? 'unknown')}" — exceeds ${MAX_ATTACHMENT_BYTES} bytes (T-07-05)`,
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const buffer = Buffer.isBuffer(content)
|
|
||||||
? content
|
|
||||||
: Buffer.from(content);
|
|
||||||
|
|
||||||
pdfs.push({
|
|
||||||
filename: String(attachment.Name ?? `attachment-${i}.pdf`),
|
|
||||||
contentType: 'application/pdf',
|
|
||||||
buffer,
|
|
||||||
});
|
|
||||||
} catch (loadErr) {
|
|
||||||
// T-07-03: generic warning — no credential or content details
|
|
||||||
this.logger.warn(
|
|
||||||
`Failed to load EWS attachment "${String(attachment.Name ?? 'unknown')}": ${(loadErr as Error).message}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
// If no <m:Items> blocks, treat whole response as one block
|
||||||
return pdfs;
|
return blocks.length > 0 ? blocks : [xml];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** POST a SOAP body to the EWS endpoint using NTLM authentication. */
|
||||||
* Constructs and configures an ExchangeService from InboxConfig.
|
private async ewsPost(
|
||||||
* Follows the same pattern as ExchangeProvider.fetchViaEws() lines 155–163.
|
config: InboxConfig,
|
||||||
*/
|
soap: string,
|
||||||
private buildService(ews: any, config: InboxConfig): any {
|
action: string,
|
||||||
const service = new ews.ExchangeService(ews.ExchangeVersion.Exchange2013);
|
): Promise<{ statusCode: number; body: string }> {
|
||||||
service.Url = new ews.Uri(config.host);
|
const opts: NtlmOptions = {
|
||||||
service.Credentials = new ews.WebCredentials(
|
url: config.host, // must be full EWS URL: https://server/EWS/Exchange.asmx
|
||||||
config.username ?? '',
|
username: config.username ?? '',
|
||||||
config.password ?? '',
|
password: config.password ?? '',
|
||||||
config.domain ?? undefined,
|
domain: config.domain ?? '',
|
||||||
);
|
workstation: '',
|
||||||
return service;
|
body: soap,
|
||||||
}
|
headers: {
|
||||||
|
'Content-Type': 'text/xml; charset=utf-8',
|
||||||
/**
|
'SOAPAction': `"http://schemas.microsoft.com/exchange/services/2006/messages/${action}"`,
|
||||||
* Maps a folder name string to an EWS WellKnownFolderName enum value.
|
},
|
||||||
* Supports common German and English names. Defaults to Inbox.
|
|
||||||
*/
|
|
||||||
private resolveFolder(ews: any, folder?: string): any {
|
|
||||||
const name = (folder ?? 'INBOX').toLowerCase().replace(/[\s_-]/g, '');
|
|
||||||
const map: Record<string, string> = {
|
|
||||||
inbox: 'Inbox',
|
|
||||||
posteingang: 'Inbox',
|
|
||||||
deleteditems: 'DeletedItems',
|
|
||||||
gelöschteelemente: 'DeletedItems',
|
|
||||||
trash: 'DeletedItems',
|
|
||||||
sentitems: 'SentItems',
|
|
||||||
gesendet: 'SentItems',
|
|
||||||
drafts: 'Drafts',
|
|
||||||
entwürfe: 'Drafts',
|
|
||||||
junk: 'JunkEmail',
|
|
||||||
junkemail: 'JunkEmail',
|
|
||||||
spam: 'JunkEmail',
|
|
||||||
};
|
};
|
||||||
const key = map[name] ?? 'Inbox';
|
return ntlmPost(opts);
|
||||||
return ews.WellKnownFolderName[key] ?? ews.WellKnownFolderName.Inbox;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+25
@@ -74,6 +74,9 @@ importers:
|
|||||||
ews-javascript-api:
|
ews-javascript-api:
|
||||||
specifier: 0.15.3
|
specifier: 0.15.3
|
||||||
version: 0.15.3
|
version: 0.15.3
|
||||||
|
httpntlm:
|
||||||
|
specifier: ^1.8.13
|
||||||
|
version: 1.8.13
|
||||||
imapflow:
|
imapflow:
|
||||||
specifier: ^1.4.3
|
specifier: ^1.4.3
|
||||||
version: 1.4.3
|
version: 1.4.3
|
||||||
@@ -3069,6 +3072,14 @@ packages:
|
|||||||
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
|
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
|
|
||||||
|
httpntlm@1.8.13:
|
||||||
|
resolution: {integrity: sha512-2F2FDPiWT4rewPzNMg3uPhNkP3NExENlUGADRUDPQvuftuUTGW98nLZtGemCIW3G40VhWZYgkIDcQFAwZ3mf2Q==}
|
||||||
|
engines: {node: '>=10.4.0'}
|
||||||
|
|
||||||
|
httpreq@1.1.1:
|
||||||
|
resolution: {integrity: sha512-uhSZLPPD2VXXOSN8Cni3kIsoFHaU2pT/nySEU/fHr/ePbqHYr0jeiQRmUKLEirC09SFPsdMoA7LU7UXMd/w0Kw==}
|
||||||
|
engines: {node: '>= 6.15.1'}
|
||||||
|
|
||||||
https-proxy-agent@5.0.1:
|
https-proxy-agent@5.0.1:
|
||||||
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
|
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
|
||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
@@ -4974,6 +4985,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==}
|
resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
underscore@1.12.1:
|
||||||
|
resolution: {integrity: sha512-hEQt0+ZLDVUMhebKxL4x1BTtDY7bavVofhZ9KZ4aI26X9SRaE+Y3m83XUL1UP2jn8ynjndwCCpEHdUG+9pP1Tw==}
|
||||||
|
|
||||||
undici-types@6.21.0:
|
undici-types@6.21.0:
|
||||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||||
|
|
||||||
@@ -8192,6 +8206,15 @@ snapshots:
|
|||||||
statuses: 2.0.2
|
statuses: 2.0.2
|
||||||
toidentifier: 1.0.1
|
toidentifier: 1.0.1
|
||||||
|
|
||||||
|
httpntlm@1.8.13:
|
||||||
|
dependencies:
|
||||||
|
des.js: 1.1.0
|
||||||
|
httpreq: 1.1.1
|
||||||
|
js-md4: 0.3.2
|
||||||
|
underscore: 1.12.1
|
||||||
|
|
||||||
|
httpreq@1.1.1: {}
|
||||||
|
|
||||||
https-proxy-agent@5.0.1:
|
https-proxy-agent@5.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
agent-base: 6.0.2
|
agent-base: 6.0.2
|
||||||
@@ -10907,6 +10930,8 @@ snapshots:
|
|||||||
|
|
||||||
uint8array-extras@1.5.0: {}
|
uint8array-extras@1.5.0: {}
|
||||||
|
|
||||||
|
underscore@1.12.1: {}
|
||||||
|
|
||||||
undici-types@6.21.0: {}
|
undici-types@6.21.0: {}
|
||||||
|
|
||||||
undici@6.27.0:
|
undici@6.27.0:
|
||||||
|
|||||||
Reference in New Issue
Block a user