From 7881da675b8b1fb16f4b24988d6c421d35c1cb93 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 21 May 2026 19:28:01 +0200 Subject: [PATCH] feat(admin-email): SMTP test-send card on /admin/email MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../[portSlug]/admin/email/page.tsx | 2 + src/app/api/v1/admin/email/test-send/route.ts | 61 +++++++++ .../admin/email/smtp-test-send-card.tsx | 121 ++++++++++++++++++ 3 files changed, 184 insertions(+) create mode 100644 src/app/api/v1/admin/email/test-send/route.ts create mode 100644 src/components/admin/email/smtp-test-send-card.tsx diff --git a/src/app/(dashboard)/[portSlug]/admin/email/page.tsx b/src/app/(dashboard)/[portSlug]/admin/email/page.tsx index 7a9259ce..941d1ba4 100644 --- a/src/app/(dashboard)/[portSlug]/admin/email/page.tsx +++ b/src/app/(dashboard)/[portSlug]/admin/email/page.tsx @@ -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." /> + diff --git a/src/app/api/v1/admin/email/test-send/route.ts b/src/app/api/v1/admin/email/test-send/route.ts new file mode 100644 index 00000000..0d040cec --- /dev/null +++ b/src/app/api/v1/admin/email/test-send/route.ts @@ -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 = ` +
+

SMTP test

+

+ If you're reading this, the SMTP credentials configured for this port + are reaching ${recipient}. +

+

+ Sent from /admin/email — Port Nimara CRM +

+
+ `; + 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); + } + }), +); diff --git a/src/components/admin/email/smtp-test-send-card.tsx b/src/components/admin/email/smtp-test-send-card.tsx new file mode 100644 index 00000000..56720d95 --- /dev/null +++ b/src/components/admin/email/smtp-test-send-card.tsx @@ -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 ( + + + SMTP test send + + 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.). + + + +
+ +
+ setRecipient(e.target.value)} + placeholder="you@example.com" + className="flex-1 min-w-[240px]" + /> + +
+
+ {result?.kind === 'ok' && ( +
+ +
+
Accepted by SMTP for {result.recipient}.
+ {result.messageId ? ( +
+ Message-ID: {result.messageId} +
+ ) : null} +
+ Acceptance doesn't guarantee inbox delivery. Check the recipient's mailbox + (and spam folder) to confirm. +
+
+
+ )} + {result?.kind === 'err' && ( +
+ +
+
SMTP send failed
+
{result.message}
+
+
+ )} +
+
+ ); +}