Skip to main content

max / goingson

29.0 KB · 623 lines History Blame Raw
1 /**
2 * GoingsOn — Shared compose-form contract (Phase 7 Tier 6 #3, stage 1).
3 *
4 * This module is the single source of truth for:
5 * - attachment-cap thresholds (`ATTACHMENT_WARN_CAP_BYTES`, `ATTACHMENT_HARD_CAP_BYTES`)
6 * - the SendEmailInput payload shape sent to `send_email` (camelCase,
7 * matching `commands/email.rs::SendEmailInput`)
8 * - the validation rules every compose surface must enforce
9 *
10 * Both compose surfaces — the desktop `compose.html` window and the in-app
11 * `openComposeModal` — used to maintain their own copies. The drift that
12 * produced is documented in `docs/ux-audit/compose-migration.md`; this
13 * module is stage 1 of that migration. Later stages will collapse the
14 * markup and behaviors; for now, just the data contract.
15 *
16 * The module is safe to load in either context (main app webview *or*
17 * the standalone compose webview). Its only dependency is `GoingsOn.escape`
18 * (js/escape.js), which both compose.html and index.html load before this
19 * file — so escaping is single-source and gate-covered in both surfaces.
20 */
21 (function() {
22 'use strict';
23
24 // ============ Attachment caps ============
25 //
26 // 25 MB matches the practical SMTP cap (Gmail, Fastmail, Outlook).
27 // Warn from 20 MB so the user has runway to drop a file before the
28 // hard block kicks in. Both surfaces previously hard-coded these;
29 // changing the numbers here changes both at once.
30 const ATTACHMENT_HARD_CAP_BYTES = 25 * 1024 * 1024;
31 const ATTACHMENT_WARN_CAP_BYTES = 20 * 1024 * 1024;
32
33 // Escaping goes through the shared single-source primitives (js/escape.js),
34 // loaded before this file in both compose.html and the main app, so the
35 // compose window is covered by the same CHRONIC-XSS enforcement gate.
36 // `esc` = text/innerHTML context; `escAttr` = double-quoted attribute value.
37 const esc = GoingsOn.escape.escapeHtml;
38 const escAttr = GoingsOn.escape.escapeAttrValue;
39
40 function totalAttachmentBytes(files) {
41 return (files || []).reduce((sum, f) => sum + (f && f.size ? f.size : 0), 0);
42 }
43
44 function exceedsAttachmentCap(files) {
45 const total = totalAttachmentBytes(files);
46 return {
47 totalBytes: total,
48 warn: total > ATTACHMENT_WARN_CAP_BYTES,
49 over: total > ATTACHMENT_HARD_CAP_BYTES,
50 };
51 }
52
53 function formatBytes(n) {
54 if (n == null || n < 1024) return (n || 0) + ' B';
55 if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
56 if (n < 1024 * 1024 * 1024) return (n / (1024 * 1024)).toFixed(1) + ' MB';
57 return (n / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
58 }
59
60 // ============ Payload contract ============
61 //
62 // Build a SendEmailInput from raw form values + reply context +
63 // attachments. The wire shape must match `commands/email.rs::SendEmailInput`
64 // (camelCase via serde). Callers should:
65 //
66 // const input = composeForm.collectInput({
67 // accountId, toAddress, ccAddress, bccAddress, subject, body,
68 // attachedFiles, replyContext,
69 // });
70 // const result = composeForm.validate(input);
71 // if (!result.ok) { showError(result.field, result.message); return; }
72 // queueSend({ input });
73 //
74 // `replyContext` is optional and may contain { inReplyTo, references, threadId }.
75 function collectInput(raw) {
76 const r = raw || {};
77 const reply = r.replyContext || {};
78 const trim = (s) => (typeof s === 'string' ? s.trim() : '');
79 const orNull = (s) => (s && s.length ? s : null);
80
81 return {
82 accountId: r.accountId || null,
83 toAddress: trim(r.toAddress),
84 ccAddress: orNull(trim(r.ccAddress)),
85 bccAddress: orNull(trim(r.bccAddress)),
86 subject: trim(r.subject),
87 body: typeof r.body === 'string' ? r.body : '',
88 projectId: r.projectId || null,
89 inReplyTo: reply.inReplyTo || null,
90 references: reply.references || null,
91 threadId: reply.threadId || null,
92 attachmentPaths: (r.attachedFiles || [])
93 .map(f => f && f.path)
94 .filter(Boolean),
95 };
96 }
97
98 // ============ Validation ============
99 //
100 // Returns { ok: true } | { ok: false, field, message }. `field` is one
101 // of 'accountId' | 'toAddress' | 'subject' | 'attachments' so each
102 // surface can focus the offending input (or show an inline error).
103 function validate(input) {
104 if (!input || !input.accountId) {
105 return { ok: false, field: 'accountId', message: 'Please select a From account' };
106 }
107 if (!input.toAddress) {
108 return { ok: false, field: 'toAddress', message: 'Please enter a recipient' };
109 }
110 if (!input.subject) {
111 return { ok: false, field: 'subject', message: 'Please enter a subject' };
112 }
113 return { ok: true };
114 }
115
116 // Combined check: validate + attachment cap. Use when the caller has
117 // attached files in memory but hasn't folded them into `input` yet.
118 function validateForSend(input, attachedFiles) {
119 const base = validate(input);
120 if (!base.ok) return base;
121 const cap = exceedsAttachmentCap(attachedFiles);
122 if (cap.over) {
123 return {
124 ok: false,
125 field: 'attachments',
126 message: `Attachments exceed ${formatBytes(ATTACHMENT_HARD_CAP_BYTES)} — remove some files or use a file-share link.`,
127 };
128 }
129 return { ok: true };
130 }
131
132 // ============ Shared HTML template (stage 3) ============
133 //
134 // `buildFieldsHtml(opts)` returns the form-fields HTML used by both the
135 // desktop `compose.html` window and the in-app `openComposeModal`. The
136 // canonical IDs (`from-account`, `to-address`, `cc-row`, `cc-address`,
137 // `bcc-row`, `bcc-address`, `subject`, `body`, `attachments-bar`,
138 // `toggle-cc`) match compose.html's pre-existing inline JS so it
139 // keeps binding by `getElementById` unchanged. The modal opts into the
140 // same IDs to share behaviors.
141 //
142 // Each surface owns its chrome:
143 // - compose.html: outer <form>, toolbar, status bar
144 // - modal: GoingsOn.modal.openModal wrapper, footer action row
145 //
146 // Options:
147 // accounts: Array<{id, account_name?, email_address?, email?, ...}> — From-select rows
148 // selectedAccountId: id of the option pre-selected in From
149 // accountsLoading: boolean — if true, render a placeholder option
150 // prefill: { to, cc, bcc, subject, body } — pre-filled values
151 // showCcBcc: boolean — if true, CC/BCC rows are visible at render time
152 // showAttachments: boolean — include the #attachments-bar container
153 // bodyRows: number — initial `rows` attribute on the body textarea (modal honours this)
154 function buildFieldsHtml(opts) {
155 const o = opts || {};
156 const accounts = o.accounts || [];
157 const prefill = o.prefill || {};
158 const showCcBcc = !!o.showCcBcc;
159 const showAttachments = o.showAttachments !== false;
160 const selectedId = o.selectedAccountId || null;
161 const bodyRows = o.bodyRows || 0;
162
163 let accountOptionsHtml;
164 if (o.accountsLoading) {
165 accountOptionsHtml = '<option value="">Loading accounts…</option>';
166 } else if (accounts.length === 0) {
167 accountOptionsHtml = '<option value="">No email accounts configured</option>';
168 } else {
169 accountOptionsHtml = accounts.map((a) => {
170 const id = a.id;
171 const name = a.account_name || a.accountName || '';
172 const addr = a.email_address || a.email || '';
173 const label = name ? `${name} <${addr}>` : addr;
174 const sel = id === selectedId ? ' selected' : '';
175 return `<option value="${escAttr(id)}"${sel}>${esc(label)}</option>`;
176 }).join('');
177 }
178
179 const ccRowHidden = showCcBcc ? '' : ' hidden';
180 const toggleLabel = showCcBcc ? 'Hide CC/BCC' : 'Show CC/BCC';
181 const bodyRowsAttr = bodyRows ? ` rows="${bodyRows}"` : '';
182
183 const attachmentsBlock = showAttachments
184 ? '<div class="compose-attachments hidden" id="attachments-bar"></div>'
185 : '';
186
187 return `
188 <div class="header-row">
189 <label class="header-label" for="from-account">From:</label>
190 <select class="form-select form-select--ghost flex-1" id="from-account" name="from-account" required>
191 ${accountOptionsHtml}
192 </select>
193 </div>
194 <div class="header-row">
195 <label class="header-label" for="to-address">To:</label>
196 <div class="autocomplete-wrapper">
197 <input type="text" class="form-input form-input--ghost flex-1" id="to-address" name="to-address" placeholder="recipient@example.com (comma-separated)" required autocomplete="off" value="${escAttr(prefill.to || '')}">
198 </div>
199 </div>
200 <div class="header-row${ccRowHidden}" id="cc-row">
201 <label class="header-label" for="cc-address">CC:</label>
202 <div class="autocomplete-wrapper">
203 <input type="text" class="form-input form-input--ghost flex-1" id="cc-address" name="cc-address" placeholder="cc@example.com (comma-separated)" autocomplete="off" value="${escAttr(prefill.cc || '')}">
204 </div>
205 </div>
206 <div class="header-row${ccRowHidden}" id="bcc-row">
207 <label class="header-label" for="bcc-address">BCC:</label>
208 <div class="autocomplete-wrapper">
209 <input type="text" class="form-input form-input--ghost flex-1" id="bcc-address" name="bcc-address" placeholder="bcc@example.com (comma-separated)" autocomplete="off" value="${escAttr(prefill.bcc || '')}">
210 </div>
211 </div>
212 <div class="header-row header-row--tight">
213 <span class="header-label"></span>
214 <button type="button" id="toggle-cc" class="btn-link">${toggleLabel}</button>
215 </div>
216 <div class="header-row">
217 <label class="header-label" for="subject">Subject:</label>
218 <input type="text" class="form-input form-input--ghost flex-1" id="subject" name="subject" placeholder="Subject" value="${escAttr(prefill.subject || '')}">
219 </div>
220 <div class="body-container">
221 <textarea class="body-textarea" id="body" name="body"${bodyRowsAttr} placeholder="Write your message...">${esc(prefill.body || '')}</textarea>
222 </div>
223 ${attachmentsBlock}
224 `;
225 }
226
227 // ============ Shared behaviors (stage 4) ============
228 //
229 // `bindBehaviors(opts)` wires every interactive behavior the compose
230 // surfaces share: autocomplete on To/CC/BCC, address-highlight, CC/BCC
231 // show/hide toggle, signature swap on From change, and attachment
232 // picker / render / remove (delegated, no inline onclick). Each surface
233 // mounts `buildFieldsHtml`, then calls this once.
234 //
235 // Returns a controller the surface uses for its chrome:
236 // { pickAttachment, removeAttachment, renderAttachments,
237 // getAttachedFiles, setAttachedFiles, appendSignatureForAccount,
238 // toggleCcBcc }
239 //
240 // Options:
241 // accounts: same shape as buildFieldsHtml
242 // initialSignature: optional string to treat as the "currently appended" sig
243 // (so the first From-change knows what trailing block to strip)
244 // getContacts: () => [{name, email, isImplicit?}] — autocomplete source
245 // onError: (message) => void — surface-specific error sink
246 // (window uses setStatus; modal uses showToast)
247 // onAttachmentsChange: (files) => void — optional callback after picker/remove
248 function bindBehaviors(opts) {
249 const o = opts || {};
250 const accounts = o.accounts || [];
251 const getContacts = typeof o.getContacts === 'function' ? o.getContacts : () => [];
252 const onError = typeof o.onError === 'function' ? o.onError : null;
253 const onAttachmentsChange = typeof o.onAttachmentsChange === 'function' ? o.onAttachmentsChange : null;
254
255 const fromEl = document.getElementById('from-account');
256 const toEl = document.getElementById('to-address');
257 const ccEl = document.getElementById('cc-address');
258 const bccEl = document.getElementById('bcc-address');
259 const ccRow = document.getElementById('cc-row');
260 const bccRow = document.getElementById('bcc-row');
261 const toggleBtn = document.getElementById('toggle-cc');
262 const bodyEl = document.getElementById('body');
263 const attachmentsBar = document.getElementById('attachments-bar');
264
265 let attachedFiles = [];
266 let currentSignature = o.initialSignature || '';
267
268 // ---------- Autocomplete (self-contained — compose.html doesn't load js/autocomplete.js) ----------
269 function getLastToken(input) {
270 const val = input.value;
271 const cursor = input.selectionStart || val.length;
272 const before = val.slice(0, cursor);
273 const lastComma = before.lastIndexOf(',');
274 return { token: before.slice(lastComma + 1).trim() };
275 }
276
277 function attachAutocomplete(input) {
278 if (!input) return;
279 let dropdown = null;
280 let activeIndex = -1;
281 let matches = [];
282
283 function hide() {
284 if (dropdown) { dropdown.remove(); dropdown = null; activeIndex = -1; matches = []; }
285 }
286
287 function selectMatch(email) {
288 const val = input.value;
289 const cursor = input.selectionStart || val.length;
290 const before = val.slice(0, cursor);
291 const after = val.slice(cursor);
292 const lastComma = before.lastIndexOf(',');
293 const prefix = lastComma >= 0 ? before.slice(0, lastComma + 1) + ' ' : '';
294 input.value = prefix + email + ', ' + after.trimStart();
295 const newCursor = (prefix + email + ', ').length;
296 input.setSelectionRange(newCursor, newCursor);
297 input.focus();
298 hide();
299 }
300
301 function show(filtered) {
302 hide();
303 if (filtered.length === 0) return;
304 matches = filtered;
305 dropdown = document.createElement('div');
306 dropdown.className = 'autocomplete-dropdown';
307 filtered.forEach((m) => {
308 const item = document.createElement('div');
309 item.className = 'autocomplete-item';
310 item.innerHTML = `<span class="autocomplete-name">${esc(m.name)}</span> <span class="autocomplete-email">${esc(m.email)}</span>`;
311 item.addEventListener('mousedown', (e) => { e.preventDefault(); selectMatch(m.email); });
312 dropdown.appendChild(item);
313 });
314 const wrapper = input.parentElement;
315 wrapper.appendChild(dropdown);
316 }
317
318 input.addEventListener('input', () => {
319 const { token } = getLastToken(input);
320 if (!token) { hide(); return; }
321 const q = token.toLowerCase();
322 const filtered = (getContacts() || [])
323 .filter(c => (c.email || '').toLowerCase().includes(q) || (c.name || '').toLowerCase().includes(q))
324 .sort((a, b) => {
325 if (!!a.isImplicit !== !!b.isImplicit) return a.isImplicit ? 1 : -1;
326 const ap = (a.email || '').toLowerCase().startsWith(q) ? 0 : 1;
327 const bp = (b.email || '').toLowerCase().startsWith(q) ? 0 : 1;
328 return ap - bp;
329 })
330 .slice(0, 8);
331 show(filtered);
332 });
333
334 input.addEventListener('blur', () => setTimeout(hide, 150));
335
336 input.addEventListener('keydown', (e) => {
337 if (!dropdown) return;
338 const items = dropdown.querySelectorAll('.autocomplete-item');
339 if (e.key === 'ArrowDown') {
340 e.preventDefault();
341 activeIndex = Math.min(activeIndex + 1, items.length - 1);
342 items.forEach((el, i) => el.classList.toggle('active', i === activeIndex));
343 } else if (e.key === 'ArrowUp') {
344 e.preventDefault();
345 activeIndex = Math.max(activeIndex - 1, 0);
346 items.forEach((el, i) => el.classList.toggle('active', i === activeIndex));
347 } else if (e.key === 'Enter' || e.key === 'Tab') {
348 if (activeIndex >= 0 && activeIndex < matches.length) {
349 e.preventDefault();
350 selectMatch(matches[activeIndex].email);
351 }
352 } else if (e.key === 'Escape') {
353 hide();
354 }
355 });
356 }
357 attachAutocomplete(toEl);
358 attachAutocomplete(ccEl);
359 attachAutocomplete(bccEl);
360
361 // ---------- Address highlight (GoingsOn.addressHighlight, if the surface loaded it) ----------
362 const ahAttach = window.GoingsOn && window.GoingsOn.addressHighlight && window.GoingsOn.addressHighlight.attach;
363 if (ahAttach) {
364 const ahOpts = { contacts: getContacts };
365 // Tauri's `invoke` is needed by the IMAP-backed lookup in
366 // address-highlight.js; pass through if present.
367 if (window.__TAURI__ && window.__TAURI__.core && window.__TAURI__.core.invoke) {
368 ahOpts.invoke = window.__TAURI__.core.invoke;
369 }
370 [toEl, ccEl, bccEl].forEach(el => { if (el) ahAttach(el, ahOpts); });
371 }
372
373 // ---------- CC/BCC toggle ----------
374 function toggleCcBcc() {
375 if (!ccRow || !bccRow || !toggleBtn) return;
376 const visible = !ccRow.classList.contains('hidden');
377 ccRow.classList.toggle('hidden', visible);
378 bccRow.classList.toggle('hidden', visible);
379 toggleBtn.textContent = visible ? 'Show CC/BCC' : 'Hide CC/BCC';
380 }
381 if (toggleBtn) toggleBtn.addEventListener('click', toggleCcBcc);
382
383 // ---------- Signature swap on From change ----------
384 function appendSignatureForAccount(accountId) {
385 if (!bodyEl) return;
386 let body = bodyEl.value;
387 if (currentSignature) {
388 const block = '\n\n-- \n' + currentSignature;
389 if (body.endsWith(block)) body = body.slice(0, -block.length);
390 }
391 const account = accounts.find(a => a.id === accountId);
392 const sig = (account && (account.emailSignature || account.email_signature)) || '';
393 currentSignature = sig;
394 bodyEl.value = body + (sig ? '\n\n-- \n' + sig : '');
395 }
396 if (fromEl) fromEl.addEventListener('change', (e) => appendSignatureForAccount(e.target.value));
397
398 // ---------- Attachments (picker, render, remove) ----------
399 function renderAttachments() {
400 if (!attachmentsBar) return;
401 if (attachedFiles.length === 0) {
402 attachmentsBar.classList.add('hidden');
403 attachmentsBar.innerHTML = '';
404 return;
405 }
406 const items = attachedFiles.map((f, i) => `
407 <div class="compose-attachment-item row-flex row-flex-2">
408 <span class="compose-attachment-name" title="${escAttr(f.path)}">${esc(f.name)}</span>
409 <span class="compose-attachment-size">${formatBytes(f.size || 0)}</span>
410 <button type="button" class="compose-attachment-remove" data-compose-remove-attachment="${i}" title="Remove">&times;</button>
411 </div>
412 `).join('');
413 const cap = exceedsAttachmentCap(attachedFiles);
414 const cls = cap.over ? 'compose-attachment-total over-cap'
415 : cap.warn ? 'compose-attachment-total over-warn'
416 : 'compose-attachment-total';
417 const warn = cap.over
418 ? '<span class="compose-attachment-warn">— most mail servers will reject this. Remove some files or use a file-share link.</span>'
419 : cap.warn
420 ? '<span class="compose-attachment-warn">— large attachment; your mail server may reject this.</span>'
421 : '';
422 const totalLine = `<div class="${cls}"><span>Total: ${formatBytes(cap.totalBytes)} / ${formatBytes(ATTACHMENT_HARD_CAP_BYTES)}</span>${warn}</div>`;
423 attachmentsBar.classList.remove('hidden');
424 attachmentsBar.innerHTML = items + totalLine;
425 }
426
427 if (attachmentsBar) {
428 // Event delegation on the bar — survives re-renders without re-binding.
429 attachmentsBar.addEventListener('click', (e) => {
430 const btn = e.target && e.target.closest && e.target.closest('[data-compose-remove-attachment]');
431 if (!btn) return;
432 const idx = Number(btn.getAttribute('data-compose-remove-attachment'));
433 if (!Number.isInteger(idx)) return;
434 removeAttachment(idx);
435 });
436 }
437
438 function removeAttachment(index) {
439 if (index < 0 || index >= attachedFiles.length) return;
440 attachedFiles.splice(index, 1);
441 renderAttachments();
442 if (onAttachmentsChange) onAttachmentsChange(attachedFiles);
443 }
444
445 async function pickAttachment() {
446 try {
447 if (!window.__TAURI__ || !window.__TAURI__.dialog) {
448 if (onError) onError('File picker unavailable');
449 return;
450 }
451 const { open } = window.__TAURI__.dialog;
452 const selected = await open({ multiple: true, title: 'Select files to attach' });
453 if (!selected) return;
454 const paths = Array.isArray(selected) ? selected : [selected];
455 const invoke = window.__TAURI__.core && window.__TAURI__.core.invoke;
456 for (const p of paths) {
457 const filePath = typeof p === 'string' ? p : p && p.path;
458 if (!filePath) continue;
459 if (attachedFiles.some(f => f.path === filePath)) continue;
460 const name = filePath.split(/[/\\]/).pop() || 'file';
461 let size = 0;
462 if (invoke) {
463 try { size = await invoke('get_file_size', { filePath }); } catch (_) { /* leave 0 */ }
464 }
465 attachedFiles.push({ path: filePath, name, size });
466 }
467 renderAttachments();
468 if (onAttachmentsChange) onAttachmentsChange(attachedFiles);
469 } catch (err) {
470 if (err && String(err).includes('cancelled')) return;
471 if (onError) onError('Failed to pick file: ' + err);
472 }
473 }
474
475 renderAttachments();
476
477 // ---------- Draft autosave + reply indicator (stage 5) ----------
478 const enableAutosave = !!o.enableAutosave;
479 const saveDraft = typeof o.saveDraft === 'function' ? o.saveDraft : null;
480 const getReplyContext = typeof o.getReplyContext === 'function' ? o.getReplyContext : () => ({});
481 const onDraftStatus = typeof o.onDraftStatus === 'function' ? o.onDraftStatus : null;
482 const enableReplyIndicator = !!o.enableReplyIndicator;
483
484 let currentDraftId = o.initialDraftId || null;
485 let autosaveTimer = null;
486 let isSending = false;
487
488 function collectDraftInput() {
489 const rc = getReplyContext() || {};
490 const subjEl = document.getElementById('subject');
491 const trim = (s) => (typeof s === 'string' ? s.trim() : '');
492 const orNull = (s) => (s && s.length ? s : null);
493 return {
494 id: currentDraftId || null,
495 accountId: (fromEl && fromEl.value) || null,
496 toAddress: orNull(trim(toEl && toEl.value)),
497 ccAddress: orNull(trim(ccEl && ccEl.value)),
498 bccAddress: orNull(trim(bccEl && bccEl.value)),
499 subject: orNull(trim(subjEl && subjEl.value)),
500 body: (bodyEl && bodyEl.value) || null,
501 inReplyTo: rc.inReplyTo || null,
502 references: rc.references || null,
503 threadId: rc.threadId || null,
504 };
505 }
506
507 function hasContent() {
508 const subjEl = document.getElementById('subject');
509 const to = (toEl && toEl.value.trim()) || '';
510 const subject = (subjEl && subjEl.value.trim()) || '';
511 const body = (bodyEl && bodyEl.value.trim()) || '';
512 // Signature-only body shouldn't trigger autosave.
513 const sigOnly = currentSignature && body === '-- \n' + currentSignature;
514 return !!(to || subject || (body && !sigOnly));
515 }
516
517 async function autosaveNow() {
518 if (!enableAutosave || !saveDraft) return;
519 if (isSending || !hasContent()) return;
520 try {
521 const result = await saveDraft(collectDraftInput());
522 if (result && result.id) currentDraftId = result.id;
523 if (onDraftStatus) onDraftStatus('saved', 'Draft auto-saved');
524 } catch (_) {
525 // Silent — manual save still works.
526 }
527 }
528
529 function scheduleAutosave() {
530 if (!enableAutosave || isSending) return;
531 if (autosaveTimer) clearTimeout(autosaveTimer);
532 autosaveTimer = setTimeout(autosaveNow, 2000);
533 }
534
535 async function saveDraftNow() {
536 if (!saveDraft) return null;
537 if (autosaveTimer) { clearTimeout(autosaveTimer); autosaveTimer = null; }
538 try {
539 const result = await saveDraft(collectDraftInput());
540 if (result && result.id) currentDraftId = result.id;
541 if (onDraftStatus) onDraftStatus('saved', 'Draft saved!');
542 return result;
543 } catch (err) {
544 if (onDraftStatus) onDraftStatus('error', 'Failed to save draft: ' + err);
545 return null;
546 }
547 }
548
549 function cancelAutosave() {
550 if (autosaveTimer) { clearTimeout(autosaveTimer); autosaveTimer = null; }
551 }
552
553 function setSending(b) {
554 isSending = !!b;
555 if (isSending) cancelAutosave();
556 }
557
558 if (enableAutosave) {
559 const autosaveIds = ['to-address', 'cc-address', 'bcc-address', 'subject', 'body'];
560 for (const id of autosaveIds) {
561 const el = document.getElementById(id);
562 if (el) el.addEventListener('input', scheduleAutosave);
563 }
564 if (fromEl) fromEl.addEventListener('change', scheduleAutosave);
565 }
566
567 function updateReplyIndicator() {
568 if (!enableReplyIndicator) return;
569 const rc = getReplyContext() || {};
570 const isReply = !!rc.inReplyTo;
571 const indicator = document.getElementById('reply-indicator');
572 if (indicator) {
573 indicator.style.display = isReply ? 'inline' : 'none';
574 indicator.textContent = isReply ? 'Replying to thread' : '';
575 }
576 // Both surfaces' send buttons (compose.html: #send-btn, modal: #compose-modal-send)
577 // get the "Send Reply" relabel.
578 const sendBtn = document.getElementById('send-btn') || document.getElementById('compose-modal-send');
579 if (sendBtn) sendBtn.textContent = isReply ? 'Send Reply' : 'Send';
580 }
581 updateReplyIndicator();
582
583 return {
584 pickAttachment,
585 removeAttachment,
586 renderAttachments,
587 getAttachedFiles: () => attachedFiles,
588 setAttachedFiles: (arr) => { attachedFiles = Array.isArray(arr) ? arr.slice() : []; renderAttachments(); },
589 appendSignatureForAccount,
590 getCurrentSignature: () => currentSignature,
591 toggleCcBcc,
592 // Stage 5
593 scheduleAutosave,
594 saveDraftNow,
595 cancelAutosave,
596 setSending,
597 getCurrentDraftId: () => currentDraftId,
598 setCurrentDraftId: (id) => { currentDraftId = id || null; },
599 updateReplyIndicator,
600 };
601 }
602
603 // ============ Export ============
604
605 const api = {
606 ATTACHMENT_HARD_CAP_BYTES,
607 ATTACHMENT_WARN_CAP_BYTES,
608 totalAttachmentBytes,
609 exceedsAttachmentCap,
610 formatBytes,
611 collectInput,
612 validate,
613 validateForSend,
614 buildFieldsHtml,
615 bindBehaviors,
616 };
617
618 // escape.js bootstraps the GoingsOn namespace even in the standalone
619 // compose.html webview (this file already depends on GoingsOn.escape), so
620 // the API lives on the namespace, not a bare window global.
621 GoingsOn.composeForm = api;
622 })();
623