diff --git a/apps/api/src/inbox/exchange-inbox.provider.spec.ts b/apps/api/src/inbox/exchange-inbox.provider.spec.ts
new file mode 100644
index 0000000..05d4625
--- /dev/null
+++ b/apps/api/src/inbox/exchange-inbox.provider.spec.ts
@@ -0,0 +1,207 @@
+import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+import type { ExchangeInboxProvider as ExchangeInboxProviderType } from './exchange-inbox.provider';
+import type { InboxConfig } from './inbox.types';
+
+/**
+ * ExchangeInboxProvider.fetchMessages spec (Plan 14-01, Task 2, TDD).
+ *
+ * Net-new coverage for the additive fetchMessages() method (D-02) — the seam
+ * the Plan 14-03 EmailAlertAdapter consumes. fetchPdfAttachments is NOT
+ * touched or retested here.
+ *
+ * httpntlm is loaded via a raw CommonJS `require('httpntlm')` inside
+ * exchange-inbox.provider.ts (not an ES import) — this bypasses Vitest's
+ * `vi.mock` ESM-graph interception entirely (verified: `vi.mock('httpntlm',
+ * ...)` has zero effect on a `require('httpntlm')` call, since Node's real
+ * `require` executes it directly against the module cache). Instead, this
+ * spec pre-seeds Node's own `require.cache` for the resolved httpntlm path
+ * with a stub `{ post }` BEFORE the provider module is first loaded (in
+ * `beforeAll`, via a dynamic `import()` — kept out of top-level module scope
+ * so this file stays compatible with the project's commonjs tsconfig, which
+ * rejects top-level `await`/`import.meta`), so the provider's own
+ * `require('httpntlm')` call resolves to the stub.
+ */
+
+const httpntlmPath = require.resolve('httpntlm');
+
+const httpntlmPost = vi.fn(
+ (_opts: any, cb: (err: Error | null, res: any) => void) => {
+ cb(new Error('httpntlmPost not configured for this test'), null);
+ },
+);
+
+require.cache[httpntlmPath] = {
+ id: httpntlmPath,
+ filename: httpntlmPath,
+ loaded: true,
+ exports: { post: httpntlmPost },
+} as any;
+
+let ExchangeInboxProvider: typeof ExchangeInboxProviderType;
+
+beforeAll(async () => {
+ ({ ExchangeInboxProvider } = await import('./exchange-inbox.provider'));
+});
+
+const BASE_CONFIG: InboxConfig = {
+ protocol: 'exchange',
+ host: 'https://mail.example.com/EWS/Exchange.asmx',
+ port: 443,
+ username: 'alerts',
+ password: 'secret',
+ encryption: 'ssl-tls',
+ folder: 'INBOX',
+ domain: 'CONTOSO',
+};
+
+function soapResponse(statusCode: number, body: string) {
+ return { statusCode, body: Buffer.from(body, 'utf-8') };
+}
+
+const FIND_ITEM_RESPONSE = `
+
+
+
+
+
+
+ false
+
+
+
+
+`;
+
+function getItemHtmlResponse(): string {
+ return `
+
+
+
+
+
+ Neue Ausschreibung verfuegbar
+ msg-1@example.com
+ noreply@vergabeportal.de
+ 2026-07-20T08:00:00Z
+ Hallo HTML Body
+
+
+
+`;
+}
+
+function getItemTextResponse(): string {
+ return `
+
+
+
+
+
+ Neue Ausschreibung verfuegbar
+ msg-1@example.com
+ noreply@vergabeportal.de
+ 2026-07-20T08:00:00Z
+ Hallo Text Body
+
+
+
+`;
+}
+
+/** Routes the stubbed httpntlm.post by the SOAPAction header, mirroring ewsPost's per-action dispatch. */
+function mockEwsFlow(getItemResponseBody: string) {
+ httpntlmPost.mockImplementation((opts: any, cb: (err: Error | null, res: any) => void) => {
+ const action = opts.headers.SOAPAction as string;
+ if (action.includes('FindItem')) {
+ cb(null, soapResponse(200, FIND_ITEM_RESPONSE));
+ } else if (action.includes('GetItem')) {
+ cb(null, soapResponse(200, getItemResponseBody));
+ } else if (action.includes('UpdateItem')) {
+ cb(null, soapResponse(200, ''));
+ } else {
+ cb(null, soapResponse(200, ''));
+ }
+ });
+}
+
+beforeEach(() => {
+ httpntlmPost.mockClear();
+});
+
+describe('ExchangeInboxProvider.fetchMessages', () => {
+ it('a BodyType="HTML" GetItem response yields bodyHtml populated and bodyText empty', async () => {
+ mockEwsFlow(getItemHtmlResponse());
+ const provider = new ExchangeInboxProvider();
+
+ const messages = await provider.fetchMessages(BASE_CONFIG);
+
+ expect(messages).toHaveLength(1);
+ expect(messages[0]).toMatchObject({
+ uid: 'AAA1',
+ messageId: 'msg-1@example.com',
+ subject: 'Neue Ausschreibung verfuegbar',
+ from: 'noreply@vergabeportal.de',
+ bodyHtml: 'Hallo HTML Body',
+ bodyText: '',
+ });
+ });
+
+ it('a BodyType="Text" GetItem response yields bodyText populated and bodyHtml null', async () => {
+ mockEwsFlow(getItemTextResponse());
+ const provider = new ExchangeInboxProvider();
+
+ const messages = await provider.fetchMessages(BASE_CONFIG);
+
+ expect(messages).toHaveLength(1);
+ expect(messages[0]!.bodyHtml).toBeNull();
+ expect(messages[0]!.bodyText).toBe('Hallo Text Body');
+ });
+
+ it('marks the item read (IsRead) like fetchPdfAttachments', async () => {
+ mockEwsFlow(getItemHtmlResponse());
+ const provider = new ExchangeInboxProvider();
+
+ await provider.fetchMessages(BASE_CONFIG);
+
+ const updateCall = httpntlmPost.mock.calls.find(
+ (call) => (call[0].headers.SOAPAction as string).includes('UpdateItem'),
+ );
+ expect(updateCall).toBeDefined();
+ expect(updateCall![0].body).toContain('IsRead');
+ expect(updateCall![0].body).toContain('AAA1');
+ });
+
+ it('returns [] on non-200 FindItem response, without throwing', async () => {
+ httpntlmPost.mockImplementation((_opts: any, cb: (err: Error | null, res: any) => void) => {
+ cb(null, soapResponse(500, 'Internal Server Error'));
+ });
+ const provider = new ExchangeInboxProvider();
+
+ await expect(provider.fetchMessages(BASE_CONFIG)).resolves.toEqual([]);
+ });
+
+ it('returns [] when httpntlm.post errors, without throwing', async () => {
+ httpntlmPost.mockImplementation((_opts: any, cb: (err: Error | null, res: any) => void) => {
+ cb(new Error('NTLM handshake failed'), null);
+ });
+ const provider = new ExchangeInboxProvider();
+
+ await expect(provider.fetchMessages(BASE_CONFIG)).resolves.toEqual([]);
+ });
+
+ it('returns [] when FindItem has no items', async () => {
+ httpntlmPost.mockImplementation((opts: any, cb: (err: Error | null, res: any) => void) => {
+ const action = opts.headers.SOAPAction as string;
+ if (action.includes('FindItem')) {
+ cb(null, soapResponse(200, ''));
+ } else {
+ cb(null, soapResponse(200, ''));
+ }
+ });
+ const provider = new ExchangeInboxProvider();
+
+ const messages = await provider.fetchMessages(BASE_CONFIG);
+
+ expect(messages).toEqual([]);
+ });
+});
diff --git a/apps/api/src/inbox/exchange-inbox.provider.ts b/apps/api/src/inbox/exchange-inbox.provider.ts
index 3997e29..cb8f0ee 100644
--- a/apps/api/src/inbox/exchange-inbox.provider.ts
+++ b/apps/api/src/inbox/exchange-inbox.provider.ts
@@ -1,7 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';
// eslint-disable-next-line @typescript-eslint/no-require-imports
const httpntlm = require('httpntlm') as { post: (opts: any, cb: (err: Error | null, res: any) => void) => void };
-import type { InboxAttachment, InboxConfig, InboxEmail } from './inbox-provider.interface';
+import type { InboxAttachment, InboxConfig, InboxEmail, InboxMessage } from './inbox-provider.interface';
import type { InboxProvider } from './inbox-provider.interface';
const MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024; // 25 MB — T-07-05
@@ -80,6 +80,29 @@ function getItemSoap(itemIds: string[]): string {
`);
}
+/**
+ * GetItem SOAP requesting item:Body instead of item:Attachments — used by
+ * fetchMessages (D-02) to retrieve the HTML/text body without touching the
+ * attachment-metadata path getItemSoap() serves for fetchPdfAttachments.
+ */
+function getItemBodySoap(itemIds: string[]): string {
+ const ids = itemIds.map((id) => ``).join('');
+ return soapEnvelope(`
+
+
+ IdOnly
+
+
+
+
+
+
+
+
+ ${ids}
+ `);
+}
+
function markReadSoap(itemId: string, changeKey: string): string {
return soapEnvelope(`
@@ -248,6 +271,26 @@ export class ExchangeInboxProvider implements InboxProvider {
}
}
+ /**
+ * Connects via EWS, finds unread items (optionally filtered by sender —
+ * same restriction as fetchPdfAttachments), and returns each message's
+ * subject + HTML/text body.
+ *
+ * Additive method (D-02) — does NOT touch fetchViaEws (fetchPdfAttachments'
+ * path). Marks each processed item IsRead so re-polls don't reprocess it.
+ *
+ * @param config Decrypted inbox connection parameters
+ * @returns Messages with subject + body (empty array on error)
+ */
+ async fetchMessages(config: InboxConfig): Promise {
+ try {
+ return await this.fetchMessagesViaEws(config);
+ } catch (error) {
+ this.logger.error(`EWS fetchMessages failed: ${(error as Error).message}`);
+ return [];
+ }
+ }
+
async testConnection(config: InboxConfig): Promise<{ success: boolean; message?: string }> {
try {
const folderElement = await this.resolveFolderElement(config);
@@ -411,6 +454,67 @@ export class ExchangeInboxProvider implements InboxProvider {
return results;
}
+ private async fetchMessagesViaEws(config: InboxConfig): Promise {
+ const folderElement = await this.resolveFolderElement(config);
+
+ // 1. FindItem — get IDs of unread emails (no attachment filter — unlike fetchViaEws)
+ const findSoap = findItemSoap(folderElement, 50, config.senderFilter);
+ const findRes = await this.ewsPost(config, findSoap, 'FindItem');
+ if (findRes.statusCode !== 200) {
+ this.logger.warn(`EWS FindItem (fetchMessages) returned HTTP ${findRes.statusCode}`);
+ return [];
+ }
+
+ const itemIds = extractAttrs(findRes.body, 't:ItemId', 'Id');
+ if (itemIds.length === 0) return [];
+
+ const results: InboxMessage[] = [];
+
+ // 2. GetItem in batches of 10, requesting item:Body instead of item:Attachments
+ for (let i = 0; i < itemIds.length; i += 10) {
+ const batch = itemIds.slice(i, i + 10);
+ const getRes = await this.ewsPost(config, getItemBodySoap(batch), 'GetItem');
+ if (getRes.statusCode !== 200) continue;
+
+ const messageBlocks = this.splitMessageBlocks(getRes.body);
+
+ for (const block of messageBlocks) {
+ const uid = extractAttr(block, 't:ItemId', 'Id');
+ const changeKey = extractAttr(block, 't:ItemId', 'ChangeKey');
+ const subject = extractAll(block, 't:Subject')[0] ?? '';
+ const messageId = extractAll(block, 't:InternetMessageId')[0] ?? '';
+ const from = (extractAttr(block, 't:Mailbox', 'SmtpAddress') ||
+ extractAll(block, 't:EmailAddress')[0]) ?? '';
+ const dateStr = extractAll(block, 't:DateTimeReceived')[0] ?? '';
+ const date = dateStr ? new Date(dateStr) : new Date();
+
+ // Filter by sender if provided (client-side fallback, mirrors fetchViaEws)
+ if (config.senderFilter && from &&
+ !from.toLowerCase().includes(config.senderFilter.toLowerCase())) {
+ continue;
+ }
+
+ const bodyType = extractAttr(block, 't:Body', 'BodyType');
+ const bodyContent = extractAll(block, 't:Body')[0] ?? '';
+ const bodyHtml = bodyType === 'HTML' ? bodyContent : null;
+ const bodyText = bodyType === 'Text' ? bodyContent : '';
+
+ results.push({ uid, messageId, subject, from, date, bodyHtml, bodyText });
+
+ // Mark as read so subsequent polls skip this message (mirrors fetchViaEws).
+ if (uid && changeKey) {
+ try {
+ await this.ewsPost(config, markReadSoap(uid, changeKey), 'UpdateItem');
+ } catch {
+ // Non-fatal: message will reappear on next poll but IsRead filter will catch it
+ }
+ }
+ }
+ }
+
+ return results;
+ }
+
/** Split a GetItem response body into per-message XML blocks. */
private splitMessageBlocks(xml: string): string[] {
const blocks: string[] = [];
diff --git a/apps/api/src/inbox/imap.provider.spec.ts b/apps/api/src/inbox/imap.provider.spec.ts
new file mode 100644
index 0000000..40f06c3
--- /dev/null
+++ b/apps/api/src/inbox/imap.provider.spec.ts
@@ -0,0 +1,176 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { ImapFlow } from 'imapflow';
+import { ImapProvider } from './imap.provider';
+import type { InboxConfig } from './inbox.types';
+
+/**
+ * ImapProvider.fetchMessages spec (Plan 14-01, Task 2, TDD).
+ *
+ * Net-new coverage for the additive fetchMessages() method (D-02) — the seam
+ * the Plan 14-03 EmailAlertAdapter consumes. fetchPdfAttachments is NOT
+ * touched or retested here (regression coverage lives in the DKV suite).
+ *
+ * ImapFlow is mocked entirely — no real IMAP connection. Mirrors the mock
+ * style used in tender-mail.service.spec.ts (module-level vi.mock +
+ * mockImplementation returning a stub client).
+ */
+
+vi.mock('imapflow', () => ({
+ ImapFlow: vi.fn(),
+}));
+
+function makeReadable(text: string): NodeJS.ReadableStream {
+ const { Readable } = require('stream') as typeof import('stream');
+ return Readable.from([Buffer.from(text, 'utf8')]);
+}
+
+const BASE_CONFIG: InboxConfig = {
+ protocol: 'imap',
+ host: 'imap.example.com',
+ port: 993,
+ username: 'alerts@example.com',
+ password: 'secret',
+ encryption: 'ssl-tls',
+ folder: 'INBOX',
+};
+
+/** Body structure with a text/html part (id '1') and a text/plain part (id '2'). */
+const MULTIPART_BODY_STRUCTURE = {
+ type: 'multipart/alternative',
+ childNodes: [
+ { type: 'text/plain', part: '2' },
+ { type: 'text/html', part: '1' },
+ ],
+};
+
+function makeMockClient(overrides: Partial> = {}) {
+ return {
+ connect: vi.fn().mockResolvedValue(undefined),
+ logout: vi.fn().mockResolvedValue(undefined),
+ getMailboxLock: vi.fn().mockResolvedValue({ release: vi.fn() }),
+ search: vi.fn().mockResolvedValue([42]),
+ fetchAll: vi.fn().mockResolvedValue([
+ {
+ uid: 42,
+ envelope: {
+ messageId: '',
+ subject: 'Neue Ausschreibung verfügbar',
+ from: [{ address: 'noreply@vergabeportal.de' }],
+ date: new Date('2026-07-20T08:00:00Z'),
+ },
+ bodyStructure: MULTIPART_BODY_STRUCTURE,
+ },
+ ]),
+ download: vi.fn(async (_uid: string, partId: string) => {
+ if (partId === '1') {
+ return { content: makeReadable('Hallo Welt
') };
+ }
+ return { content: makeReadable('Hallo Welt (Text)') };
+ }),
+ messageFlagsAdd: vi.fn().mockResolvedValue(undefined),
+ ...overrides,
+ };
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+});
+
+describe('ImapProvider.fetchMessages', () => {
+ it('returns one InboxMessage with bodyHtml from the html part and bodyText from the plain part', async () => {
+ const client = makeMockClient();
+ (ImapFlow as unknown as ReturnType).mockImplementation(() => client);
+ const provider = new ImapProvider();
+
+ const messages = await provider.fetchMessages(BASE_CONFIG);
+
+ expect(messages).toHaveLength(1);
+ expect(messages[0]).toMatchObject({
+ uid: 42,
+ messageId: '',
+ subject: 'Neue Ausschreibung verfügbar',
+ from: 'noreply@vergabeportal.de',
+ bodyHtml: 'Hallo Welt
',
+ bodyText: 'Hallo Welt (Text)',
+ });
+ });
+
+ it('marks each processed message \\Seen (idempotency for re-polls)', async () => {
+ const client = makeMockClient();
+ (ImapFlow as unknown as ReturnType).mockImplementation(() => client);
+ const provider = new ImapProvider();
+
+ await provider.fetchMessages(BASE_CONFIG);
+
+ expect(client.messageFlagsAdd).toHaveBeenCalledWith('42', ['\\Seen'], { uid: true });
+ });
+
+ it('honors the same UNSEEN + optional senderFilter search as fetchPdfAttachments', async () => {
+ const client = makeMockClient();
+ (ImapFlow as unknown as ReturnType).mockImplementation(() => client);
+ const provider = new ImapProvider();
+
+ await provider.fetchMessages({ ...BASE_CONFIG, senderFilter: 'vergabeportal.de' });
+
+ expect(client.search).toHaveBeenCalledWith(
+ { seen: false, from: 'vergabeportal.de' },
+ { uid: true },
+ );
+ });
+
+ it('returns [] on connect error without throwing', async () => {
+ const client = makeMockClient({
+ connect: vi.fn().mockRejectedValue(new Error('ECONNREFUSED')),
+ });
+ (ImapFlow as unknown as ReturnType).mockImplementation(() => client);
+ const provider = new ImapProvider();
+
+ await expect(provider.fetchMessages(BASE_CONFIG)).resolves.toEqual([]);
+ });
+
+ it('returns [] on search error without throwing', async () => {
+ const client = makeMockClient({
+ search: vi.fn().mockRejectedValue(new Error('search boom')),
+ });
+ (ImapFlow as unknown as ReturnType).mockImplementation(() => client);
+ const provider = new ImapProvider();
+
+ await expect(provider.fetchMessages(BASE_CONFIG)).resolves.toEqual([]);
+ expect(client.logout).toHaveBeenCalled();
+ });
+
+ it('returns [] when there are no unread messages', async () => {
+ const client = makeMockClient({ search: vi.fn().mockResolvedValue([]) });
+ (ImapFlow as unknown as ReturnType).mockImplementation(() => client);
+ const provider = new ImapProvider();
+
+ const messages = await provider.fetchMessages(BASE_CONFIG);
+
+ expect(messages).toEqual([]);
+ });
+
+ it('sets bodyHtml null and bodyText empty when the message has neither part', async () => {
+ const client = makeMockClient({
+ fetchAll: vi.fn().mockResolvedValue([
+ {
+ uid: 7,
+ envelope: {
+ messageId: '',
+ subject: 'Nur Betreff',
+ from: [{ address: 'a@b.de' }],
+ date: new Date('2026-07-20T08:00:00Z'),
+ },
+ bodyStructure: { type: 'text/x-unknown' },
+ },
+ ]),
+ });
+ (ImapFlow as unknown as ReturnType).mockImplementation(() => client);
+ const provider = new ImapProvider();
+
+ const messages = await provider.fetchMessages(BASE_CONFIG);
+
+ expect(messages).toHaveLength(1);
+ expect(messages[0]!.bodyHtml).toBeNull();
+ expect(messages[0]!.bodyText).toBe('');
+ });
+});
diff --git a/apps/api/src/inbox/imap.provider.ts b/apps/api/src/inbox/imap.provider.ts
index ae17abf..14d7fed 100644
--- a/apps/api/src/inbox/imap.provider.ts
+++ b/apps/api/src/inbox/imap.provider.ts
@@ -1,6 +1,6 @@
import { Injectable, Logger } from '@nestjs/common';
import { ImapFlow, MessageStructureObject } from 'imapflow';
-import type { InboxAttachment, InboxConfig, InboxEmail } from './inbox-provider.interface';
+import type { InboxAttachment, InboxConfig, InboxEmail, InboxMessage } from './inbox-provider.interface';
import type { InboxProvider } from './inbox-provider.interface';
/**
@@ -82,6 +82,43 @@ function collectPdfParts(
return parts;
}
+/** First-html + first-text body part IDs found in a message's MIME tree. */
+interface BodyParts {
+ htmlPart?: string;
+ textPart?: string;
+}
+
+/**
+ * Recursively walks a message's MIME structure collecting the first
+ * `text/html` and first `text/plain` part IDs (D-02 — additive sibling to
+ * collectPdfParts, does not affect the PDF-attachment path).
+ *
+ * @param node Root or child MIME structure node
+ * @param found Accumulator (pass empty object on first call)
+ */
+function findBodyParts(
+ node: MessageStructureObject | undefined,
+ found: BodyParts = {},
+): BodyParts {
+ if (!node) return found;
+
+ const type = node.type?.toLowerCase() ?? '';
+
+ if (type === 'text/html' && found.htmlPart === undefined && node.part !== undefined) {
+ found.htmlPart = node.part;
+ } else if (type === 'text/plain' && found.textPart === undefined && node.part !== undefined) {
+ found.textPart = node.part;
+ }
+
+ if (Array.isArray(node.childNodes)) {
+ for (const child of node.childNodes) {
+ findBodyParts(child, found);
+ }
+ }
+
+ return found;
+}
+
/**
* IMAP inbox provider using the imapflow library.
*
@@ -197,6 +234,105 @@ export class ImapProvider implements InboxProvider {
return results;
}
+ /**
+ * Connects to the IMAP server, searches for unread emails (optionally
+ * filtered by sender — same query as fetchPdfAttachments), and returns
+ * each message's subject + HTML/text body.
+ *
+ * Additive method (D-02) — does NOT touch fetchPdfAttachments. Marks each
+ * processed message \Seen so re-polls don't reprocess it (idempotency).
+ *
+ * @param config Decrypted inbox connection parameters
+ * @returns Messages with subject + body (empty array on error)
+ */
+ async fetchMessages(config: InboxConfig): Promise {
+ const client = this.buildClient(config);
+ const results: InboxMessage[] = [];
+
+ try {
+ await client.connect();
+ } catch (err) {
+ this.logger.error(`IMAP fetchMessages connect failed: ${(err as Error).message}`);
+ return results;
+ }
+
+ let lock: Awaited> | null = null;
+
+ try {
+ lock = await client.getMailboxLock(config.folder || 'INBOX');
+
+ const searchQuery: Record = { seen: false };
+ if (config.senderFilter) {
+ searchQuery.from = config.senderFilter;
+ }
+ const uids = await client.search(searchQuery, { uid: true });
+
+ if (!uids || uids.length === 0) {
+ return results;
+ }
+
+ // Same fetchAll-before-download ordering as fetchPdfAttachments (Pitfall 1)
+ const messages = await client.fetchAll(
+ uids.join(','),
+ { envelope: true, bodyStructure: true },
+ { uid: true },
+ );
+
+ for (const msg of messages) {
+ const { htmlPart, textPart } = findBodyParts(msg.bodyStructure);
+
+ let bodyHtml: string | null = null;
+ let bodyText = '';
+
+ if (htmlPart !== undefined) {
+ try {
+ const { content } = await client.download(String(msg.uid), htmlPart, { uid: true });
+ bodyHtml = (await streamToBuffer(content)).toString('utf8');
+ } catch (partErr) {
+ this.logger.warn(
+ `Skipped IMAP html body part for UID ${String(msg.uid)}: ${(partErr as Error).message}`,
+ );
+ }
+ }
+
+ if (textPart !== undefined) {
+ try {
+ const { content } = await client.download(String(msg.uid), textPart, { uid: true });
+ bodyText = (await streamToBuffer(content)).toString('utf8');
+ } catch (partErr) {
+ this.logger.warn(
+ `Skipped IMAP text body part for UID ${String(msg.uid)}: ${(partErr as Error).message}`,
+ );
+ }
+ }
+
+ results.push({
+ uid: msg.uid!,
+ messageId: msg.envelope?.messageId ?? '',
+ subject: msg.envelope?.subject ?? '',
+ from: msg.envelope?.from?.[0]?.address ?? '',
+ date: msg.envelope?.date ?? new Date(),
+ bodyHtml,
+ bodyText,
+ });
+
+ // Mark as read so subsequent polls skip this message (idempotency).
+ try {
+ await client.messageFlagsAdd(String(msg.uid), ['\\Seen'], { uid: true });
+ } catch {
+ // Non-fatal: message will simply appear again on next poll
+ }
+ }
+ } catch (err) {
+ this.logger.error(`IMAP fetchMessages failed: ${(err as Error).message}`);
+ } finally {
+ lock?.release();
+ await client.logout();
+ }
+
+ return results;
+ }
+
/**
* Tests whether the IMAP connection can be established.
* Connects and immediately logs out without selecting any folder.
diff --git a/apps/api/src/inbox/inbox-provider.interface.ts b/apps/api/src/inbox/inbox-provider.interface.ts
index ca0205b..1ac196d 100644
--- a/apps/api/src/inbox/inbox-provider.interface.ts
+++ b/apps/api/src/inbox/inbox-provider.interface.ts
@@ -1,7 +1,7 @@
-import { InboxAttachment, InboxConfig, InboxEmail } from './inbox.types';
+import { InboxAttachment, InboxConfig, InboxEmail, InboxMessage } from './inbox.types';
// Re-export types for downstream consumers that import from this module
-export type { InboxAttachment, InboxConfig, InboxEmail };
+export type { InboxAttachment, InboxConfig, InboxEmail, InboxMessage };
/**
* Abstract inbox provider contract — implemented by ImapProvider and
@@ -29,4 +29,17 @@ export interface InboxProvider {
* @returns { success: true } on success, { success: false, message } on failure
*/
testConnection(config: InboxConfig): Promise<{ success: boolean; message?: string }>;
+
+ /**
+ * Connects to the configured inbox, searches for unread emails (optionally
+ * filtered by sender), and returns each message's subject + HTML/text body.
+ *
+ * Additive method (D-02, Phase 14 inbox extraction) — the seam the
+ * EmailAlertAdapter (Plan 14-03) consumes. Does NOT affect
+ * fetchPdfAttachments; DKV is not switched to this method.
+ *
+ * @param config Decrypted inbox connection parameters
+ * @returns Array of messages with subject + body (empty array on error)
+ */
+ fetchMessages(config: InboxConfig): Promise;
}