fix(dkv): fix inbox processing pipeline — orphan tenant, MIME detection, UNSEEN filter
- 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:
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
+55
-1
@@ -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">
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user