import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { Mail, Users, Wifi, Server, Copy, CheckCheck, Settings as SettingsIcon, CheckCircle, XCircle } from 'lucide-react'; import { emailAPI, cellAPI, peerAPI } from '../../services/api'; import { useConfig } from '../../contexts/ConfigContext'; import { useDraftConfig } from '../../contexts/DraftConfigContext'; import { useAuth } from '../../contexts/AuthContext'; import { Field, TextInput, NumberInput } from '../../components/FormFields'; import { detectPortConflicts, validateEmailConfig, PORT_CONFLICT_FIELDS } from '../../utils/serviceConfig'; const EMAIL_DEFAULTS = { domain: '', smtp_port: 25, submission_port: 587, imap_port: 993, webmail_port: 8888 }; function CopyButton({ text }) { const [copied, setCopied] = useState(false); return ( ); } function InfoRow({ label, value }) { return (
{label}
{value}
); } function AdminConfigSection({ emailCfg, onChange, errors, portConflicts, saving }) { const conflictFor = (f) => portConflicts[`email|${f}`]; return (

Service Configuration

{saving && Saving…}
onChange({ ...emailCfg, domain: v })} placeholder="mail.example.com" /> onChange({ ...emailCfg, smtp_port: v })} min={1} max={65535} /> onChange({ ...emailCfg, submission_port: v })} min={1} max={65535} /> onChange({ ...emailCfg, imap_port: v })} min={1} max={65535} /> onChange({ ...emailCfg, webmail_port: v })} min={1} max={65535} />
); } function Toast({ msg, type }) { if (!msg) return null; return (
{type === 'error' ? : } {msg}
); } export default function EmailPage() { const { user } = useAuth(); const isAdmin = user?.role === 'admin'; const { domain = 'cell', effective_domain, domain_mode = 'lan', service_ips = {}, service_configs = {}, refresh: refreshConfig } = useConfig(); const draftConfig = useDraftConfig(); const svcDomain = (domain_mode !== 'lan' && effective_domain) ? effective_domain : domain; const proto = domain_mode === 'lan' ? 'http' : 'https'; const cellHost = `mail.${svcDomain}`; const mailIp = service_ips.vip_mail || '172.20.0.23'; const dnsIp = service_ips.dns || '172.20.0.3'; const emailCfgServer = service_configs.email || {}; const imapPort = emailCfgServer.imap_port ?? 993; const smtpPort = emailCfgServer.smtp_port ?? 25; const webmailPort = emailCfgServer.webmail_port ?? 8888; // Admin state const [emailCfg, setEmailCfg] = useState({ ...EMAIL_DEFAULTS }); const [dirty, setDirty] = useState(false); const [saving, setSaving] = useState(false); const [users, setUsers] = useState([]); const [status, setStatus] = useState(null); const [toast, setToast] = useState(null); // Peer state const [peerData, setPeerData] = useState(null); useEffect(() => { if (service_configs.email) setEmailCfg({ ...EMAIL_DEFAULTS, ...service_configs.email }); }, [service_configs.email]); useEffect(() => { if (!isAdmin) { peerAPI.services().then(r => setPeerData(r.data)).catch(() => {}); return; } emailAPI.getUsers().then(r => setUsers(r.data)).catch(() => {}); emailAPI.getStatus().then(r => setStatus(r.data)).catch(() => {}); }, [isAdmin]); const showToast = (msg, type = 'success') => { setToast({ msg, type }); setTimeout(() => setToast(null), 3000); }; const errors = useMemo(() => validateEmailConfig(emailCfg), [emailCfg]); const portConflicts = useMemo( () => detectPortConflicts({ ...service_configs, email: emailCfg }), [emailCfg, service_configs] ); const hasErrors = useMemo( () => Object.keys(errors).length > 0 || PORT_CONFLICT_FIELDS.email.some(f => portConflicts[`email|${f}`]), [errors, portConflicts] ); // Refs so flusher closure always sees current values after navigation const emailCfgRef = useRef(emailCfg); useEffect(() => { emailCfgRef.current = emailCfg; }, [emailCfg]); const dirtyRef = useRef(dirty); useEffect(() => { dirtyRef.current = dirty; }, [dirty]); const hasErrorsRef = useRef(hasErrors); useEffect(() => { hasErrorsRef.current = hasErrors; }, [hasErrors]); const save = useCallback(async () => { if (!dirtyRef.current || hasErrorsRef.current) return; setSaving(true); try { await cellAPI.updateConfig({ email: emailCfgRef.current }); setDirty(false); draftConfig?.setDirty('email', false); refreshConfig(); } catch (err) { showToast(err?.response?.data?.error || 'Failed to save email config', 'error'); } finally { setSaving(false); } }, [draftConfig, refreshConfig]); const saveRef = useRef(save); useEffect(() => { saveRef.current = save; }, [save]); // Register flusher without cleanup so it persists when user navigates away mid-edit useEffect(() => { if (!draftConfig) return; draftConfig.registerFlusher('email', () => saveRef.current()); }, [draftConfig]); // eslint-disable-line react-hooks/exhaustive-deps // Debounced auto-save useEffect(() => { if (!dirty || hasErrors) return; const t = setTimeout(() => saveRef.current(), 800); return () => clearTimeout(t); }, [emailCfg, dirty, hasErrors]); // eslint-disable-line react-hooks/exhaustive-deps const handleChange = (cfg) => { setEmailCfg(cfg); setDirty(true); draftConfig?.setDirty('email', true); }; return (

Email Services

Postfix (SMTP) + Dovecot (IMAP)

{/* IMAP */}

Incoming mail (IMAP)

{/* SMTP */}

Outgoing mail (SMTP)

{/* Webmail */}

Webmail

Requires VPN + DNS set to {dnsIp}.

{/* Status — admin only */} {isAdmin && (

Service Status

{status ? (
Postfix (SMTP): Running
Dovecot (IMAP): Running
) : (

Status unavailable

)}
)} {/* Peer credentials */} {!isAdmin && peerData?.email && (

Your Account

Authenticate with your dashboard username and password.

)} {/* Admin users list */} {isAdmin && users.length > 0 && (

Email Accounts

{users.map((u, i) => (
{u.username} {u.domain}
))}
)}
{/* Admin config form */} {isAdmin && ( )}
); }