feat(email): add 'Test connection' to incoming mail + tidy IMAP label
- emailIntakeService.testConnection(): logs in, opens the configured folder, reports message/unread counts (non-destructive). Accepts current form creds so it works before saving; masked password falls back to stored. - route POST /admin/email/incoming-config/test - IMAP card: 'Test connection' button beside Save; toast shows folder + counts - capitalize 'IMAP Host' label to match 'SMTP Host' Note: incoming uses IMAP (receiving) vs outgoing SMTP (sending) — genuinely different servers/credentials, hence the distinct field set (Folder; no From).
This commit is contained in:
@@ -197,6 +197,31 @@ router.post('/incoming-config/folders', adminAuth, requirePermission('email.view
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Test the incoming-mail connection: log in + open the configured folder and
|
||||||
|
// report message/unread counts. Accepts current form creds (test before save).
|
||||||
|
router.post('/incoming-config/test', adminAuth, requirePermission('email.view'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { imap_host, imap_port, imap_secure, imap_user, imap_pass, imap_folder } = req.body || {};
|
||||||
|
if (imap_host) {
|
||||||
|
const { isPrivateIP } = require('../utils/networkValidation');
|
||||||
|
if (isPrivateIP(imap_host)) {
|
||||||
|
return res.status(400).json({ error: 'IMAP host cannot point to a private or internal network address' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const emailIntakeService = require('../services/emailIntakeService');
|
||||||
|
const result = await emailIntakeService.testConnection(
|
||||||
|
imap_host ? { host: imap_host, port: imap_port, secure: imap_secure, user: imap_user, pass: imap_pass, folder: imap_folder } : undefined
|
||||||
|
);
|
||||||
|
if (result && result.ok === false) {
|
||||||
|
return res.status(400).json({ error: 'Incoming mail is not configured yet — enter host, username and password first.' });
|
||||||
|
}
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('IMAP connection test error:', error);
|
||||||
|
res.status(502).json({ error: 'Could not connect to the mailbox. Check host, port, credentials and folder.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.get('/received', adminAuth, requirePermission('email.view'), async (req, res) => {
|
router.get('/received', adminAuth, requirePermission('email.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const page = Math.max(1, parseInt(req.query.page, 10) || 1);
|
const page = Math.max(1, parseInt(req.query.page, 10) || 1);
|
||||||
|
|||||||
@@ -83,6 +83,43 @@ async function listFolders(override) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test the IMAP connection: log in, open the configured folder, and report
|
||||||
|
* the message + unread counts. Non-destructive (marks nothing seen, ingests
|
||||||
|
* nothing) — proves host/port/user/pass AND that the chosen folder opens.
|
||||||
|
* Accepts an `override` ({ host, port, secure, user, pass, folder }) so the
|
||||||
|
* admin can test before saving; a masked/blank password falls back to stored.
|
||||||
|
*/
|
||||||
|
async function testConnection(override) {
|
||||||
|
let cfg; let folder;
|
||||||
|
if (override && override.host && override.user) {
|
||||||
|
cfg = {
|
||||||
|
host: override.host,
|
||||||
|
port: override.port || 993,
|
||||||
|
secure: override.secure !== false && override.secure !== 0,
|
||||||
|
auth: { user: override.user, pass: override.pass || '' },
|
||||||
|
};
|
||||||
|
folder = override.folder || 'INBOX';
|
||||||
|
if (!cfg.auth.pass || cfg.auth.pass === '********') {
|
||||||
|
const stored = await getImapConfig();
|
||||||
|
cfg.auth.pass = stored?.auth?.pass || '';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const c = await getImapConfig();
|
||||||
|
if (!c) return { ok: false, error: 'unconfigured' };
|
||||||
|
cfg = { host: c.host, port: c.port, secure: c.secure, auth: c.auth };
|
||||||
|
folder = c.folder;
|
||||||
|
}
|
||||||
|
const client = new ImapFlow({ host: cfg.host, port: cfg.port, secure: cfg.secure, auth: cfg.auth, logger: false });
|
||||||
|
await client.connect();
|
||||||
|
try {
|
||||||
|
const status = await client.status(folder, { messages: true, unseen: true });
|
||||||
|
return { ok: true, folder, messages: status.messages || 0, unseen: status.unseen || 0 };
|
||||||
|
} finally {
|
||||||
|
await client.logout().catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Poll the mailbox once. Safe to call repeatedly; self-skips when busy/off. */
|
/** Poll the mailbox once. Safe to call repeatedly; self-skips when busy/off. */
|
||||||
async function pollOnce() {
|
async function pollOnce() {
|
||||||
if (polling) return { skipped: 'busy' };
|
if (polling) return { skipped: 'busy' };
|
||||||
@@ -155,4 +192,4 @@ function startIncomingMailPoller() {
|
|||||||
logger.info?.('Incoming-mail poller started (every 60s when enabled)');
|
logger.info?.('Incoming-mail poller started (every 60s when enabled)');
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { pollOnce, startIncomingMailPoller, listFolders, _internal: { getImapConfig, isEnabled, saveAttachment } };
|
module.exports = { pollOnce, startIncomingMailPoller, listFolders, testConnection, _internal: { getImapConfig, isEnabled, saveAttachment } };
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import React, { useEffect, useState } from 'react';
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import { Save, Server, User, Lock, Eye, EyeOff, FolderSearch } from 'lucide-react';
|
import { Save, Server, User, Lock, Eye, EyeOff, FolderSearch, PlugZap } from 'lucide-react';
|
||||||
import { Button, Card, Input, Loading } from '../common';
|
import { Button, Card, Input, Loading } from '../common';
|
||||||
import { emailService, type IncomingMailConfig, type ImapFolder } from '../../services/email.service';
|
import { emailService, type IncomingMailConfig, type ImapFolder } from '../../services/email.service';
|
||||||
|
|
||||||
@@ -44,6 +44,12 @@ export const IncomingMailConfigCard: React.FC = () => {
|
|||||||
onError: (e: any) => toast.error(e?.response?.data?.error || e?.response?.data?.errors?.[0]?.msg || e.message || 'Failed'),
|
onError: (e: any) => toast.error(e?.response?.data?.error || e?.response?.data?.errors?.[0]?.msg || e.message || 'Failed'),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const test = useMutation({
|
||||||
|
mutationFn: () => emailService.testIncoming(cfg),
|
||||||
|
onSuccess: (r) => toast.success(t('email.incoming.testOk', 'Connected to {{folder}} — {{messages}} messages, {{unseen}} unread.', { folder: r.folder, messages: r.messages, unseen: r.unseen })),
|
||||||
|
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || t('email.incoming.testFailed', 'Connection failed.')),
|
||||||
|
});
|
||||||
|
|
||||||
const detect = useMutation({
|
const detect = useMutation({
|
||||||
mutationFn: () => emailService.listIncomingFolders(cfg),
|
mutationFn: () => emailService.listIncomingFolders(cfg),
|
||||||
onSuccess: (list) => {
|
onSuccess: (list) => {
|
||||||
@@ -152,10 +158,22 @@ export const IncomingMailConfigCard: React.FC = () => {
|
|||||||
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">{t('email.incoming.folderHint', 'Enter host, username and password, then Detect to list the mailbox folders.')}</p>
|
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">{t('email.incoming.folderHint', 'Enter host, username and password, then Detect to list the mailbox folders.')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button variant="primary" onClick={() => save.mutate()} isLoading={save.isPending} leftIcon={<Save className="w-5 h-5" />} className="w-full">
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => test.mutate()}
|
||||||
|
isLoading={test.isPending}
|
||||||
|
disabled={!cfg.imap_host || !cfg.imap_user}
|
||||||
|
leftIcon={<PlugZap className="w-5 h-5" />}
|
||||||
|
className="whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{t('email.incoming.test', 'Test connection')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" onClick={() => save.mutate()} isLoading={save.isPending} leftIcon={<Save className="w-5 h-5" />} className="flex-1">
|
||||||
{t('email.incoming.save', 'Save Incoming Mail Settings')}
|
{t('email.incoming.save', 'Save Incoming Mail Settings')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2479,6 +2479,9 @@
|
|||||||
"title": "Eingehende E-Mails (IMAP)",
|
"title": "Eingehende E-Mails (IMAP)",
|
||||||
"subtitle": "Ein dediziertes Postfach, jede Minute abgerufen; Anhänge landen in Buchhaltung → Eingangsrechnungen.",
|
"subtitle": "Ein dediziertes Postfach, jede Minute abgerufen; Anhänge landen in Buchhaltung → Eingangsrechnungen.",
|
||||||
"host": "IMAP-Host",
|
"host": "IMAP-Host",
|
||||||
|
"test": "Verbindung testen",
|
||||||
|
"testOk": "Verbunden mit {{folder}} — {{messages}} Nachrichten, {{unseen}} ungelesen.",
|
||||||
|
"testFailed": "Verbindung fehlgeschlagen.",
|
||||||
"port": "Port",
|
"port": "Port",
|
||||||
"security": "Sicherheit",
|
"security": "Sicherheit",
|
||||||
"ssl": "SSL/TLS",
|
"ssl": "SSL/TLS",
|
||||||
|
|||||||
@@ -2051,7 +2051,10 @@
|
|||||||
"incoming": {
|
"incoming": {
|
||||||
"title": "Incoming mail (IMAP)",
|
"title": "Incoming mail (IMAP)",
|
||||||
"subtitle": "A dedicated mailbox polled every minute; attachments land in Accounting → Incoming invoices.",
|
"subtitle": "A dedicated mailbox polled every minute; attachments land in Accounting → Incoming invoices.",
|
||||||
"host": "IMAP host",
|
"host": "IMAP Host",
|
||||||
|
"test": "Test connection",
|
||||||
|
"testOk": "Connected to {{folder}} — {{messages}} messages, {{unseen}} unread.",
|
||||||
|
"testFailed": "Connection failed.",
|
||||||
"port": "Port",
|
"port": "Port",
|
||||||
"security": "Security",
|
"security": "Security",
|
||||||
"ssl": "SSL/TLS",
|
"ssl": "SSL/TLS",
|
||||||
|
|||||||
@@ -95,6 +95,13 @@ export interface ImapFolder {
|
|||||||
specialUse: string | null;
|
specialUse: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ImapTestResult {
|
||||||
|
ok: boolean;
|
||||||
|
folder: string;
|
||||||
|
messages: number;
|
||||||
|
unseen: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ReceivedEmail {
|
export interface ReceivedEmail {
|
||||||
id: number;
|
id: number;
|
||||||
message_id: string | null;
|
message_id: string | null;
|
||||||
@@ -138,6 +145,11 @@ export const emailService = {
|
|||||||
const response = await api.post<{ folders: ImapFolder[] }>('/admin/email/incoming-config/folders', config || {});
|
const response = await api.post<{ folders: ImapFolder[] }>('/admin/email/incoming-config/folders', config || {});
|
||||||
return response.data.folders;
|
return response.data.folders;
|
||||||
},
|
},
|
||||||
|
// Test the IMAP connection: opens the configured folder, reports counts.
|
||||||
|
async testIncoming(config?: Partial<IncomingMailConfig>): Promise<ImapTestResult> {
|
||||||
|
const response = await api.post<ImapTestResult>('/admin/email/incoming-config/test', config || {});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
async listReceived(params: { page?: number; pageSize?: number } = {}): Promise<ReceivedEmailsResponse> {
|
async listReceived(params: { page?: number; pageSize?: number } = {}): Promise<ReceivedEmailsResponse> {
|
||||||
const response = await api.get<ReceivedEmailsResponse>('/admin/email/received', { params });
|
const response = await api.get<ReceivedEmailsResponse>('/admin/email/received', { params });
|
||||||
return response.data;
|
return response.data;
|
||||||
|
|||||||
Reference in New Issue
Block a user