/** * GoingsOn — Shared compose-form contract (Phase 7 Tier 6 #3, stage 1). * * This module is the single source of truth for: * - attachment-cap thresholds (`ATTACHMENT_WARN_CAP_BYTES`, `ATTACHMENT_HARD_CAP_BYTES`) * - the SendEmailInput payload shape sent to `send_email` (camelCase, * matching `commands/email.rs::SendEmailInput`) * - the validation rules every compose surface must enforce * * Both compose surfaces — the desktop `compose.html` window and the in-app * `openComposeModal` — used to maintain their own copies. The drift that * produced is documented in `docs/ux-audit/compose-migration.md`; this * module is stage 1 of that migration. Later stages will collapse the * markup and behaviors; for now, just the data contract. * * The module is safe to load in either context (main app webview *or* * the standalone compose webview) since it touches neither GoingsOn * state nor the DOM. */ (function() { 'use strict'; // ============ Attachment caps ============ // // 25 MB matches the practical SMTP cap (Gmail, Fastmail, Outlook). // Warn from 20 MB so the user has runway to drop a file before the // hard block kicks in. Both surfaces previously hard-coded these; // changing the numbers here changes both at once. const ATTACHMENT_HARD_CAP_BYTES = 25 * 1024 * 1024; const ATTACHMENT_WARN_CAP_BYTES = 20 * 1024 * 1024; function totalAttachmentBytes(files) { return (files || []).reduce((sum, f) => sum + (f && f.size ? f.size : 0), 0); } function exceedsAttachmentCap(files) { const total = totalAttachmentBytes(files); return { totalBytes: total, warn: total > ATTACHMENT_WARN_CAP_BYTES, over: total > ATTACHMENT_HARD_CAP_BYTES, }; } function formatBytes(n) { if (n == null || n < 1024) return (n || 0) + ' B'; if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'; if (n < 1024 * 1024 * 1024) return (n / (1024 * 1024)).toFixed(1) + ' MB'; return (n / (1024 * 1024 * 1024)).toFixed(2) + ' GB'; } // ============ Payload contract ============ // // Build a SendEmailInput from raw form values + reply context + // attachments. The wire shape must match `commands/email.rs::SendEmailInput` // (camelCase via serde). Callers should: // // const input = composeForm.collectInput({ // accountId, toAddress, ccAddress, bccAddress, subject, body, // attachedFiles, replyContext, // }); // const result = composeForm.validate(input); // if (!result.ok) { showError(result.field, result.message); return; } // queueSend({ input }); // // `replyContext` is optional and may contain { inReplyTo, references, threadId }. function collectInput(raw) { const r = raw || {}; const reply = r.replyContext || {}; const trim = (s) => (typeof s === 'string' ? s.trim() : ''); const orNull = (s) => (s && s.length ? s : null); return { accountId: r.accountId || null, toAddress: trim(r.toAddress), ccAddress: orNull(trim(r.ccAddress)), bccAddress: orNull(trim(r.bccAddress)), subject: trim(r.subject), body: typeof r.body === 'string' ? r.body : '', projectId: r.projectId || null, inReplyTo: reply.inReplyTo || null, references: reply.references || null, threadId: reply.threadId || null, attachmentPaths: (r.attachedFiles || []) .map(f => f && f.path) .filter(Boolean), }; } // ============ Validation ============ // // Returns { ok: true } | { ok: false, field, message }. `field` is one // of 'accountId' | 'toAddress' | 'subject' | 'attachments' so each // surface can focus the offending input (or show an inline error). function validate(input) { if (!input || !input.accountId) { return { ok: false, field: 'accountId', message: 'Please select a From account' }; } if (!input.toAddress) { return { ok: false, field: 'toAddress', message: 'Please enter a recipient' }; } if (!input.subject) { return { ok: false, field: 'subject', message: 'Please enter a subject' }; } return { ok: true }; } // Combined check: validate + attachment cap. Use when the caller has // attached files in memory but hasn't folded them into `input` yet. function validateForSend(input, attachedFiles) { const base = validate(input); if (!base.ok) return base; const cap = exceedsAttachmentCap(attachedFiles); if (cap.over) { return { ok: false, field: 'attachments', message: `Attachments exceed ${formatBytes(ATTACHMENT_HARD_CAP_BYTES)} — remove some files or use a file-share link.`, }; } return { ok: true }; } // ============ Shared HTML template (stage 3) ============ // // `buildFieldsHtml(opts)` returns the form-fields HTML used by both the // desktop `compose.html` window and the in-app `openComposeModal`. The // canonical IDs (`from-account`, `to-address`, `cc-row`, `cc-address`, // `bcc-row`, `bcc-address`, `subject`, `body`, `attachments-bar`, // `toggle-cc`) match compose.html's pre-existing inline JS so it // keeps binding by `getElementById` unchanged. The modal opts into the // same IDs to share behaviors. // // Each surface owns its chrome: // - compose.html: outer
, toolbar, status bar // - modal: GoingsOn.modal.openModal wrapper, footer action row // // Options: // accounts: Array<{id, account_name?, email_address?, email?, ...}> — From-select rows // selectedAccountId: id of the option pre-selected in From // accountsLoading: boolean — if true, render a placeholder option // prefill: { to, cc, bcc, subject, body } — pre-filled values // showCcBcc: boolean — if true, CC/BCC rows are visible at render time // showAttachments: boolean — include the #attachments-bar container // bodyRows: number — initial `rows` attribute on the body textarea (modal honours this) function buildFieldsHtml(opts) { const o = opts || {}; const accounts = o.accounts || []; const prefill = o.prefill || {}; const showCcBcc = !!o.showCcBcc; const showAttachments = o.showAttachments !== false; const selectedId = o.selectedAccountId || null; const bodyRows = o.bodyRows || 0; const escHtml = (s) => { const d = (typeof document !== 'undefined') ? document.createElement('div') : null; if (d) { d.textContent = s == null ? '' : String(s); return d.innerHTML; } return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', })[c]); }; const escAttr = (s) => escHtml(s).replace(/"/g, '"'); let accountOptionsHtml; if (o.accountsLoading) { accountOptionsHtml = ''; } else if (accounts.length === 0) { accountOptionsHtml = ''; } else { accountOptionsHtml = accounts.map((a) => { const id = a.id; const name = a.account_name || a.accountName || ''; const addr = a.email_address || a.email || ''; const label = name ? `${name} <${addr}>` : addr; const sel = id === selectedId ? ' selected' : ''; return ``; }).join(''); } const ccRowHidden = showCcBcc ? '' : ' hidden'; const toggleLabel = showCcBcc ? 'Hide CC/BCC' : 'Show CC/BCC'; const bodyRowsAttr = bodyRows ? ` rows="${bodyRows}"` : ''; const attachmentsBlock = showAttachments ? '' : ''; return `
${attachmentsBlock} `; } // ============ Shared behaviors (stage 4) ============ // // `bindBehaviors(opts)` wires every interactive behavior the compose // surfaces share: autocomplete on To/CC/BCC, address-highlight, CC/BCC // show/hide toggle, signature swap on From change, and attachment // picker / render / remove (delegated, no inline onclick). Each surface // mounts `buildFieldsHtml`, then calls this once. // // Returns a controller the surface uses for its chrome: // { pickAttachment, removeAttachment, renderAttachments, // getAttachedFiles, setAttachedFiles, appendSignatureForAccount, // toggleCcBcc } // // Options: // accounts: same shape as buildFieldsHtml // initialSignature: optional string to treat as the "currently appended" sig // (so the first From-change knows what trailing block to strip) // getContacts: () => [{name, email, isImplicit?}] — autocomplete source // onError: (message) => void — surface-specific error sink // (window uses setStatus; modal uses showToast) // onAttachmentsChange: (files) => void — optional callback after picker/remove function bindBehaviors(opts) { const o = opts || {}; const accounts = o.accounts || []; const getContacts = typeof o.getContacts === 'function' ? o.getContacts : () => []; const onError = typeof o.onError === 'function' ? o.onError : null; const onAttachmentsChange = typeof o.onAttachmentsChange === 'function' ? o.onAttachmentsChange : null; const fromEl = document.getElementById('from-account'); const toEl = document.getElementById('to-address'); const ccEl = document.getElementById('cc-address'); const bccEl = document.getElementById('bcc-address'); const ccRow = document.getElementById('cc-row'); const bccRow = document.getElementById('bcc-row'); const toggleBtn = document.getElementById('toggle-cc'); const bodyEl = document.getElementById('body'); const attachmentsBar = document.getElementById('attachments-bar'); let attachedFiles = []; let currentSignature = o.initialSignature || ''; const escHtmlLocal = (s) => { const d = document.createElement('div'); d.textContent = s == null ? '' : String(s); return d.innerHTML; }; const escAttrLocal = (s) => escHtmlLocal(s).replace(/"/g, '"'); // ---------- Autocomplete (self-contained — compose.html doesn't load js/autocomplete.js) ---------- function getLastToken(input) { const val = input.value; const cursor = input.selectionStart || val.length; const before = val.slice(0, cursor); const lastComma = before.lastIndexOf(','); return { token: before.slice(lastComma + 1).trim() }; } function attachAutocomplete(input) { if (!input) return; let dropdown = null; let activeIndex = -1; let matches = []; function hide() { if (dropdown) { dropdown.remove(); dropdown = null; activeIndex = -1; matches = []; } } function selectMatch(email) { const val = input.value; const cursor = input.selectionStart || val.length; const before = val.slice(0, cursor); const after = val.slice(cursor); const lastComma = before.lastIndexOf(','); const prefix = lastComma >= 0 ? before.slice(0, lastComma + 1) + ' ' : ''; input.value = prefix + email + ', ' + after.trimStart(); const newCursor = (prefix + email + ', ').length; input.setSelectionRange(newCursor, newCursor); input.focus(); hide(); } function show(filtered) { hide(); if (filtered.length === 0) return; matches = filtered; dropdown = document.createElement('div'); dropdown.className = 'autocomplete-dropdown'; filtered.forEach((m) => { const item = document.createElement('div'); item.className = 'autocomplete-item'; item.innerHTML = `${escHtmlLocal(m.name)} ${escHtmlLocal(m.email)}`; item.addEventListener('mousedown', (e) => { e.preventDefault(); selectMatch(m.email); }); dropdown.appendChild(item); }); const wrapper = input.parentElement; wrapper.appendChild(dropdown); } input.addEventListener('input', () => { const { token } = getLastToken(input); if (!token) { hide(); return; } const q = token.toLowerCase(); const filtered = (getContacts() || []) .filter(c => (c.email || '').toLowerCase().includes(q) || (c.name || '').toLowerCase().includes(q)) .sort((a, b) => { if (!!a.isImplicit !== !!b.isImplicit) return a.isImplicit ? 1 : -1; const ap = (a.email || '').toLowerCase().startsWith(q) ? 0 : 1; const bp = (b.email || '').toLowerCase().startsWith(q) ? 0 : 1; return ap - bp; }) .slice(0, 8); show(filtered); }); input.addEventListener('blur', () => setTimeout(hide, 150)); input.addEventListener('keydown', (e) => { if (!dropdown) return; const items = dropdown.querySelectorAll('.autocomplete-item'); if (e.key === 'ArrowDown') { e.preventDefault(); activeIndex = Math.min(activeIndex + 1, items.length - 1); items.forEach((el, i) => el.classList.toggle('active', i === activeIndex)); } else if (e.key === 'ArrowUp') { e.preventDefault(); activeIndex = Math.max(activeIndex - 1, 0); items.forEach((el, i) => el.classList.toggle('active', i === activeIndex)); } else if (e.key === 'Enter' || e.key === 'Tab') { if (activeIndex >= 0 && activeIndex < matches.length) { e.preventDefault(); selectMatch(matches[activeIndex].email); } } else if (e.key === 'Escape') { hide(); } }); } attachAutocomplete(toEl); attachAutocomplete(ccEl); attachAutocomplete(bccEl); // ---------- Address highlight (uses whichever helper the surface loaded) ---------- const ahAttach = (window.GoingsOn && window.GoingsOn.addressHighlight && window.GoingsOn.addressHighlight.attach) || (typeof window.attachAddressHighlight === 'function' ? window.attachAddressHighlight : null); if (ahAttach) { const ahOpts = { contacts: getContacts }; // Tauri's `invoke` is needed by the IMAP-backed lookup in // address-highlight.js; pass through if present. if (window.__TAURI__ && window.__TAURI__.core && window.__TAURI__.core.invoke) { ahOpts.invoke = window.__TAURI__.core.invoke; } [toEl, ccEl, bccEl].forEach(el => { if (el) ahAttach(el, ahOpts); }); } // ---------- CC/BCC toggle ---------- function toggleCcBcc() { if (!ccRow || !bccRow || !toggleBtn) return; const visible = !ccRow.classList.contains('hidden'); ccRow.classList.toggle('hidden', visible); bccRow.classList.toggle('hidden', visible); toggleBtn.textContent = visible ? 'Show CC/BCC' : 'Hide CC/BCC'; } if (toggleBtn) toggleBtn.addEventListener('click', toggleCcBcc); // ---------- Signature swap on From change ---------- function appendSignatureForAccount(accountId) { if (!bodyEl) return; let body = bodyEl.value; if (currentSignature) { const block = '\n\n-- \n' + currentSignature; if (body.endsWith(block)) body = body.slice(0, -block.length); } const account = accounts.find(a => a.id === accountId); const sig = (account && (account.emailSignature || account.email_signature)) || ''; currentSignature = sig; bodyEl.value = body + (sig ? '\n\n-- \n' + sig : ''); } if (fromEl) fromEl.addEventListener('change', (e) => appendSignatureForAccount(e.target.value)); // ---------- Attachments (picker, render, remove) ---------- function renderAttachments() { if (!attachmentsBar) return; if (attachedFiles.length === 0) { attachmentsBar.classList.add('hidden'); attachmentsBar.innerHTML = ''; return; } const items = attachedFiles.map((f, i) => `
${escHtmlLocal(f.name)} ${formatBytes(f.size || 0)}
`).join(''); const cap = exceedsAttachmentCap(attachedFiles); const cls = cap.over ? 'compose-attachment-total over-cap' : cap.warn ? 'compose-attachment-total over-warn' : 'compose-attachment-total'; const warn = cap.over ? '— most mail servers will reject this. Remove some files or use a file-share link.' : cap.warn ? '— large attachment; your mail server may reject this.' : ''; const totalLine = `
Total: ${formatBytes(cap.totalBytes)} / ${formatBytes(ATTACHMENT_HARD_CAP_BYTES)}${warn}
`; attachmentsBar.classList.remove('hidden'); attachmentsBar.innerHTML = items + totalLine; } if (attachmentsBar) { // Event delegation on the bar — survives re-renders without re-binding. attachmentsBar.addEventListener('click', (e) => { const btn = e.target && e.target.closest && e.target.closest('[data-compose-remove-attachment]'); if (!btn) return; const idx = Number(btn.getAttribute('data-compose-remove-attachment')); if (!Number.isInteger(idx)) return; removeAttachment(idx); }); } function removeAttachment(index) { if (index < 0 || index >= attachedFiles.length) return; attachedFiles.splice(index, 1); renderAttachments(); if (onAttachmentsChange) onAttachmentsChange(attachedFiles); } async function pickAttachment() { try { if (!window.__TAURI__ || !window.__TAURI__.dialog) { if (onError) onError('File picker unavailable'); return; } const { open } = window.__TAURI__.dialog; const selected = await open({ multiple: true, title: 'Select files to attach' }); if (!selected) return; const paths = Array.isArray(selected) ? selected : [selected]; const invoke = window.__TAURI__.core && window.__TAURI__.core.invoke; for (const p of paths) { const filePath = typeof p === 'string' ? p : p && p.path; if (!filePath) continue; if (attachedFiles.some(f => f.path === filePath)) continue; const name = filePath.split(/[/\\]/).pop() || 'file'; let size = 0; if (invoke) { try { size = await invoke('get_file_size', { filePath }); } catch (_) { /* leave 0 */ } } attachedFiles.push({ path: filePath, name, size }); } renderAttachments(); if (onAttachmentsChange) onAttachmentsChange(attachedFiles); } catch (err) { if (err && String(err).includes('cancelled')) return; if (onError) onError('Failed to pick file: ' + err); } } renderAttachments(); // ---------- Draft autosave + reply indicator (stage 5) ---------- const enableAutosave = !!o.enableAutosave; const saveDraft = typeof o.saveDraft === 'function' ? o.saveDraft : null; const getReplyContext = typeof o.getReplyContext === 'function' ? o.getReplyContext : () => ({}); const onDraftStatus = typeof o.onDraftStatus === 'function' ? o.onDraftStatus : null; const enableReplyIndicator = !!o.enableReplyIndicator; let currentDraftId = o.initialDraftId || null; let autosaveTimer = null; let isSending = false; function collectDraftInput() { const rc = getReplyContext() || {}; const subjEl = document.getElementById('subject'); const trim = (s) => (typeof s === 'string' ? s.trim() : ''); const orNull = (s) => (s && s.length ? s : null); return { id: currentDraftId || null, accountId: (fromEl && fromEl.value) || null, toAddress: orNull(trim(toEl && toEl.value)), ccAddress: orNull(trim(ccEl && ccEl.value)), bccAddress: orNull(trim(bccEl && bccEl.value)), subject: orNull(trim(subjEl && subjEl.value)), body: (bodyEl && bodyEl.value) || null, inReplyTo: rc.inReplyTo || null, references: rc.references || null, threadId: rc.threadId || null, }; } function hasContent() { const subjEl = document.getElementById('subject'); const to = (toEl && toEl.value.trim()) || ''; const subject = (subjEl && subjEl.value.trim()) || ''; const body = (bodyEl && bodyEl.value.trim()) || ''; // Signature-only body shouldn't trigger autosave. const sigOnly = currentSignature && body === '-- \n' + currentSignature; return !!(to || subject || (body && !sigOnly)); } async function autosaveNow() { if (!enableAutosave || !saveDraft) return; if (isSending || !hasContent()) return; try { const result = await saveDraft(collectDraftInput()); if (result && result.id) currentDraftId = result.id; if (onDraftStatus) onDraftStatus('saved', 'Draft auto-saved'); } catch (_) { // Silent — manual save still works. } } function scheduleAutosave() { if (!enableAutosave || isSending) return; if (autosaveTimer) clearTimeout(autosaveTimer); autosaveTimer = setTimeout(autosaveNow, 2000); } async function saveDraftNow() { if (!saveDraft) return null; if (autosaveTimer) { clearTimeout(autosaveTimer); autosaveTimer = null; } try { const result = await saveDraft(collectDraftInput()); if (result && result.id) currentDraftId = result.id; if (onDraftStatus) onDraftStatus('saved', 'Draft saved!'); return result; } catch (err) { if (onDraftStatus) onDraftStatus('error', 'Failed to save draft: ' + err); return null; } } function cancelAutosave() { if (autosaveTimer) { clearTimeout(autosaveTimer); autosaveTimer = null; } } function setSending(b) { isSending = !!b; if (isSending) cancelAutosave(); } if (enableAutosave) { const autosaveIds = ['to-address', 'cc-address', 'bcc-address', 'subject', 'body']; for (const id of autosaveIds) { const el = document.getElementById(id); if (el) el.addEventListener('input', scheduleAutosave); } if (fromEl) fromEl.addEventListener('change', scheduleAutosave); } function updateReplyIndicator() { if (!enableReplyIndicator) return; const rc = getReplyContext() || {}; const isReply = !!rc.inReplyTo; const indicator = document.getElementById('reply-indicator'); if (indicator) { indicator.style.display = isReply ? 'inline' : 'none'; indicator.textContent = isReply ? 'Replying to thread' : ''; } // Both surfaces' send buttons (compose.html: #send-btn, modal: #compose-modal-send) // get the "Send Reply" relabel. const sendBtn = document.getElementById('send-btn') || document.getElementById('compose-modal-send'); if (sendBtn) sendBtn.textContent = isReply ? 'Send Reply' : 'Send'; } updateReplyIndicator(); return { pickAttachment, removeAttachment, renderAttachments, getAttachedFiles: () => attachedFiles, setAttachedFiles: (arr) => { attachedFiles = Array.isArray(arr) ? arr.slice() : []; renderAttachments(); }, appendSignatureForAccount, getCurrentSignature: () => currentSignature, toggleCcBcc, // Stage 5 scheduleAutosave, saveDraftNow, cancelAutosave, setSending, getCurrentDraftId: () => currentDraftId, setCurrentDraftId: (id) => { currentDraftId = id || null; }, updateReplyIndicator, }; } // ============ Export ============ const api = { ATTACHMENT_HARD_CAP_BYTES, ATTACHMENT_WARN_CAP_BYTES, totalAttachmentBytes, exceedsAttachmentCap, formatBytes, collectInput, validate, validateForSend, buildFieldsHtml, bindBehaviors, }; // Compose.html is a standalone webview that doesn't bootstrap the // GoingsOn namespace, so we attach to window as well for that path. if (typeof window !== 'undefined') { window.GoingsOnComposeForm = api; if (window.GoingsOn) { window.GoingsOn.composeForm = api; } } })();