// 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 (
{/* TOP BAR */}
{/* Left — SMTP status */}
SMTP · {currentDomain}
{/* Center — address input. The local-part is editable; the @domain visually mirrors the input's selection state and is included in copy when the whole input is highlighted. */}
{ if (e.target.tagName !== 'BUTTON') addressInputRef.current?.focus(); }} style={{ display: 'flex', alignItems: 'center', background: '#141414', border: `1px solid ${addressFocused ? '#2a4a3f' : '#1f1f1f'}`, borderRadius: 6, padding: '0 10px', height: 34, transition: 'border-color 120ms', }} > $ setDraftLocalPart(e.target.value.replace(/[^a-z0-9._-]/gi, '').toLowerCase())} onFocus={() => setAddressFocused(true)} onBlur={() => { setAddressFocused(false); setAddressFullySelected(false); }} onSelect={(e) => { const t = e.target; setAddressFullySelected( t.selectionStart === 0 && t.selectionEnd === t.value.length && t.value.length > 0, ); }} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); commitDraft(); e.target.blur(); } else if (e.key === 'Escape') { e.preventDefault(); setDraftLocalPart(localPart); e.target.blur(); } }} onCopy={(e) => { const t = e.target; if (t.selectionStart === 0 && t.selectionEnd === t.value.length && t.value.length > 0) { e.preventDefault(); e.clipboardData.setData('text/plain', `${t.value}@${currentDomain}`); } }} style={{ flex: 1, background: 'none', border: 'none', outline: 'none', color: '#EDEDED', fontFamily: "'JetBrains Mono', monospace", fontSize: 13 }} /> { // Click the @domain → focus + select the whole local-part so // copy/paste captures the full email. e.preventDefault(); e.stopPropagation(); addressInputRef.current?.focus(); addressInputRef.current?.select(); }} style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: 13, padding: '2px 4px', borderRadius: 2, background: addressFullySelected ? '#10B981' : 'transparent', color: addressFullySelected ? '#0A0A0A' : '#565656', cursor: 'text', userSelect: 'none', transition: 'background 80ms, color 80ms', }} >@{currentDomain}
{/* Right — domain selector + icons + count */}
doCopy(fullAddress, 'Address copied')} title="Copy address (C)"> setQrOpen(true)} title="QR code"> setSettingsOpen(true)} title="Settings">
{emails.length} {emails.length === 1 ? 'msg' : 'msgs'}
setDomainOpen(false)} domains={domains} currentDomain={currentDomain} onSelect={(d) => { setCurrentDomain(d); setDomainOpen(false); showToast(`Switched to @${d}`); }} onAddDomain={() => { setDomainOpen(false); setAddOpen(true); }} onToggleFav={toggleFav} onShowDns={(d) => { setDomainOpen(false); showDnsForDomain(d); }} onReverify={(d) => { setDomainOpen(false); reverifyDomain(d.id, d.domain); }} onRemove={(d) => { setDomainOpen(false); removeDomain(d.id); }} />
{/* ACTION BAR — second slot toggles between CHANGE (idle) and SAVE (editing) */}
} label="REFRESH" hotkey="R" onClick={doRefresh} active={refreshing} /> : } label={addressFocused ? 'SAVE' : 'CHANGE'} hotkey={addressFocused ? '↵' : 'N'} // Stop the browser's default focus-shift on mousedown when the input // is currently focused — otherwise blur fires before our onClick, // re-rendering the button as "CHANGE" so the click bounces straight // back to focus. onMouseDown={(e) => { if (addressFocused) e.preventDefault(); }} onClick={() => { if (addressFocused) { commitDraft(); addressInputRef.current?.blur(); } else { addressInputRef.current?.focus(); addressInputRef.current?.select(); } }} active={addressFocused} divider /> } label="DELETE" hotkey="D" onClick={doDelete} divider /> } label="MANAGE" onClick={() => setManageOpen(true)} divider />
{/* MAIN SPLIT — drag the divider to resize */}
{/* Inbox list */}
{/* Column headers */}
# FROM · SUBJECT TIME
{emails.length === 0 ? ( ) : ( emails.map((e, i) => ( setSelectedId(e.id)} onCopyCode={(code) => doCopy(code, `Code ${code} copied`)} rowPadY={rowPadY} showCodes={settings.showCodes} /> )) )}
{/* Drag handle to resize the split */}
setSplitPct(40)} title="Drag to resize · double-click to reset" style={{ cursor: 'col-resize', background: '#1f1f1f', position: 'relative', transition: 'background 120ms', }} onMouseEnter={(e) => (e.currentTarget.style.background = '#2a2a2a')} onMouseLeave={(e) => (e.currentTarget.style.background = '#1f1f1f')} > {/* a tiny grip indicator */}
{/* Preview panel */}
{selected ? doCopy(c, `Code ${c} copied`)} /> : }
{/* Status footer */}
RELAY ONLINE retention {settings.retention}h auto-refresh {settings.refresh === 'off' ? 'off' : settings.refresh + 's'}
v0.4.2
{/* Overlays */} setAddOpen(false)} onAdd={addDomain} /> setManageOpen(false)} currentAddress={fullAddress} onSelectAddress={(addr) => { const [lp, dom] = addr.split('@'); if (lp && dom) { setLocalPart(lp); setDraftLocalPart(lp); setCurrentDomain(dom); showToast(`Switched to ${addr}`); } }} toast={(msg, kind) => showToast(msg, kind)} /> setSettingsOpen(false)} settings={settings} setSettings={setSettings} onShowShortcuts={() => { setSettingsOpen(false); setShortcutsOpen(true); }} onSignOut={async () => { try { await tmApi.customer.logout(); } catch {} onSignOut(); }} codePreview={session?.codePreview} /> setShortcutsOpen(false)} /> setQrOpen(false)} address={fullAddress} /> setDnsModal(null)} domain={dnsModal?.domain} records={dnsModal?.records} /> {/* Toast */} {toast && (
{toast.msg}
)}
); }; const IconBtn = ({ children, onClick, title }) => ( ); const ActionBtn = ({ icon, label, hotkey, onClick, onMouseDown, active, divider }) => ( ); const EmailRow = ({ idx, email, selected, onSelect, onCopyCode, rowPadY, showCodes }) => (
!selected && (e.currentTarget.style.background = '#101010')} onMouseLeave={(e) => !selected && (e.currentTarget.style.background = 'transparent')} > {String(idx + 1).padStart(2, '0')}
{email.avatar}
{email.from}
{email.subject}
{email.code && showCodes ? ( ) : } {email.time}
); const EmailPreview = ({ email, onCopyCode }) => ( <> {/* Preview header */}
{email.subject}
{email.fromEmail}
{email.fullDate}
{email.code && (
CODE DETECTED {email.code}
)}
{/* Set explicit color so plain-text emails (no inline styles) stay readable. HTML emails with their own styles override these defaults. */}
); const Label = ({ children }) => ( {children} ); const EmptyList = () => (
▒ INBOX EMPTY ▒
waiting for mail…
); const EmptyPreview = () => (
Select an email to preview
J/K to navigate · ENTER to open
); window.Inbox = Inbox;