/** * GoingsOn - Emails Compose * Compose/reply/forward flows, drafts, and send-with-delay (undo-send). * Extracted from emails.js. Reply/forward prefills are built in Rust * (build_reply_prefill / build_forward_prefill); this module owns the compose * window/modal chrome and the send lifecycle. Refreshing the list after a send * goes through GoingsOn.emails.load() (the controller keeps the list state). */ (function() { 'use strict'; const esc = GoingsOn.utils.escapeHtml; const escArg = GoingsOn.utils.escapeHandlerArg; const escAttr = GoingsOn.utils.escapeAttrValue; /** * Open the email compose interface (new window on desktop, modal on mobile). */ async function openCompose() { // On mobile, compose in-app since Tauri doesn't support multiple windows if (GoingsOn.touch?.isTouchDevice) { openComposeModal(); return; } // Open compose in a new window using Tauri invoke try { await GoingsOn.api.window.openCompose(); } catch (err) { console.error('Failed to open compose window:', err); // Fall back to in-app modal openComposeModal(); } } // Modal compose controller, populated after openComposeModal mounts and // composeForm.bindBehaviors wires up the shared behaviors. let modalComposeCtrl = null; function openComposeModal(prefill) { modalComposeCtrl = null; const accounts = GoingsOn.getEmailAccountsCache(); const pf = prefill || {}; // Stage 3/4 of compose unification, markup and behaviors come from // composeForm. The chrome (modal wrapper, footer action row, Attach // button) stays modal-specific. CC/BCC sit after To, matching compose.html. // Pre-append signature for the default/selected account; bindBehaviors // tracks it so the first From-change knows what trailing block to strip. const selectedAccount = accounts.find(a => a.id === pf.accountId) || accounts[0]; const selectedAccountId = selectedAccount?.id || null; const sig = selectedAccount?.emailSignature || ''; const bodyWithSig = (pf.body || '') + (sig ? '\n\n-- \n' + sig : ''); const ccPrefill = pf.cc || ''; const bccPrefill = pf.bcc || ''; const ccBccExpanded = !!(ccPrefill || bccPrefill); const fieldsHtml = GoingsOn.composeForm.buildFieldsHtml({ accounts, selectedAccountId, prefill: { to: pf.to || '', cc: ccPrefill, bcc: bccPrefill, subject: pf.subject || '', body: bodyWithSig, }, showCcBcc: ccBccExpanded, showAttachments: true, bodyRows: 8, }); const replyContext = { inReplyTo: pf.inReplyTo || null, references: pf.references || null, threadId: pf.threadId || null, }; const content = `
${fieldsHtml}
`; GoingsOn.modal.openModal('Compose Email', content); setTimeout(() => { const form = document.getElementById('compose-modal-form'); if (!form) return; // Stage 4/5, single bindBehaviors call owns autocomplete, // address highlight, CC/BCC toggle, signature swap, attachment // picker/render/remove, draft autosave, and reply indicator. modalComposeCtrl = GoingsOn.composeForm.bindBehaviors({ accounts, initialSignature: sig, getContacts: GoingsOn.autocomplete.getContacts, onError: (msg) => GoingsOn.ui.showToast(msg, 'error', { duration: 8000 }), enableAutosave: true, initialDraftId: pf.draftId || null, saveDraft: (input) => GoingsOn.api.emails.saveDraft(input), getReplyContext: () => replyContext, onDraftStatus: (kind, message) => { if (kind === 'error') { GoingsOn.ui.showToast(message, 'error', { duration: 6000 }); } else { GoingsOn.ui.showToast(message, 'success', { duration: 2500 }); } }, enableReplyIndicator: true, }); const attachBtn = document.getElementById('compose-modal-attach'); const cancelBtn = document.getElementById('compose-modal-cancel'); const sendBtn = document.getElementById('compose-modal-send'); const saveDraftBtn = document.getElementById('compose-modal-save-draft'); if (attachBtn) attachBtn.addEventListener('click', modalComposeCtrl.pickAttachment); if (saveDraftBtn) saveDraftBtn.addEventListener('click', () => modalComposeCtrl.saveDraftNow()); if (cancelBtn) cancelBtn.addEventListener('click', () => GoingsOn.ui.closeModal()); const submit = async () => { modalComposeCtrl.setSending(true); const accountSelect = document.getElementById('from-account'); const toEl = document.getElementById('to-address'); const ccEl = document.getElementById('cc-address'); const bccEl = document.getElementById('bcc-address'); const subjectEl = document.getElementById('subject'); const bodyEl = document.getElementById('body'); const attachedFiles = modalComposeCtrl.getAttachedFiles(); const input = GoingsOn.composeForm.collectInput({ accountId: accountSelect ? accountSelect.value : null, toAddress: toEl ? toEl.value : '', ccAddress: ccEl ? ccEl.value : '', bccAddress: bccEl ? bccEl.value : '', subject: subjectEl ? subjectEl.value : '', body: bodyEl ? bodyEl.value : '', attachedFiles, replyContext, }); const result = GoingsOn.composeForm.validateForSend(input, attachedFiles); if (!result.ok) { GoingsOn.ui.showToast(result.message, 'error', { duration: 8000 }); modalComposeCtrl.setSending(false); return; } GoingsOn.ui.closeModal(); queueSend({ input }); }; form.addEventListener('submit', (e) => { e.preventDefault(); submit(); }); if (sendBtn) sendBtn.addEventListener('click', (e) => { e.preventDefault(); submit(); }); }, 50); } async function openReply(emailId, replyAll) { try { // Rust builds the reply prefill: recipients (reply-all excludes our // own accounts + de-dupes), Re: subject, and quoted body. const pf = await GoingsOn.api.emails.buildReplyPrefill(emailId, !!replyAll); if (!pf) return; const prefill = { to: pf.to, subject: pf.subject, body: pf.body, inReplyTo: pf.inReplyTo || null, references: pf.references || null, threadId: pf.threadId || null, accountId: pf.accountId || '', }; if (GoingsOn.touch?.isTouchDevice) { openComposeModal(prefill); } else { await GoingsOn.api.window.openCompose(prefill); } } catch (err) { GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to open reply'), 'error'); } } /** * Open compose window to forward an email. * @param {string} emailId - Email to forward */ async function openForward(emailId) { try { // Rust builds the Fwd: subject and forwarded-message body block. const pf = await GoingsOn.api.emails.buildForwardPrefill(emailId); if (!pf) return; const prefill = { to: '', subject: pf.subject, body: pf.body, accountId: pf.accountId || '' }; if (GoingsOn.touch?.isTouchDevice) { openComposeModal(prefill); } else { await GoingsOn.api.window.openCompose(prefill); } } catch (err) { GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to forward email'), 'error'); } } // Drafts /** * Load and display drafts in a modal. */ async function openDraftsModal() { try { const drafts = await GoingsOn.api.emails.listDrafts(); if (drafts.length === 0) { GoingsOn.ui.openModal('Drafts', '

No drafts

'); return; } const listHtml = drafts.map(d => { const to = d.to || '(no recipient)'; const subject = d.subject || '(no subject)'; return `
${esc(subject)}
To: ${esc(to)} · ${d.receivedFormatted}
`; }).join(''); GoingsOn.ui.openModal('Drafts', `
${listHtml}
`); } catch (err) { GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load drafts'), 'error'); } } /** * Open a draft for editing in the compose window. * @param {string} id - Draft email ID */ async function openDraft(id) { GoingsOn.ui.closeModal(); if (GoingsOn.touch?.isTouchDevice) { // Mobile: open compose modal with draft data try { const draft = await GoingsOn.api.emails.get(id); if (!draft) return; openComposeModal({ to: draft.to || '', cc: draft.ccAddress || '', bcc: draft.bccAddress || '', subject: draft.subject || '', body: draft.body || '', accountId: draft.draftAccountId || '', inReplyTo: draft.inReplyTo || null, threadId: draft.threadId || null, draftId: draft.id, }); } catch (err) { GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to open draft'), 'error'); } } else { try { await GoingsOn.api.window.openCompose({ draftId: id }); } catch (err) { GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to open draft'), 'error'); } } } /** * Send a draft directly. * @param {string} id - Draft email ID */ async function sendDraft(id) { try { await GoingsOn.api.emails.sendDraft(id); GoingsOn.ui.showToast('Draft sent!', 'success'); GoingsOn.ui.closeModal(); GoingsOn.cache.invalidate('emails'); GoingsOn.emails.load(); } catch (err) { GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to send draft'), 'error'); } } // Send-with-Delay (undo-send) /** * Build a compose prefill object from a queued SendInput. * Note: attachmentPaths are NOT preserved through compose re-open today * (the compose UI uses a fresh file picker). When undo restores a message * that had attachments, surface a notice so the user re-attaches. */ function _sendInputToComposeParams(input) { return { to: input.to || input.toAddress || '', subject: input.subject || '', body: input.body || '', accountId: input.accountId || null, inReplyTo: input.inReplyTo || null, references: input.references || null, threadId: input.threadId || null, }; } /** * Queue an email send with an undo window. Returns immediately. The actual * `api.emails.send` call fires when the undo window expires; if the user * clicks Undo, the compose surface re-opens with the message restored. * * Charter rule: every irreversible operation gets an undo path (Phase 7 * Tier 1 #3 / Phase 3 #1). * * @param {Object} cfg * @param {Object} cfg.input - SendInput (accountId / to / subject / body / inReplyTo / references / threadId / attachmentPaths) * @param {number} [cfg.delaySeconds=5] - Undo window in seconds. */ function queueSend({ input, delaySeconds = 5 }) { const hadAttachments = Array.isArray(input.attachmentPaths) && input.attachmentPaths.length > 0; const timeout = Math.max(1000, delaySeconds * 1000); const message = input.inReplyTo ? 'Sending reply…' : 'Sending email…'; GoingsOn.ui.showUndoToast(message, { timeout, onConfirm: async () => { try { const result = await GoingsOn.api.emails.send(input); GoingsOn.ui.showToast('Email sent', 'success'); GoingsOn.emails.load(); if (result?.newImplicitContacts?.length > 0) { GoingsOn.autocomplete?.refresh?.(); for (const c of result.newImplicitContacts) { const name = c.displayName || c.display_name || ''; GoingsOn.ui.showToast(`Save ${name} as a contact?`, 'info', { duration: 8000, action: { label: 'Save', fn: async () => { try { await GoingsOn.api.contacts.promoteContact(c.id); GoingsOn.cache.invalidate('contacts'); GoingsOn.autocomplete?.refresh?.(); GoingsOn.ui.showToast(`${name} saved as contact`, 'success'); } catch (err) { GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to save contact'), 'error'); } }, }, }); } } } catch (err) { // User is no longer in compose; offer to re-open with the message. GoingsOn.ui.showToast( 'Send failed: ' + GoingsOn.utils.getErrorMessage(err), 'error', { duration: 12000, action: { label: 'Edit & retry', fn: () => { const prefill = _sendInputToComposeParams(input); if (GoingsOn.touch?.isTouchDevice) { openComposeModal(prefill); } else { GoingsOn.api.window.openCompose(prefill).catch(() => openComposeModal(prefill)); } }, }, } ); } }, onUndo: () => { const prefill = _sendInputToComposeParams(input); if (GoingsOn.touch?.isTouchDevice) { openComposeModal(prefill); } else { GoingsOn.api.window.openCompose(prefill).catch(() => openComposeModal(prefill)); } if (hadAttachments) { GoingsOn.ui.showToast('Re-attach your files; attachments cleared on undo.', 'info', { duration: 8000 }); } }, }); } GoingsOn.emailsCompose = { openCompose, openComposeModal, openReply, openForward, openDraftsModal, openDraft, sendDraft, queueSend, }; })();