feat(admin-email): SMTP test-send card on /admin/email
Adds a plaintext-only SMTP connectivity test on the email-settings
page. Distinct from the branding-preview "Send a test" affordance:
- branding-preview exercises the full rendering pipeline (logo +
branded shell + colour) — useful for confirming the email *looks*
right.
- this test isolates SMTP — minimal HTML, plaintext alternative, no
logo dependency — so a failure is purely transport. Confirms the
configured credentials (env or per-port DB) reach the wire before
a real notification flow depends on them.
SMTP errors surface inline below the input (auth failure, ENOTFOUND,
connection refused, etc.) rather than as a passing toast — the whole
point of the test is to read them.
`/api/v1/admin/email/test-send` route reuses `sendEmail(...,
ctx.portId)` so per-port SMTP overrides are exercised the same way a
real notification would.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ import { PageHeader } from '@/components/shared/page-header';
|
||||
import { RegistryDrivenForm } from '@/components/admin/shared/registry-driven-form';
|
||||
import { SalesEmailConfigCard } from '@/components/admin/sales-email-config-card';
|
||||
import { EmailRoutingCard } from '@/components/admin/email-routing-card';
|
||||
import { SmtpTestSendCard } from '@/components/admin/email/smtp-test-send-card';
|
||||
|
||||
export default function EmailSettingsPage() {
|
||||
return (
|
||||
@@ -23,6 +24,7 @@ export default function EmailSettingsPage() {
|
||||
title="SMTP transport overrides"
|
||||
description="Optional per-port SMTP credentials. Leave blank to use the global env defaults. Each field shows its current source (env / port / default) so you can tell what's active without checking the deploy."
|
||||
/>
|
||||
<SmtpTestSendCard />
|
||||
<SalesEmailConfigCard />
|
||||
<EmailRoutingCard />
|
||||
</div>
|
||||
|
||||
61
src/app/api/v1/admin/email/test-send/route.ts
Normal file
61
src/app/api/v1/admin/email/test-send/route.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { errorResponse, ValidationError } from '@/lib/errors';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { sendEmail } from '@/lib/email';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
const testSendSchema = z.object({
|
||||
recipient: z.string().email('Enter a valid email address'),
|
||||
});
|
||||
|
||||
/**
|
||||
* SMTP connectivity test. Sends a minimal plaintext-ish message so the
|
||||
* admin can verify the configured SMTP credentials (env or per-port DB)
|
||||
* reach the inbox WITHOUT depending on branding being uploaded.
|
||||
*
|
||||
* Separate from the branding-preview send (`/admin/branding/email-preview`):
|
||||
* - This one isolates the SMTP-host/port/user/pass surface.
|
||||
* - The branding one exercises the rendering pipeline + logo bytes.
|
||||
*
|
||||
* Surface SMTP errors to the caller directly (auth failure, ENOTFOUND,
|
||||
* connection refused) — the whole point of the test is to see them
|
||||
* inline in the admin UI.
|
||||
*/
|
||||
export const POST = withAuth(
|
||||
withPermission('admin', 'manage_settings', async (req, ctx) => {
|
||||
try {
|
||||
if (!ctx.portId) throw new ValidationError('No active port');
|
||||
const { recipient } = await parseBody(req, testSendSchema);
|
||||
|
||||
const subject = 'CRM SMTP test — connection verified';
|
||||
const html = `
|
||||
<div style="font-family:system-ui,-apple-system,sans-serif;font-size:14px;color:#1e293b;padding:24px;line-height:1.5;">
|
||||
<h1 style="font-size:18px;margin:0 0 12px;">SMTP test</h1>
|
||||
<p style="margin:0 0 12px;">
|
||||
If you're reading this, the SMTP credentials configured for this port
|
||||
are reaching ${recipient}.
|
||||
</p>
|
||||
<p style="margin:0;color:#64748b;font-size:13px;">
|
||||
Sent from /admin/email — Port Nimara CRM
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
const text = `SMTP test\n\nIf you're reading this, the SMTP credentials configured for this port are reaching ${recipient}.\n\nSent from /admin/email — Port Nimara CRM`;
|
||||
|
||||
const info = await sendEmail(recipient, subject, html, undefined, text, ctx.portId);
|
||||
logger.info(
|
||||
{ portId: ctx.portId, recipient, messageId: info.messageId },
|
||||
'Admin SMTP test send succeeded',
|
||||
);
|
||||
return NextResponse.json({
|
||||
data: { sent: true, recipient, messageId: info.messageId ?? null },
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn({ err: error, portId: ctx.portId }, 'Admin SMTP test send failed');
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
121
src/components/admin/email/smtp-test-send-card.tsx
Normal file
121
src/components/admin/email/smtp-test-send-card.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Send, CheckCircle2, AlertCircle } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
|
||||
/**
|
||||
* SMTP connectivity test card. Distinct from the branding-page "Send a
|
||||
* test" affordance:
|
||||
* - This one isolates SMTP — plaintext + minimal HTML, no logo, no
|
||||
* branded shell — so the failure mode is pure transport.
|
||||
* - The branding-preview send exercises the full rendering pipeline.
|
||||
*
|
||||
* Surfaces the SMTP error inline (under the input) instead of toasting,
|
||||
* because the whole point is to read it. ENOTFOUND, EAUTH, connection
|
||||
* refused, etc. land here as a useful diagnostic.
|
||||
*/
|
||||
export function SmtpTestSendCard() {
|
||||
const [recipient, setRecipient] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const [result, setResult] = useState<
|
||||
| { kind: 'ok'; recipient: string; messageId: string | null }
|
||||
| { kind: 'err'; message: string }
|
||||
| null
|
||||
>(null);
|
||||
|
||||
async function send() {
|
||||
if (!recipient) return;
|
||||
setSending(true);
|
||||
setResult(null);
|
||||
try {
|
||||
const res = await apiFetch<{
|
||||
data: { sent: boolean; recipient: string; messageId: string | null };
|
||||
}>('/api/v1/admin/email/test-send', {
|
||||
method: 'POST',
|
||||
body: { recipient },
|
||||
});
|
||||
setResult({
|
||||
kind: 'ok',
|
||||
recipient: res.data.recipient,
|
||||
messageId: res.data.messageId,
|
||||
});
|
||||
toast.success(`Test email accepted by SMTP for ${res.data.recipient}`);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'SMTP send failed';
|
||||
setResult({ kind: 'err', message });
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>SMTP test send</CardTitle>
|
||||
<CardDescription>
|
||||
Fires a minimal plaintext email through the configured SMTP host. Use this to confirm
|
||||
credentials reach the wire before a real notification flow depends on them. Errors land
|
||||
inline below so you can read the SMTP response (auth failure, connection refused, etc.).
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="smtp-test-recipient">Recipient</Label>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
id="smtp-test-recipient"
|
||||
type="email"
|
||||
value={recipient}
|
||||
onChange={(e) => setRecipient(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
className="flex-1 min-w-[240px]"
|
||||
/>
|
||||
<Button onClick={send} disabled={sending || !recipient}>
|
||||
<Send className="mr-1.5 h-4 w-4" aria-hidden />
|
||||
{sending ? 'Sending…' : 'Send test'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{result?.kind === 'ok' && (
|
||||
<div
|
||||
role="status"
|
||||
className="flex items-start gap-2 rounded-md border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-900"
|
||||
>
|
||||
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" aria-hidden />
|
||||
<div>
|
||||
<div className="font-medium">Accepted by SMTP for {result.recipient}.</div>
|
||||
{result.messageId ? (
|
||||
<div className="text-xs text-emerald-800/80">
|
||||
Message-ID: <span className="font-mono">{result.messageId}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="text-xs text-emerald-800/80">
|
||||
Acceptance doesn't guarantee inbox delivery. Check the recipient's mailbox
|
||||
(and spam folder) to confirm.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{result?.kind === 'err' && (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm text-destructive"
|
||||
>
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" aria-hidden />
|
||||
<div>
|
||||
<div className="font-medium">SMTP send failed</div>
|
||||
<div className="font-mono text-xs break-all">{result.message}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user