Skip to main content

max / goingson

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