feat(07-02): ExchangeInboxProvider — EWS email inbox access
- 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)
This commit is contained in:
@@ -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<InboxEmail[]> {
|
||||||
|
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<boolean> {
|
||||||
|
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<InboxEmail[]> {
|
||||||
|
// 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<Array<{ filename: string; contentType: string; buffer: Buffer }>> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user