Files
tessera-ctl/apps/api/src/dkv/providers/exchange-inbox.provider.ts
T
schalli 01ff137f43
Tessera CI/CD / Lint & Type Check (push) Waiting to run
Tessera CI/CD / Tests (push) Blocked by required conditions
Tessera CI/CD / Build & Deploy (push) Blocked by required conditions
fix(07): UAT fixes — SMTP test email, DKV routing, Exchange domain field
- 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>
2026-06-28 21:58:26 +02:00

230 lines
8.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
}