feat(07-02): ExchangeInboxProvider — EWS email inbox access
Tessera CI/CD / Build & Deploy (push) Blocked by required conditions
Tessera CI/CD / Lint & Type Check (push) Successful in 41s
Tessera CI/CD / Tests (push) Waiting to run

- 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:
2026-06-27 00:03:56 +02:00
parent b3f21a8747
commit 924de76f93
@@ -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;
}
}