fix(dkv): fix inbox processing pipeline — orphan tenant, MIME detection, UNSEEN filter
Tessera CI/CD / Lint & Type Check (push) Successful in 42s
Tessera CI/CD / Tests (push) Successful in 39s
Tessera CI/CD / Build & Publish Images (push) Successful in 1m37s

- Fix orphaned DkvModuleConfig: tenantId pointed to deleted tenant, updated to Default tenant
- DKV controller: return 404 instead of HTTP 200 null when no config exists
- IMAP provider: also detect PDFs sent as application/octet-stream (check filename extension)
- IMAP provider: add seen:false filter so already-processed emails are skipped on re-poll
- IMAP provider: mark email as \Seen after successful PDF download to prevent reprocessing
- Frontend dkv-api: handle 404 from fetchConfig as "not yet configured" (returns null)
- InboxConfigForm: show warning banner when config not yet saved in DB
- InboxConfigForm: add "Jetzt prüfen" button to manually trigger POST /dkv/check-now

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 08:31:36 +02:00
parent 9a652ea440
commit 9efa3bab1d
4 changed files with 91 additions and 15 deletions
+6 -2
View File
@@ -55,12 +55,16 @@ export class DkvController {
// ─── Config ──────────────────────────────────────────────────────────────── // ─── Config ────────────────────────────────────────────────────────────────
/** GET /dkv/config — returns module config without encrypted credentials. */ /** GET /dkv/config — returns module config without encrypted credentials. 404 when not yet configured. */
@Get('config') @Get('config')
@Roles(Role.ADMIN, Role.SUPER_ADMIN) @Roles(Role.ADMIN, Role.SUPER_ADMIN)
async getConfig(@Req() req: any) { async getConfig(@Req() req: any) {
const tenantId = this._requireTenant(req); const tenantId = this._requireTenant(req);
return this.dkvService.loadConfig(tenantId); const config = await this.dkvService.loadConfig(tenantId);
if (!config) {
throw new NotFoundException('DKV module not yet configured');
}
return config;
} }
/** /**
+25 -9
View File
@@ -56,11 +56,19 @@ function collectPdfParts(
): string[] { ): string[] {
if (!node) return parts; if (!node) return parts;
// Check this node — type is the full MIME type (e.g. "application/pdf") const type = node.type?.toLowerCase() ?? '';
if ( // Some mail clients (e.g. Outlook) send PDFs as application/octet-stream.
node.type?.toLowerCase() === 'application/pdf' && // Fall back to checking the filename from Content-Disposition or Content-Type parameters.
node.part !== undefined const dispositionFilename =
) { ((node as any).disposition?.parameters?.filename as string | undefined)?.toLowerCase() ?? '';
const typeFilename =
((node as any).parameters?.name as string | undefined)?.toLowerCase() ?? '';
const looksLikePdf =
type === 'application/pdf' ||
(type === 'application/octet-stream' &&
(dispositionFilename.endsWith('.pdf') || typeFilename.endsWith('.pdf')));
if (looksLikePdf && node.part !== undefined) {
parts.push(node.part); parts.push(node.part);
} }
@@ -112,10 +120,12 @@ export class ImapProvider implements InboxProvider {
try { try {
lock = await client.getMailboxLock(config.folder || 'INBOX'); lock = await client.getMailboxLock(config.folder || 'INBOX');
// Search for emails from the configured sender // Search only UNSEEN emails to avoid reprocessing already-handled messages.
const searchQuery = config.senderFilter // Combine with sender filter when configured.
? { from: config.senderFilter } const searchQuery: Record<string, unknown> = { seen: false };
: {}; if (config.senderFilter) {
searchQuery.from = config.senderFilter;
}
const uids = await client.search(searchQuery, { uid: true }); const uids = await client.search(searchQuery, { uid: true });
if (!uids || uids.length === 0) { if (!uids || uids.length === 0) {
@@ -171,6 +181,12 @@ export class ImapProvider implements InboxProvider {
date: msg.envelope?.date ?? new Date(), date: msg.envelope?.date ?? new Date(),
attachments, attachments,
}); });
// Mark as read so subsequent polls skip this message (UNSEEN filter above).
try {
await client.messageFlagsAdd(String(msg.uid), ['\\Seen'], { uid: true });
} catch {
// Non-fatal: message will simply appear again on next poll
}
} }
} }
} finally { } finally {
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useState } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { import {
type DkvConfig, type DkvConfig,
checkNow,
fetchConfig, fetchConfig,
saveConfig, saveConfig,
testConnection, testConnection,
@@ -154,14 +155,24 @@ export function InboxConfigForm() {
const [saveError, setSaveError] = useState<string | null>(null); const [saveError, setSaveError] = useState<string | null>(null);
const [saveSuccess, setSaveSuccess] = useState(false); const [saveSuccess, setSaveSuccess] = useState(false);
// Check-now state
const [isChecking, setIsChecking] = useState(false);
const [checkResult, setCheckResult] = useState<{ status: string; message: string } | null>(null);
// Whether config exists in DB (null = not yet saved)
const [configExists, setConfigExists] = useState(false);
// Load config on mount // Load config on mount
useEffect(() => { useEffect(() => {
fetchConfig() fetchConfig()
.then((config) => { .then((config) => {
if (config) {
setForm(configToForm(config)); setForm(configToForm(config));
setConfigExists(true);
}
}) })
.catch(() => { .catch(() => {
// Keep defaults if config not yet created // Keep defaults if fetch failed
}) })
.finally(() => { .finally(() => {
setIsLoading(false); setIsLoading(false);
@@ -201,6 +212,7 @@ export function InboxConfigForm() {
try { try {
await saveConfig(formToPayload(form)); await saveConfig(formToPayload(form));
setSaveSuccess(true); setSaveSuccess(true);
setConfigExists(true);
// Clear password field after save (T-07-12) // Clear password field after save (T-07-12)
setForm((f) => ({ ...f, password: '' })); setForm((f) => ({ ...f, password: '' }));
} catch (err) { } catch (err) {
@@ -212,6 +224,22 @@ export function InboxConfigForm() {
} }
}; };
const handleCheckNow = async () => {
setIsChecking(true);
setCheckResult(null);
try {
const result = await checkNow();
setCheckResult(result);
} catch (err) {
setCheckResult({
status: 'error',
message: err instanceof Error ? err.message : 'Fehler beim Abrufen',
});
} finally {
setIsChecking(false);
}
};
const inputCls = const inputCls =
'h-9 w-full max-w-md rounded border border-border bg-background px-3 text-sm text-foreground'; 'h-9 w-full max-w-md rounded border border-border bg-background px-3 text-sm text-foreground';
const labelCls = 'mb-1 block text-sm text-foreground'; const labelCls = 'mb-1 block text-sm text-foreground';
@@ -231,6 +259,12 @@ export function InboxConfigForm() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* Banner: config not yet saved */}
{!configExists && (
<div className="rounded-md border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-800 dark:border-amber-700 dark:bg-amber-900/20 dark:text-amber-300">
Noch nicht gespeichert — Konfiguration ausfüllen und speichern.
</div>
)}
{/* Protokoll */} {/* Protokoll */}
<div> <div>
<label htmlFor="dkv-protocol" className={labelCls}> <label htmlFor="dkv-protocol" className={labelCls}>
@@ -503,8 +537,28 @@ export function InboxConfigForm() {
> >
{t('form.save')} {t('form.save')}
</button> </button>
{configExists && (
<button
type="button"
onClick={handleCheckNow}
disabled={isChecking || isSaving || isTesting}
className="rounded border border-border px-4 py-2 text-sm text-foreground transition-colors hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed"
>
{isChecking ? 'Prüfe...' : 'Jetzt prüfen'}
</button>
)}
</div> </div>
{/* Check-now result */}
{checkResult && (
<p
className="text-sm"
style={{ color: checkResult.status === 'ok' ? 'oklch(0.40 0.15 148)' : 'var(--destructive)' }}
>
{checkResult.status === 'ok' ? `✓ ${checkResult.message}` : `✗ ${checkResult.message}`}
</p>
)}
{/* Inline test feedback */} {/* Inline test feedback */}
{testResult !== null && ( {testResult !== null && (
<div className="space-y-1"> <div className="space-y-1">
+4 -2
View File
@@ -95,10 +95,11 @@ export interface DkvHistoryPage {
* Fetch the current DKV module inbox configuration. * Fetch the current DKV module inbox configuration.
* GET /dkv/config * GET /dkv/config
*/ */
export async function fetchConfig(): Promise<DkvConfig> { export async function fetchConfig(): Promise<DkvConfig | null> {
const res = await fetch(`${API_URL}/dkv/config`, { const res = await fetch(`${API_URL}/dkv/config`, {
credentials: 'include', credentials: 'include',
}); });
if (res.status === 404) return null;
if (!res.ok) throw new Error('Failed to fetch DKV config'); if (!res.ok) throw new Error('Failed to fetch DKV config');
return res.json(); return res.json();
} }
@@ -143,12 +144,13 @@ export async function testConnection(
* Trigger an immediate inbox check (manual poll). * Trigger an immediate inbox check (manual poll).
* POST /dkv/check-now * POST /dkv/check-now
*/ */
export async function checkNow(): Promise<void> { export async function checkNow(): Promise<{ status: string; message: string; checkedAt: string }> {
const res = await fetch(`${API_URL}/dkv/check-now`, { const res = await fetch(`${API_URL}/dkv/check-now`, {
method: 'POST', method: 'POST',
credentials: 'include', credentials: 'include',
}); });
if (!res.ok) throw new Error('Failed to trigger DKV inbox check'); if (!res.ok) throw new Error('Failed to trigger DKV inbox check');
return res.json();
} }
/** /**