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 ?? '', config.domain ?? undefined, ); return service; } }