'use client'; /** * Sales send-from config card (Phase 7 §5.9). * * Lives on /[portSlug]/admin/email below the existing noreply transport * card. Lets per-port admins configure the SMTP/IMAP creds + body templates * that the document-sends flow uses. * * §14.10 enforcement: passwords are write-only. The GET endpoint never * returns the decrypted value — only a `*PassIsSet` boolean. Empty * password input means "leave unchanged"; explicit `null` sent over the * wire means "clear". */ import { useEffect, useState } from 'react'; import { Loader2 } 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 { Switch } from '@/components/ui/switch'; import { Textarea } from '@/components/ui/textarea'; import { apiFetch } from '@/lib/api/client'; import { toastError } from '@/lib/api/toast-error'; interface SalesConfigResponse { data: { email: { fromAddress: string; smtpHost: string | null; smtpPort: number; smtpSecure: boolean; smtpUser: string | null; authMethod: string; smtpPassIsSet: boolean; isUsable: boolean; }; imap: { imapHost: string | null; imapPort: number; imapUser: string | null; imapPassIsSet: boolean; isUsable: boolean; }; content: { noreplyFromAddress: string; templateBerthPdfBody: string; templateBrochureBody: string; brochureMaxUploadMb: number; emailAttachThresholdMb: number; }; }; } interface FormState { fromAddress: string; smtpHost: string; smtpPort: number | ''; smtpSecure: boolean; smtpUser: string; smtpPass: string; // empty = unchanged imapHost: string; imapPort: number | ''; imapUser: string; imapPass: string; noreplyFromAddress: string; templateBerthPdfBody: string; templateBrochureBody: string; brochureMaxUploadMb: number | ''; emailAttachThresholdMb: number | ''; } const EMPTY_FORM: FormState = { fromAddress: '', smtpHost: '', smtpPort: 587, smtpSecure: false, smtpUser: '', smtpPass: '', imapHost: '', imapPort: 993, imapUser: '', imapPass: '', noreplyFromAddress: '', templateBerthPdfBody: '', templateBrochureBody: '', brochureMaxUploadMb: 50, emailAttachThresholdMb: 15, }; export function SalesEmailConfigCard() { const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [smtpPassSet, setSmtpPassSet] = useState(false); const [imapPassSet, setImapPassSet] = useState(false); const [form, setForm] = useState(EMPTY_FORM); async function refresh() { setLoading(true); try { const res: SalesConfigResponse = await apiFetch('/api/v1/admin/email/sales-config'); setSmtpPassSet(res.data.email.smtpPassIsSet); setImapPassSet(res.data.imap.imapPassIsSet); setForm({ fromAddress: res.data.email.fromAddress, smtpHost: res.data.email.smtpHost ?? '', smtpPort: res.data.email.smtpPort, smtpSecure: res.data.email.smtpSecure, smtpUser: res.data.email.smtpUser ?? '', smtpPass: '', imapHost: res.data.imap.imapHost ?? '', imapPort: res.data.imap.imapPort, imapUser: res.data.imap.imapUser ?? '', imapPass: '', noreplyFromAddress: res.data.content.noreplyFromAddress, templateBerthPdfBody: res.data.content.templateBerthPdfBody, templateBrochureBody: res.data.content.templateBrochureBody, brochureMaxUploadMb: res.data.content.brochureMaxUploadMb, emailAttachThresholdMb: res.data.content.emailAttachThresholdMb, }); } finally { setLoading(false); } } useEffect(() => { void refresh(); }, []); function update(key: K, value: FormState[K]) { setForm((prev) => ({ ...prev, [key]: value })); } async function handleSave() { setSaving(true); try { const payload: Record = { fromAddress: form.fromAddress || null, smtpHost: form.smtpHost || null, smtpPort: typeof form.smtpPort === 'number' ? form.smtpPort : null, smtpSecure: form.smtpSecure, smtpUser: form.smtpUser || null, imapHost: form.imapHost || null, imapPort: typeof form.imapPort === 'number' ? form.imapPort : null, imapUser: form.imapUser || null, noreplyFromAddress: form.noreplyFromAddress || null, templateBerthPdfBody: form.templateBerthPdfBody, templateBrochureBody: form.templateBrochureBody, brochureMaxUploadMb: typeof form.brochureMaxUploadMb === 'number' ? form.brochureMaxUploadMb : null, emailAttachThresholdMb: typeof form.emailAttachThresholdMb === 'number' ? form.emailAttachThresholdMb : null, }; // Only send password fields when the user actually typed something. if (form.smtpPass !== '') payload.smtpPass = form.smtpPass; if (form.imapPass !== '') payload.imapPass = form.imapPass; await apiFetch('/api/v1/admin/email/sales-config', { method: 'PATCH', body: payload }); toast.success('Sales email settings saved'); await refresh(); } catch (err) { toastError(err); } finally { setSaving(false); } } if (loading) { return ( Loading sales email config… ); } return (
Sales send-from account SMTP credentials for human-touch outbound (brochures + per-berth PDFs). IMAP creds enable the bounce monitor — leave blank to disable bounce-rejection banners. Passwords are encrypted at rest and never returned by the API.
update('fromAddress', e.target.value)} placeholder="sales@portnimara.com" /> update('smtpHost', e.target.value)} placeholder="smtp.gmail.com" /> update('smtpPort', e.target.value === '' ? '' : Number(e.target.value)) } />
update('smtpSecure', v)} />
update('smtpUser', e.target.value)} /> update('smtpPass', e.target.value)} placeholder={smtpPassSet ? '••••••••' : 'app password'} />
Bounce monitor (IMAP) Required only for the async-bounce banner (§14.9). Same provider account as SMTP in most setups. update('imapHost', e.target.value)} placeholder="imap.gmail.com" /> update('imapPort', e.target.value === '' ? '' : Number(e.target.value)) } /> update('imapUser', e.target.value)} /> update('imapPass', e.target.value)} placeholder={imapPassSet ? '••••••••' : 'app password'} /> Body templates Default markdown bodies used when a rep doesn’t write a custom one. Tokens like{' '} {'{{client.fullName}}'} are expanded server-side.