// Main Inbox View — the core screen // Wired to real backend via window.tmApi (see lib/tm-browser.js) // Default local-part: prefer the deterministic one returned by the backend // (sticky to this access key, so the customer always lands on the same address // across sessions). Fall back to a random one only for very old sessions // without the field. const Inbox = ({ session, onSignOut }) => { const initialLocal = () => { if (session?.defaultLocalPart) return session.defaultLocalPart; const arr = (typeof RANDOM_ADDRESSES !== 'undefined' && RANDOM_ADDRESSES.length) ? RANDOM_ADDRESSES : ['mailbox']; const pick = arr[Math.floor(Math.random() * arr.length)]; return pick.replace(/\d+$/, '') + Math.floor(10 + Math.random() * 990); }; const [domains, setDomains] = React.useState({ shared: [], custom: [] }); const [currentDomain, setCurrentDomain] = React.useState('zeromailer.cloud'); const [localPart, setLocalPart] = React.useState(initialLocal); const [emails, setEmails] = React.useState([]); const [selectedId, setSelectedId] = React.useState(null); const [refreshing, setRefreshing] = React.useState(false); const [toast, setToast] = React.useState(null); const [loading, setLoading] = React.useState(true); const [settings, setSettings] = React.useState({ refresh: '10', retention: '24', showCodes: true, sound: false, density: 'cozy', }); // Modals / panels const [domainOpen, setDomainOpen] = React.useState(false); const [addOpen, setAddOpen] = React.useState(false); const [manageOpen, setManageOpen] = React.useState(false); const [settingsOpen, setSettingsOpen] = React.useState(false); const [shortcutsOpen, setShortcutsOpen] = React.useState(false); const [qrOpen, setQrOpen] = React.useState(false); const [dnsModal, setDnsModal] = React.useState(null); // { domain, records } | null const addressInputRef = React.useRef(null); const selected = emails.find((e) => e.id === selectedId); const fullAddress = `${localPart}@${currentDomain}`; // Address input focus + selection state. // addressFocused = input has keyboard focus → "CHANGE" becomes "SAVE" // addressFullySelected = entire local-part is highlighted → @domain mirrors the selection styling const [addressFocused, setAddressFocused] = React.useState(false); const [addressFullySelected, setAddressFullySelected] = React.useState(false); // Draft state: the input is bound to draftLocalPart, NOT localPart. // The committed localPart only changes on explicit save (Enter / SAVE click / // dice button). Blurring without commit reverts the draft. // This also closes a real privacy issue: previously, every keystroke fired // GET /api/inbox?address=… which pollutes the DB with claim rows AND leaks // address existence (200 vs 403 vs 409) to anyone with a valid code. const [draftLocalPart, setDraftLocalPart] = React.useState(localPart); React.useEffect(() => { if (!addressFocused) setDraftLocalPart(localPart); }, [localPart, addressFocused]); const showToast = (msg, kind = 'success') => { setToast({ msg, kind }); setTimeout(() => setToast(null), 1800); }; const doCopy = (text, label = 'Copied to clipboard') => { navigator.clipboard?.writeText(text); showToast(label); }; // Initial domain load React.useEffect(() => { let cancelled = false; tmApi.customer.domains().then((d) => { if (cancelled) return; setDomains(d); // Prefer the relay's own shared domain if present, else first shared const preferred = d.shared.find((s) => s.domain === 'zeromailer.cloud') || d.shared[0]; if (preferred && !d.custom.some((c) => c.domain === currentDomain)) { setCurrentDomain(preferred.domain); } }).catch((e) => showToast('Could not load domains: ' + e.message, 'error')); return () => { cancelled = true; }; }, []); // Fetch inbox whenever address changes. Auto-refresh calls this with // silent=true. We MUST preserve any previously-fetched body/headers/ // attachments on existing rows — the list endpoint doesn't return them // and overwriting would wipe the open email's content. const loadInbox = React.useCallback(async (silent = false) => { if (!silent) setLoading(true); try { const res = await tmApi.customer.inbox(`${localPart}@${currentDomain}`); setEmails((prev) => { const prevIds = new Set(prev.map((e) => e.id)); const prevById = new Map(prev.map((e) => [e.id, e])); const next = res.emails.map((e) => { const old = prevById.get(e.id); return { ...e, body: old?.body, headers: old?.headers, attachments: old?.attachments, toAddress: old?.toAddress ?? e.toAddress, isNew: !prevIds.has(e.id) && silent, }; }); if (silent) { const newCount = next.filter((e) => e.isNew).length; if (newCount > 0) showToast(`${newCount} new message${newCount > 1 ? 's' : ''}`); } return next; }); } catch (e) { if (e.code === 'address_taken') showToast('Address in use by another key', 'error'); else if (e.code !== 'http_401') showToast('Inbox load failed: ' + e.message, 'error'); } finally { setLoading(false); } }, [localPart, currentDomain]); React.useEffect(() => { setSelectedId(null); loadInbox(false); }, [localPart, currentDomain]); // Auto-refresh — runs every settings.refresh seconds. Browsers throttle // setInterval in background tabs (down to 1/min in Chrome), so we ALSO // refresh on visibilitychange so the inbox catches up the moment the user // tabs back in. React.useEffect(() => { if (settings.refresh === 'off') return; const ms = Number(settings.refresh) * 1000; const t = setInterval(() => loadInbox(true), ms); const onVis = () => { if (document.visibilityState === 'visible') loadInbox(true); }; document.addEventListener('visibilitychange', onVis); return () => { clearInterval(t); document.removeEventListener('visibilitychange', onVis); }; }, [settings.refresh, loadInbox]); // Also refresh when the window regains focus (covers cmd-tabbing out) React.useEffect(() => { const onFocus = () => loadInbox(true); window.addEventListener('focus', onFocus); return () => window.removeEventListener('focus', onFocus); }, [loadInbox]); const doRefresh = async () => { if (refreshing) return; setRefreshing(true); const before = emails.length; await loadInbox(true); setRefreshing(false); setEmails((curr) => { if (curr.length === before) showToast('No new mail'); return curr; }); }; const doDelete = async () => { if (!selected) return; const idx = emails.findIndex((e) => e.id === selectedId); try { await tmApi.customer.deleteEmail(selectedId); const newList = emails.filter((e) => e.id !== selectedId); setEmails(newList); setSelectedId(newList[Math.min(idx, newList.length - 1)]?.id || null); showToast('Email deleted'); } catch (e) { showToast('Delete failed: ' + e.message, 'error'); } }; const doSave = async () => { if (!selected) return; try { const res = await tmApi.customer.toggleSave(selectedId); setEmails((prev) => prev.map((e) => e.id === selectedId ? { ...e, saved: res.saved } : e)); showToast(res.saved ? 'Saved' : 'Removed from saved'); } catch (e) { showToast('Save failed: ' + e.message, 'error'); } }; const doRandomAddress = () => { const next = (RANDOM_ADDRESSES && RANDOM_ADDRESSES.length) ? RANDOM_ADDRESSES[Math.floor(Math.random() * RANDOM_ADDRESSES.length)] : 'mb' + Math.floor(Math.random() * 10000); setLocalPart(next); setDraftLocalPart(next); showToast(`Saved · ${next}@${currentDomain}`); }; // Commit whatever's in the draft to the live address. Returns whether // something actually changed so callers can decide on toast/feedback. const commitDraft = () => { const v = (draftLocalPart || '').trim(); if (!v) { // Empty: revert silently setDraftLocalPart(localPart); return false; } if (v === localPart) return false; setLocalPart(v); showToast(`Saved · ${v}@${currentDomain}`); return true; }; const addDomain = async (d, status) => { try { await tmApi.customer.addDomain(d); const fresh = await tmApi.customer.domains(); setDomains(fresh); if (status === 'verified') { setCurrentDomain(d); showToast(`@${d} added and selected`); } else { showToast(`@${d} saved as pending — verify DNS to activate`); } } catch (e) { showToast('Add domain failed: ' + e.message, 'error'); } }; const removeDomain = async (id) => { try { await tmApi.customer.removeDomain(id); const fresh = await tmApi.customer.domains(); setDomains(fresh); showToast('Domain removed'); } catch (e) { showToast('Remove failed: ' + e.message, 'error'); } }; const reverifyDomain = async (id, domain) => { try { const res = await tmApi.customer.verifyDomain(id); const fresh = await tmApi.customer.domains(); setDomains(fresh); if (res.status === 'verified') showToast(`@${domain} verified ✓`); else showToast(`@${domain} still not verified — DNS may need more time`, 'error'); } catch (e) { showToast('Re-verify failed: ' + e.message, 'error'); } }; const showDnsForDomain = (d) => { if (!d.verificationRecords) { showToast('DNS records not available — re-open this menu after the domain list refreshes', 'error'); return; } setDnsModal({ domain: d.domain, records: d.verificationRecords }); }; const toggleFav = (id, kind) => { setDomains((prev) => ({ ...prev, [kind]: prev[kind].map((d) => d.id === id ? { ...d, favorited: !d.favorited } : d), })); }; // When an email is selected, fetch its full body (the list endpoint // doesn't include body to keep responses small) React.useEffect(() => { if (!selectedId) return; const item = emails.find((e) => e.id === selectedId); if (!item || item.body !== undefined) return; tmApi.customer.email(selectedId).then((full) => { setEmails((prev) => prev.map((e) => e.id === selectedId ? { ...e, ...full, unread: false } : e)); }).catch(() => {}); }, [selectedId]); // Keyboard shortcuts React.useEffect(() => { const onKey = (e) => { if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; if (e.metaKey || e.ctrlKey || e.altKey) return; const k = e.key.toLowerCase(); if (k === 'r') { e.preventDefault(); doRefresh(); } else if (k === 'n') { e.preventDefault(); addressInputRef.current?.focus(); addressInputRef.current?.select(); } else if (k === 'd') { e.preventDefault(); doDelete(); } else if (k === 'j') { e.preventDefault(); navigateEmail(1); } else if (k === 'k') { e.preventDefault(); navigateEmail(-1); } else if (k === '/') { e.preventDefault(); addressInputRef.current?.focus(); addressInputRef.current?.select(); } else if (k === '?') { e.preventDefault(); setShortcutsOpen(true); } else if (k === 'c') { e.preventDefault(); doCopy(fullAddress, 'Address copied'); } else if (k === 'g') { e.preventDefault(); setDomainOpen(true); } else if (k === 'escape') { setDomainOpen(false); setAddOpen(false); setManageOpen(false); setSettingsOpen(false); setShortcutsOpen(false); setQrOpen(false); } }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }); const navigateEmail = (dir) => { const idx = emails.findIndex((e) => e.id === selectedId); const next = emails[Math.max(0, Math.min(emails.length - 1, idx + dir))]; if (next) setSelectedId(next.id); }; const density = settings.density; const rowPadY = density === 'compact' ? 8 : density === 'roomy' ? 18 : 12; // Resizable split between list and preview. Persist user's preferred ratio // to localStorage. Constrained to 20%..80% so neither side collapses. const [splitPct, setSplitPct] = React.useState(() => { const saved = parseFloat(localStorage.getItem('tm_split_pct') ?? ''); return Number.isFinite(saved) && saved >= 20 && saved <= 80 ? saved : 40; }); const splitContainerRef = React.useRef(null); const draggingRef = React.useRef(false); React.useEffect(() => { const onMove = (e) => { if (!draggingRef.current || !splitContainerRef.current) return; const rect = splitContainerRef.current.getBoundingClientRect(); const pct = ((e.clientX - rect.left) / rect.width) * 100; const clamped = Math.max(20, Math.min(80, pct)); setSplitPct(clamped); }; const onUp = () => { if (!draggingRef.current) return; draggingRef.current = false; document.body.style.cursor = ''; document.body.style.userSelect = ''; }; window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp); return () => { window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); }; }, []); React.useEffect(() => { localStorage.setItem('tm_split_pct', String(splitPct)); }, [splitPct]); const startDrag = (e) => { e.preventDefault(); draggingRef.current = true; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; }; return (