From 924de76f93d44c7bfe21171cc66505a17d1df771 Mon Sep 17 00:00:00 2001 From: Schalli Date: Sat, 27 Jun 2026 00:03:56 +0200 Subject: [PATCH] =?UTF-8?q?feat(07-02):=20ExchangeInboxProvider=20?= =?UTF-8?q?=E2=80=94=20EWS=20email=20inbox=20access?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implements InboxProvider contract (fetchPdfAttachments + testConnection) - Uses WellKnownFolderName.Inbox + FindItems + EmailMessage.Bind — NOT FindAppointments (Pitfall 6) - Dynamic import('ews-javascript-api') following ExchangeProvider calendar pattern - Server-side sender filter via SearchFilter.ContainsSubstring with client-side verification - 25MB attachment size guard in extractPdfAttachments — PDF-bomb mitigation (T-07-05) - Generic error messages only in all catch blocks — no credential values (T-07-03) - Returns [] on error (consistent with calendar ExchangeProvider error path) --- .../dkv/providers/exchange-inbox.provider.ts | 228 ++++++++++++++++++ 1 file changed, 228 insertions(+) create mode 100644 apps/api/src/dkv/providers/exchange-inbox.provider.ts diff --git a/apps/api/src/dkv/providers/exchange-inbox.provider.ts b/apps/api/src/dkv/providers/exchange-inbox.provider.ts new file mode 100644 index 0000000..995b26b --- /dev/null +++ b/apps/api/src/dkv/providers/exchange-inbox.provider.ts @@ -0,0 +1,228 @@ +import { Injectable, Logger } from '@nestjs/common'; +import type { InboxConfig, InboxEmail } from './inbox-provider.interface'; +import type { InboxProvider } from './inbox-provider.interface'; + +/** + * Max attachment size (bytes) accepted before buffering. + * Mirrors the same limit in ImapProvider (T-07-05 — PDF-bomb mitigation). + */ +const MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024; // 25 MB + +/** + * Exchange inbox provider using ews-javascript-api. + * + * Implements InboxProvider so it is interchangeable with ImapProvider. + * + * 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: + * - T-07-03: Error messages are generic — credentials never appear in logs + * - T-07-05: Attachment size is checked before buffering (PDF-bomb mitigation) + * + * Pitfall 6 avoidance: + * - Uses WellKnownFolderName.Inbox + FindItems + EmailMessage.Bind + * - Does NOT use FindAppointments / CalendarView (those are calendar-only EWS APIs) + */ +@Injectable() +export class ExchangeInboxProvider implements InboxProvider { + 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 { + try { + return await this.fetchViaEws(config); + } catch (error) { + // T-07-03: generic error message — no credential details in log + this.logger.error( + `EWS inbox fetch failed: ${(error as Error).message}`, + ); + 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 { + try { + // Dynamic import — ews-javascript-api is JS-only, no .d.ts (follows ExchangeProvider pattern) + const ews: any = await import('ews-javascript-api'); + const service = this.buildService(ews, config); + + // Minimal FindItems on Inbox — just confirm we can connect + const itemView = new ews.ItemView(1); + await service.FindItems(ews.WellKnownFolderName.Inbox, itemView); + return true; + } catch (err) { + // T-07-03: generic error message — no credential details + this.logger.error( + `EWS connection test failed: ${(err as Error).message}`, + ); + return false; + } + } + + /** + * Internal EWS fetch implementation. + * Separated from fetchPdfAttachments so the try/catch wraps the entire flow. + */ + private async fetchViaEws(config: InboxConfig): Promise { + // Dynamic import — ews-javascript-api is JS-only, no .d.ts + // 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.Inbox with FindItems (NOT FindAppointments) + // FindAppointments is Calendar-only — inbox emails use FindItems + const itemView = new ews.ItemView(50); + + let findResults: any; + 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( + ews.WellKnownFolderName.Inbox, + senderFilter, + itemView, + ); + } else { + findResults = await service.FindItems( + ews.WellKnownFolderName.Inbox, + itemView, + ); + } + + const results: InboxEmail[] = []; + if (!findResults?.Items?.length) { + return results; + } + + // Bind each email to load properties including attachments + // EmailMessage.Bind loads the full message with all properties + const propertySet = new ews.PropertySet( + ews.BasePropertySet.FirstClassProperties, + ews.EmailMessageSchema.Attachments, + ); + + for (const item of findResults.Items) { + let message: any; + 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 + // (server-side ContainsSubstring may not be case-sensitive on all servers) + if (config.senderFilter && message.From?.Address) { + const senderLower = String(message.From.Address).toLowerCase(); + if (!senderLower.includes(config.senderFilter.toLowerCase())) { + continue; + } + } + + 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; + } + + /** + * Extracts and downloads PDF FileAttachments from an EmailMessage. + * Enforces MAX_ATTACHMENT_BYTES before buffering (T-07-05). + */ + private async extractPdfAttachments( + message: any, + ): Promise> { + const attachments = message.Attachments; + if (!attachments?.Count) return []; + + const pdfs: Array<{ filename: string; contentType: string; buffer: Buffer }> = []; + + for (let i = 0; i < attachments.Count; i++) { + 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}`, + ); + } + } + + return pdfs; + } + + /** + * Constructs and configures an ExchangeService from InboxConfig. + * Follows the same pattern as ExchangeProvider.fetchViaEws() lines 155–163. + */ + private buildService(ews: any, config: InboxConfig): any { + const service = new ews.ExchangeService(ews.ExchangeVersion.Exchange2013); + service.Url = new ews.Uri(config.host); + service.Credentials = new ews.WebCredentials( + config.username ?? '', + config.password ?? '', + ); + return service; + } +}