| 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 |
|
| 24 |
|
| 25 |
|
| 26 |
|
| 27 |
|
| 28 |
|
| 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 |
|
| 53 |
|
| 54 |
|
| 55 |
|
| 56 |
|
| 57 |
|
| 58 |
|
| 59 |
|
| 60 |
|
| 61 |
|
| 62 |
|
| 63 |
|
| 64 |
|
| 65 |
|
| 66 |
|
| 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 |
|
| 91 |
|
| 92 |
|
| 93 |
|
| 94 |
|
| 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 |
|
| 109 |
|
| 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 |
|
| 125 |
|
| 126 |
|
| 127 |
|
| 128 |
|
| 129 |
|
| 130 |
|
| 131 |
|
| 132 |
|
| 133 |
|
| 134 |
|
| 135 |
|
| 136 |
|
| 137 |
|
| 138 |
|
| 139 |
|
| 140 |
|
| 141 |
|
| 142 |
|
| 143 |
|
| 144 |
|
| 145 |
|
| 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 |
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''', |
| 160 |
})[c]); |
| 161 |
}; |
| 162 |
const escAttr = (s) => escHtml(s).replace(/"/g, '"'); |
| 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 |
|
| 229 |
|
| 230 |
|
| 231 |
|
| 232 |
|
| 233 |
|
| 234 |
|
| 235 |
|
| 236 |
|
| 237 |
|
| 238 |
|
| 239 |
|
| 240 |
|
| 241 |
|
| 242 |
|
| 243 |
|
| 244 |
|
| 245 |
|
| 246 |
|
| 247 |
|
| 248 |
|
| 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, '"'); |
| 275 |
|
| 276 |
|
| 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 |
|
| 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 |
|
| 375 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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">×</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 |
|
| 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 (_) { } |
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 586 |
|
| 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 |
|
| 602 |
scheduleAutosave, |
| 603 |
saveDraftNow, |
| 604 |
cancelAutosave, |
| 605 |
setSending, |
| 606 |
getCurrentDraftId: () => currentDraftId, |
| 607 |
setCurrentDraftId: (id) => { currentDraftId = id || null; }, |
| 608 |
updateReplyIndicator, |
| 609 |
}; |
| 610 |
} |
| 611 |
|
| 612 |
|
| 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 |
|
| 628 |
|
| 629 |
if (typeof window !== 'undefined') { |
| 630 |
window.GoingsOnComposeForm = api; |
| 631 |
if (window.GoingsOn) { |
| 632 |
window.GoingsOn.composeForm = api; |
| 633 |
} |
| 634 |
} |
| 635 |
})(); |
| 636 |
|