01ff137f43
- SMTP: add test-to field, send real email via sendMail() instead of verify() - SMTP: fix no_auth warning shown as error for open-relay servers - DKV: register dkv-fleet in MODULE_REGISTRY (fixes "Modul nicht gefunden") - DKV: add dynamic [category]/[moduleSlug]/settings + vehicles sub-routes - DKV: settings/vehicles links use useParams for consistent URLs - DKV: add gear icon settings link to module main page - DKV: rename module to "DKV-Rechnung" in seed + translations - DKV: add optional domain field for Exchange (WebCredentials 3rd arg) - DKV: hide IMAP-only fields (Port/Verschlüsselung/Ordner) when Exchange selected - DKV: hide Exchange-only field (Domain) when IMAP selected - Prisma: add domain column to DkvModuleConfig Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
230 lines
8.0 KiB
TypeScript
230 lines
8.0 KiB
TypeScript
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 ?? '',
|
||
config.domain ?? undefined,
|
||
);
|
||
return service;
|
||
}
|
||
}
|