fix(07): propagate Exchange connection error, add folder selection for Exchange
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

- testConnection now returns { success, message? } instead of boolean so
  admins see the actual EWS/IMAP error in the UI rather than "Unbekannter Fehler"
- Exchange provider: resolveFolder() maps folder string to WellKnownFolderName
  (Inbox, SentItems, DeletedItems, Drafts, JunkEmail + German aliases)
- InboxConfigForm: folder field now shown for both IMAP and Exchange protocols
  with Exchange-specific help text listing valid well-known names
- Controller returns testConnection result directly (no more redundant wrapping)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-29 09:33:13 +02:00
parent 01ff137f43
commit 9b453e3ed3
8 changed files with 68 additions and 49 deletions
+1 -2
View File
@@ -103,8 +103,7 @@ export class DkvController {
@Roles(Role.ADMIN, Role.SUPER_ADMIN) @Roles(Role.ADMIN, Role.SUPER_ADMIN)
async testConnection(@Req() req: any, @Body() dto: DkvConfigDto) { async testConnection(@Req() req: any, @Body() dto: DkvConfigDto) {
const tenantId = this._requireTenant(req); const tenantId = this._requireTenant(req);
const success = await this.dkvService.testConnection(tenantId, dto); return this.dkvService.testConnection(tenantId, dto);
return { success };
} }
// ─── History ─────────────────────────────────────────────────────────────── // ─── History ───────────────────────────────────────────────────────────────
+1 -1
View File
@@ -175,7 +175,7 @@ export class DkvService {
* *
* T-05-13: Decrypted password used only within this method scope — never logged. * T-05-13: Decrypted password used only within this method scope — never logged.
*/ */
async testConnection(tenantId: string, dto: DkvConfigDto): Promise<boolean> { async testConnection(tenantId: string, dto: DkvConfigDto): Promise<{ success: boolean; message?: string }> {
let password: string | undefined = dto.password; let password: string | undefined = dto.password;
// If no password in DTO, fall back to the stored one // If no password in DTO, fall back to the stored one
@@ -54,22 +54,22 @@ export class ExchangeInboxProvider implements InboxProvider {
* @param config Decrypted inbox connection parameters * @param config Decrypted inbox connection parameters
* @returns true on success, false on any auth/network failure * @returns true on success, false on any auth/network failure
*/ */
async testConnection(config: InboxConfig): Promise<boolean> { async testConnection(config: InboxConfig): Promise<{ success: boolean; message?: string }> {
try { try {
// Dynamic import — ews-javascript-api is JS-only, no .d.ts (follows ExchangeProvider pattern) // Dynamic import — ews-javascript-api is JS-only, no .d.ts (follows ExchangeProvider pattern)
const ews: any = await import('ews-javascript-api'); const ews: any = await import('ews-javascript-api');
const service = this.buildService(ews, config); const service = this.buildService(ews, config);
// Minimal FindItems on Inbox — just confirm we can connect // Minimal FindItems — just confirm we can connect
const itemView = new ews.ItemView(1); const itemView = new ews.ItemView(1);
await service.FindItems(ews.WellKnownFolderName.Inbox, itemView); const folder = this.resolveFolder(ews, config.folder);
return true; await service.FindItems(folder, itemView);
return { success: true };
} catch (err) { } catch (err) {
// T-07-03: generic error message — no credential details const message = (err as Error).message;
this.logger.error( // T-07-03: log without credentials; return message to admin for diagnosis
`EWS connection test failed: ${(err as Error).message}`, this.logger.error(`EWS connection test failed: ${message}`);
); return { success: false, message };
return false;
} }
} }
@@ -83,9 +83,10 @@ export class ExchangeInboxProvider implements InboxProvider {
const ews: any = await import('ews-javascript-api'); const ews: any = await import('ews-javascript-api');
const service = this.buildService(ews, config); const service = this.buildService(ews, config);
// Pitfall 6: use WellKnownFolderName.Inbox with FindItems (NOT FindAppointments) // Pitfall 6: use WellKnownFolderName with FindItems (NOT FindAppointments)
// FindAppointments is Calendar-only — inbox emails use FindItems // FindAppointments is Calendar-only — inbox emails use FindItems
const itemView = new ews.ItemView(50); const itemView = new ews.ItemView(50);
const folder = this.resolveFolder(ews, config.folder);
let findResults: any; let findResults: any;
if (config.senderFilter) { if (config.senderFilter) {
@@ -94,16 +95,9 @@ export class ExchangeInboxProvider implements InboxProvider {
ews.EmailMessageSchema.From, ews.EmailMessageSchema.From,
config.senderFilter, config.senderFilter,
); );
findResults = await service.FindItems( findResults = await service.FindItems(folder, senderFilter, itemView);
ews.WellKnownFolderName.Inbox,
senderFilter,
itemView,
);
} else { } else {
findResults = await service.FindItems( findResults = await service.FindItems(folder, itemView);
ews.WellKnownFolderName.Inbox,
itemView,
);
} }
const results: InboxEmail[] = []; const results: InboxEmail[] = [];
@@ -226,4 +220,28 @@ export class ExchangeInboxProvider implements InboxProvider {
); );
return service; return service;
} }
/**
* Maps a folder name string to an EWS WellKnownFolderName enum value.
* Supports common German and English names. Defaults to Inbox.
*/
private resolveFolder(ews: any, folder?: string): any {
const name = (folder ?? 'INBOX').toLowerCase().replace(/[\s_-]/g, '');
const map: Record<string, string> = {
inbox: 'Inbox',
posteingang: 'Inbox',
deleteditems: 'DeletedItems',
gelöschteelemente: 'DeletedItems',
trash: 'DeletedItems',
sentitems: 'SentItems',
gesendet: 'SentItems',
drafts: 'Drafts',
entwürfe: 'Drafts',
junk: 'JunkEmail',
junkemail: 'JunkEmail',
spam: 'JunkEmail',
};
const key = map[name] ?? 'Inbox';
return ews.WellKnownFolderName[key] ?? ews.WellKnownFolderName.Inbox;
}
} }
+6 -7
View File
@@ -188,18 +188,17 @@ export class ImapProvider implements InboxProvider {
* @param config Decrypted inbox connection parameters * @param config Decrypted inbox connection parameters
* @returns true on success, false on any network/auth failure * @returns true on success, false on any network/auth failure
*/ */
async testConnection(config: InboxConfig): Promise<boolean> { async testConnection(config: InboxConfig): Promise<{ success: boolean; message?: string }> {
const client = this.buildClient(config); const client = this.buildClient(config);
try { try {
await client.connect(); await client.connect();
await client.logout(); await client.logout();
return true; return { success: true };
} catch (err) { } catch (err) {
// T-07-03: generic error message — no credential details const message = (err as Error).message;
this.logger.error( // T-07-03: log without credentials; return message to admin for diagnosis
`IMAP connection test failed: ${(err as Error).message}`, this.logger.error(`IMAP connection test failed: ${message}`);
); return { success: false, message };
return false;
} }
} }
@@ -26,7 +26,7 @@ export interface InboxProvider {
* Tests whether the inbox connection can be established. * Tests whether the inbox connection can be established.
* *
* @param config Decrypted inbox connection parameters * @param config Decrypted inbox connection parameters
* @returns true if connection succeeded, false on any auth/network error * @returns { success: true } on success, { success: false, message } on failure
*/ */
testConnection(config: InboxConfig): Promise<boolean>; testConnection(config: InboxConfig): Promise<{ success: boolean; message?: string }>;
} }
@@ -303,22 +303,23 @@ export function InboxConfigForm() {
</div> </div>
)} )}
{/* IMAP-only: Ordner */} {/* Ordner / Postfach */}
{form.protocol === 'imap' && (
<div> <div>
<label htmlFor="dkv-folder" className={labelCls}> <label htmlFor="dkv-folder" className={labelCls}>
{t('form.folder')} * {t('form.folder')}
</label> </label>
<input <input
id="dkv-folder" id="dkv-folder"
type="text" type="text"
required placeholder={form.protocol === 'exchange' ? 'Inbox' : 'INBOX'}
className={inputCls} className={inputCls}
value={form.folder} value={form.folder}
onChange={(e) => update('folder', e.target.value)} onChange={(e) => update('folder', e.target.value)}
/> />
</div> {form.protocol === 'exchange' && (
<p className="mt-1 text-xs text-muted-foreground">{t('form.folderExchangeHelp')}</p>
)} )}
</div>
{/* Exchange-only: Domain */} {/* Exchange-only: Domain */}
{form.protocol === 'exchange' && ( {form.protocol === 'exchange' && (
+2 -1
View File
@@ -318,7 +318,8 @@
"port": "Port", "port": "Port",
"encryption": "Verschluesselung", "encryption": "Verschluesselung",
"encryptionNone": "Keine", "encryptionNone": "Keine",
"folder": "Ordner", "folder": "Ordner / Postfach",
"folderExchangeHelp": "Standard-Postfaecher: Inbox, SentItems, DeletedItems, Drafts, JunkEmail",
"senderFilter": "Absenderfilter", "senderFilter": "Absenderfilter",
"pollInterval": "Abrufintervall", "pollInterval": "Abrufintervall",
"pollIntervalSuffix": "Minuten", "pollIntervalSuffix": "Minuten",
+2 -1
View File
@@ -318,7 +318,8 @@
"port": "Port", "port": "Port",
"encryption": "Encryption", "encryption": "Encryption",
"encryptionNone": "None", "encryptionNone": "None",
"folder": "Folder", "folder": "Folder / Mailbox",
"folderExchangeHelp": "Common mailboxes: Inbox, SentItems, DeletedItems, Drafts, JunkEmail",
"senderFilter": "Sender Filter", "senderFilter": "Sender Filter",
"pollInterval": "Poll Interval", "pollInterval": "Poll Interval",
"pollIntervalSuffix": "Minutes", "pollIntervalSuffix": "Minutes",