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.).
+
+
+
+