feat(email): round-trip test — send via SMTP to the IMAP mailbox and confirm arrival
- emailIntakeService.roundTripTest(): sends a uniquely-tagged email through the
saved SMTP config to the IMAP mailbox (imap_user), then polls IMAP up to 30s
for that subject token; deletes the test message on arrival so it never hits
the accounting inbox. Returns {ok, seconds, recipient} or a typed reason.
- route POST /admin/email/incoming-config/roundtrip (email.send)
- IMAP card: 'Round-trip test' button beside 'Test connection' + Save; toast
reports recipient + delivery time. Distinct reasons mapped (smtp/imap
unconfigured, send_failed, not_received→504).
- en/de strings
This commit is contained in:
@@ -11,7 +11,7 @@ import React, { useEffect, useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Save, Server, User, Lock, Eye, EyeOff, FolderSearch, PlugZap } from 'lucide-react';
|
||||
import { Save, Server, User, Lock, Eye, EyeOff, FolderSearch, PlugZap, Mailbox } from 'lucide-react';
|
||||
import { Button, Card, Input, Loading } from '../common';
|
||||
import { emailService, type IncomingMailConfig, type ImapFolder } from '../../services/email.service';
|
||||
|
||||
@@ -50,6 +50,12 @@ export const IncomingMailConfigCard: React.FC = () => {
|
||||
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || t('email.incoming.testFailed', 'Connection failed.')),
|
||||
});
|
||||
|
||||
const roundTrip = useMutation({
|
||||
mutationFn: () => emailService.roundTripIncoming(),
|
||||
onSuccess: (r) => toast.success(t('email.incoming.roundTripOk', 'Round-trip OK — delivered to {{recipient}} in {{seconds}}s.', { recipient: r.recipient, seconds: r.seconds })),
|
||||
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || t('email.incoming.roundTripFailed', 'Round-trip test failed.')),
|
||||
});
|
||||
|
||||
const detect = useMutation({
|
||||
mutationFn: () => emailService.listIncomingFolders(cfg),
|
||||
onSuccess: (list) => {
|
||||
@@ -158,7 +164,7 @@ 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>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => test.mutate()}
|
||||
@@ -169,7 +175,18 @@ export const IncomingMailConfigCard: React.FC = () => {
|
||||
>
|
||||
{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">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => roundTrip.mutate()}
|
||||
isLoading={roundTrip.isPending}
|
||||
disabled={!cfg.imap_host || !cfg.imap_user}
|
||||
leftIcon={<Mailbox className="w-5 h-5" />}
|
||||
className="whitespace-nowrap"
|
||||
title={t('email.incoming.roundTripHint', 'Sends a test email via your SMTP settings to this mailbox and confirms it arrives. Save both first.') as string}
|
||||
>
|
||||
{t('email.incoming.roundTrip', 'Round-trip test')}
|
||||
</Button>
|
||||
<Button variant="primary" onClick={() => save.mutate()} isLoading={save.isPending} leftIcon={<Save className="w-5 h-5" />} className="flex-1 min-w-[12rem]">
|
||||
{t('email.incoming.save', 'Save Incoming Mail Settings')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -2482,6 +2482,10 @@
|
||||
"test": "Verbindung testen",
|
||||
"testOk": "Verbunden mit {{folder}} — {{messages}} Nachrichten, {{unseen}} ungelesen.",
|
||||
"testFailed": "Verbindung fehlgeschlagen.",
|
||||
"roundTrip": "Rundlauf-Test",
|
||||
"roundTripHint": "Sendet über die SMTP-Einstellungen eine Test-E-Mail an dieses Postfach und prüft, ob sie ankommt. Beide vorher speichern.",
|
||||
"roundTripOk": "Rundlauf OK — an {{recipient}} zugestellt in {{seconds}}s.",
|
||||
"roundTripFailed": "Rundlauf-Test fehlgeschlagen.",
|
||||
"port": "Port",
|
||||
"security": "Sicherheit",
|
||||
"ssl": "SSL/TLS",
|
||||
|
||||
@@ -2055,6 +2055,10 @@
|
||||
"test": "Test connection",
|
||||
"testOk": "Connected to {{folder}} — {{messages}} messages, {{unseen}} unread.",
|
||||
"testFailed": "Connection failed.",
|
||||
"roundTrip": "Round-trip test",
|
||||
"roundTripHint": "Sends a test email via your SMTP settings to this mailbox and confirms it arrives. Save both first.",
|
||||
"roundTripOk": "Round-trip OK — delivered to {{recipient}} in {{seconds}}s.",
|
||||
"roundTripFailed": "Round-trip test failed.",
|
||||
"port": "Port",
|
||||
"security": "Security",
|
||||
"ssl": "SSL/TLS",
|
||||
|
||||
@@ -102,6 +102,12 @@ export interface ImapTestResult {
|
||||
unseen: number;
|
||||
}
|
||||
|
||||
export interface ImapRoundTripResult {
|
||||
ok: boolean;
|
||||
seconds?: number;
|
||||
recipient?: string;
|
||||
}
|
||||
|
||||
export interface ReceivedEmail {
|
||||
id: number;
|
||||
message_id: string | null;
|
||||
@@ -150,6 +156,12 @@ export const emailService = {
|
||||
const response = await api.post<ImapTestResult>('/admin/email/incoming-config/test', config || {});
|
||||
return response.data;
|
||||
},
|
||||
// End-to-end: send via SMTP to the IMAP mailbox and confirm it arrives.
|
||||
// Uses saved config for both sides — no body. May take up to ~30s.
|
||||
async roundTripIncoming(): Promise<ImapRoundTripResult> {
|
||||
const response = await api.post<ImapRoundTripResult>('/admin/email/incoming-config/roundtrip', {});
|
||||
return response.data;
|
||||
},
|
||||
async listReceived(params: { page?: number; pageSize?: number } = {}): Promise<ReceivedEmailsResponse> {
|
||||
const response = await api.get<ReceivedEmailsResponse>('/admin/email/received', { params });
|
||||
return response.data;
|
||||
|
||||
Reference in New Issue
Block a user