Skip to main content

max / goingson

GO-33-5: split emails.js into controller + render + compose modules emails.js was a 1515-line god-module. Split into three: - emails.js (1095): controller — load/pagination/selection/filters/search, the CHRONIC-E surgical list mutations, reader, and per-email CRUD - emails-render.js (63): renderEmailItem row markup - emails-compose.js (409): compose window/modal, reply/forward, drafts, send-with-delay (undo-send) Cross-boundary references use lazy delegation (GoingsOn.emails.reply -> emailsCompose.openReply; scroller renderItem -> emailsRender.renderEmailItem; selection read via GoingsOn.emails.selection) so script load order is irrelevant and the public GoingsOn.emails surface is unchanged. Static verification: node --check on all three, JS gate 56/56, no dangling refs, all external GoingsOn.emails.* callers still satisfied. Runtime behavior of the email view still needs a manual smoke-test (compose/reply/forward/drafts/send). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-06 15:59 UTC
Commit: 4644fd6a5c4a371450fe2d4ad79849e4a376b4bd
Parent: be01d90
4 files changed, +487 insertions, -433 deletions
@@ -685,6 +685,8 @@
685 685 <script src="js/task-overview.js"></script>
686 686 <script src="js/events.js"></script>
687 687 <script src="js/events-calendar.js"></script>
688 + <script src="js/emails-render.js"></script>
689 + <script src="js/emails-compose.js"></script>
688 690 <script src="js/emails.js"></script>
689 691 <script src="js/email-accounts.js"></script>
690 692 <script src="js/contacts.js"></script>
@@ -0,0 +1,409 @@
1 + /**
2 + * GoingsOn - Emails Compose
3 + * Compose/reply/forward flows, drafts, and send-with-delay (undo-send).
4 + * Extracted from emails.js. Reply/forward prefills are built in Rust
5 + * (build_reply_prefill / build_forward_prefill); this module owns the compose
6 + * window/modal chrome and the send lifecycle. Refreshing the list after a send
7 + * goes through GoingsOn.emails.load() (the controller keeps the list state).
8 + */
9 +
10 + (function() {
11 + 'use strict';
12 +
13 + const esc = GoingsOn.utils.escapeHtml;
14 + const escArg = GoingsOn.utils.escapeHandlerArg;
15 +
16 + /**
17 + * Open the email compose interface (new window on desktop, modal on mobile).
18 + */
19 + async function openCompose() {
20 + // On mobile, compose in-app since Tauri doesn't support multiple windows
21 + if (GoingsOn.touch?.isTouchDevice) {
22 + openComposeModal();
23 + return;
24 + }
25 +
26 + // Open compose in a new window using Tauri invoke
27 + try {
28 + await GoingsOn.api.window.openCompose();
29 + } catch (err) {
30 + console.error('Failed to open compose window:', err);
31 + // Fall back to in-app modal
32 + openComposeModal();
33 + }
34 + }
35 +
36 + // Modal compose controller — populated after openComposeModal mounts and
37 + // composeForm.bindBehaviors wires up the shared behaviors.
38 + let modalComposeCtrl = null;
39 +
40 + function openComposeModal(prefill) {
41 + modalComposeCtrl = null;
42 + const accounts = GoingsOn.getEmailAccountsCache();
43 + const pf = prefill || {};
44 +
45 + // Stage 3/4 of compose unification — markup and behaviors come from
46 + // composeForm. The chrome (modal wrapper, footer action row, Attach
47 + // button) stays modal-specific. CC/BCC sit after To, matching compose.html.
48 +
49 + // Pre-append signature for the default/selected account; bindBehaviors
50 + // tracks it so the first From-change knows what trailing block to strip.
51 + const selectedAccount = accounts.find(a => a.id === pf.accountId) || accounts[0];
52 + const selectedAccountId = selectedAccount?.id || null;
53 + const sig = selectedAccount?.emailSignature || '';
54 + const bodyWithSig = (pf.body || '') + (sig ? '\n\n-- \n' + sig : '');
55 +
56 + const ccPrefill = pf.cc || '';
57 + const bccPrefill = pf.bcc || '';
58 + const ccBccExpanded = !!(ccPrefill || bccPrefill);
59 +
60 + const fieldsHtml = GoingsOn.composeForm.buildFieldsHtml({
61 + accounts,
62 + selectedAccountId,
63 + prefill: {
64 + to: pf.to || '',
65 + cc: ccPrefill,
66 + bcc: bccPrefill,
67 + subject: pf.subject || '',
68 + body: bodyWithSig,
69 + },
70 + showCcBcc: ccBccExpanded,
71 + showAttachments: true,
72 + bodyRows: 8,
73 + });
74 +
75 + const replyContext = {
76 + inReplyTo: pf.inReplyTo || null,
77 + references: pf.references || null,
78 + threadId: pf.threadId || null,
79 + };
80 +
81 + const content = `
82 + <form id="compose-modal-form" onsubmit="event.preventDefault(); return false;">
83 + <span id="reply-indicator" class="reply-indicator"></span>
84 + ${fieldsHtml}
85 + <div class="form-actions" style="margin-top: 0.75rem;">
86 + <button type="button" class="btn btn-secondary" id="compose-modal-attach">Attach File</button>
87 + <button type="button" class="btn btn-secondary" id="compose-modal-save-draft">Save Draft</button>
88 + <div class="flex-1"></div>
89 + <button type="button" class="btn btn-secondary" id="compose-modal-cancel">Cancel</button>
90 + <button type="submit" class="btn btn-primary" id="compose-modal-send">Send</button>
91 + </div>
92 + </form>
93 + `;
94 +
95 + GoingsOn.modal.openModal('Compose Email', content);
96 +
97 + setTimeout(() => {
98 + const form = document.getElementById('compose-modal-form');
99 + if (!form) return;
100 +
101 + // Stage 4/5 — single bindBehaviors call owns autocomplete,
102 + // address highlight, CC/BCC toggle, signature swap, attachment
103 + // picker/render/remove, draft autosave, and reply indicator.
104 + modalComposeCtrl = GoingsOn.composeForm.bindBehaviors({
105 + accounts,
106 + initialSignature: sig,
107 + getContacts: GoingsOn.autocomplete.getContacts,
108 + onError: (msg) => GoingsOn.ui.showToast(msg, 'error', { duration: 8000 }),
109 + enableAutosave: true,
110 + initialDraftId: pf.draftId || null,
111 + saveDraft: (input) => GoingsOn.api.emails.saveDraft(input),
112 + getReplyContext: () => replyContext,
113 + onDraftStatus: (kind, message) => {
114 + if (kind === 'error') {
115 + GoingsOn.ui.showToast(message, 'error', { duration: 6000 });
116 + } else {
117 + GoingsOn.ui.showToast(message, 'success', { duration: 2500 });
118 + }
119 + },
120 + enableReplyIndicator: true,
121 + });
122 +
123 + const attachBtn = document.getElementById('compose-modal-attach');
124 + const cancelBtn = document.getElementById('compose-modal-cancel');
125 + const sendBtn = document.getElementById('compose-modal-send');
126 + const saveDraftBtn = document.getElementById('compose-modal-save-draft');
127 + if (attachBtn) attachBtn.addEventListener('click', modalComposeCtrl.pickAttachment);
128 + if (saveDraftBtn) saveDraftBtn.addEventListener('click', () => modalComposeCtrl.saveDraftNow());
129 + if (cancelBtn) cancelBtn.addEventListener('click', () => GoingsOn.ui.closeModal());
130 +
131 + const submit = async () => {
132 + modalComposeCtrl.setSending(true);
133 + const accountSelect = document.getElementById('from-account');
134 + const toEl = document.getElementById('to-address');
135 + const ccEl = document.getElementById('cc-address');
136 + const bccEl = document.getElementById('bcc-address');
137 + const subjectEl = document.getElementById('subject');
138 + const bodyEl = document.getElementById('body');
139 + const attachedFiles = modalComposeCtrl.getAttachedFiles();
140 + const input = GoingsOn.composeForm.collectInput({
141 + accountId: accountSelect ? accountSelect.value : null,
142 + toAddress: toEl ? toEl.value : '',
143 + ccAddress: ccEl ? ccEl.value : '',
144 + bccAddress: bccEl ? bccEl.value : '',
145 + subject: subjectEl ? subjectEl.value : '',
146 + body: bodyEl ? bodyEl.value : '',
147 + attachedFiles,
148 + replyContext,
149 + });
150 + const result = GoingsOn.composeForm.validateForSend(input, attachedFiles);
151 + if (!result.ok) {
152 + GoingsOn.ui.showToast(result.message, 'error', { duration: 8000 });
153 + modalComposeCtrl.setSending(false);
154 + return;
155 + }
156 + GoingsOn.ui.closeModal();
157 + queueSend({ input });
158 + };
159 + form.addEventListener('submit', (e) => { e.preventDefault(); submit(); });
160 + if (sendBtn) sendBtn.addEventListener('click', (e) => { e.preventDefault(); submit(); });
161 + }, 50);
162 + }
163 +
164 + async function openReply(emailId, replyAll) {
165 + try {
166 + // Rust builds the reply prefill: recipients (reply-all excludes our
167 + // own accounts + de-dupes), Re: subject, and quoted body.
168 + const pf = await GoingsOn.api.emails.buildReplyPrefill(emailId, !!replyAll);
169 + if (!pf) return;
170 +
171 + const prefill = {
172 + to: pf.to,
173 + subject: pf.subject,
174 + body: pf.body,
175 + inReplyTo: pf.inReplyTo || null,
176 + references: pf.references || null,
177 + threadId: pf.threadId || null,
178 + accountId: pf.accountId || '',
179 + };
180 +
181 + if (GoingsOn.touch?.isTouchDevice) {
182 + openComposeModal(prefill);
183 + } else {
184 + await GoingsOn.api.window.openCompose(prefill);
185 + }
186 + } catch (err) {
187 + GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to open reply'), 'error');
188 + }
189 + }
190 +
191 + /**
192 + * Open compose window to forward an email.
193 + * @param {string} emailId - Email to forward
194 + */
195 + async function openForward(emailId) {
196 + try {
197 + // Rust builds the Fwd: subject and forwarded-message body block.
198 + const pf = await GoingsOn.api.emails.buildForwardPrefill(emailId);
199 + if (!pf) return;
200 +
201 + const prefill = { to: '', subject: pf.subject, body: pf.body, accountId: pf.accountId || '' };
202 +
203 + if (GoingsOn.touch?.isTouchDevice) {
204 + openComposeModal(prefill);
205 + } else {
206 + await GoingsOn.api.window.openCompose(prefill);
207 + }
208 + } catch (err) {
209 + GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to forward email'), 'error');
210 + }
211 + }
212 +
213 + // ============ Drafts ============
214 +
215 + /**
216 + * Load and display drafts in a modal.
217 + */
218 + async function openDraftsModal() {
219 + try {
220 + const drafts = await GoingsOn.api.emails.listDrafts();
221 +
222 + if (drafts.length === 0) {
223 + GoingsOn.ui.openModal('Drafts', '<div class="empty-state empty-state--compact"><p class="empty-state-text">No drafts</p></div>');
224 + return;
225 + }
226 +
227 + const listHtml = drafts.map(d => {
228 + const to = d.to || '(no recipient)';
229 + const subject = d.subject || '(no subject)';
230 + return `
231 + <div class="email-item email-draft-item"
232 + onclick="GoingsOn.emails.openDraft('${escArg(d.id)}')">
233 + <div class="email-draft-subject">${esc(subject)}</div>
234 + <div class="email-draft-meta">To: ${esc(to)} · ${d.receivedFormatted}</div>
235 + </div>
236 + `;
237 + }).join('');
238 +
239 + GoingsOn.ui.openModal('Drafts', `<div>${listHtml}</div>`);
240 + } catch (err) {
241 + GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load drafts'), 'error');
242 + }
243 + }
244 +
245 + /**
246 + * Open a draft for editing in the compose window.
247 + * @param {string} id - Draft email ID
248 + */
249 + async function openDraft(id) {
250 + GoingsOn.ui.closeModal();
251 + if (GoingsOn.touch?.isTouchDevice) {
252 + // Mobile: open compose modal with draft data
253 + try {
254 + const draft = await GoingsOn.api.emails.get(id);
255 + if (!draft) return;
256 + openComposeModal({
257 + to: draft.to || '',
258 + cc: draft.ccAddress || '',
259 + bcc: draft.bccAddress || '',
260 + subject: draft.subject || '',
261 + body: draft.body || '',
262 + accountId: draft.draftAccountId || '',
263 + inReplyTo: draft.inReplyTo || null,
264 + threadId: draft.threadId || null,
265 + draftId: draft.id,
266 + });
267 + } catch (err) {
268 + GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to open draft'), 'error');
269 + }
270 + } else {
271 + try {
272 + await GoingsOn.api.window.openCompose({ draftId: id });
273 + } catch (err) {
274 + GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to open draft'), 'error');
275 + }
276 + }
277 + }
278 +
279 + /**
280 + * Send a draft directly.
281 + * @param {string} id - Draft email ID
282 + */
283 + async function sendDraft(id) {
284 + try {
285 + await GoingsOn.api.emails.sendDraft(id);
286 + GoingsOn.ui.showToast('Draft sent!', 'success');
287 + GoingsOn.ui.closeModal();
288 + GoingsOn.cache.invalidate('emails');
289 + GoingsOn.emails.load();
290 + } catch (err) {
291 + GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to send draft'), 'error');
292 + }
293 + }
294 +
295 + // ============ Send-with-Delay (undo-send) ============
296 +
297 + /**
298 + * Build a compose prefill object from a queued SendInput.
299 + * Note: attachmentPaths are NOT preserved through compose re-open today
300 + * (the compose UI uses a fresh file picker). When undo restores a message
301 + * that had attachments, surface a notice so the user re-attaches.
302 + */
303 + function _sendInputToComposeParams(input) {
304 + return {
305 + to: input.to || input.toAddress || '',
306 + subject: input.subject || '',
307 + body: input.body || '',
308 + accountId: input.accountId || null,
309 + inReplyTo: input.inReplyTo || null,
310 + references: input.references || null,
311 + threadId: input.threadId || null,
312 + };
313 + }
314 +
315 + /**
316 + * Queue an email send with an undo window. Returns immediately. The actual
317 + * `api.emails.send` call fires when the undo window expires; if the user
318 + * clicks Undo, the compose surface re-opens with the message restored.
319 + *
320 + * Charter rule: every irreversible operation gets an undo path (Phase 7
321 + * Tier 1 #3 / Phase 3 #1).
322 + *
323 + * @param {Object} cfg
324 + * @param {Object} cfg.input - SendInput (accountId / to / subject / body / inReplyTo / references / threadId / attachmentPaths)
325 + * @param {number} [cfg.delaySeconds=5] - Undo window in seconds.
326 + */
327 + function queueSend({ input, delaySeconds = 5 }) {
328 + const hadAttachments = Array.isArray(input.attachmentPaths) && input.attachmentPaths.length > 0;
329 + const timeout = Math.max(1000, delaySeconds * 1000);
330 + const message = input.inReplyTo ? 'Sending reply…' : 'Sending email…';
331 +
332 + GoingsOn.ui.showUndoToast(message, {
333 + timeout,
334 + onConfirm: async () => {
335 + try {
336 + const result = await GoingsOn.api.emails.send(input);
337 + GoingsOn.ui.showToast('Email sent', 'success');
338 + GoingsOn.emails.load();
339 +
340 + if (result?.newImplicitContacts?.length > 0) {
341 + GoingsOn.autocomplete?.refresh?.();
342 + for (const c of result.newImplicitContacts) {
343 + const name = c.displayName || c.display_name || '';
344 + GoingsOn.ui.showToast(`Save ${name} as a contact?`, 'info', {
345 + duration: 8000,
346 + action: {
347 + label: 'Save',
348 + fn: async () => {
349 + try {
350 + await GoingsOn.api.contacts.promoteContact(c.id);
351 + GoingsOn.cache.invalidate('contacts');
352 + GoingsOn.autocomplete?.refresh?.();
353 + GoingsOn.ui.showToast(`${name} saved as contact`, 'success');
354 + } catch (err) {
355 + GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to save contact'), 'error');
356 + }
357 + },
358 + },
359 + });
360 + }
361 + }
362 + } catch (err) {
363 + // User is no longer in compose; offer to re-open with the message.
364 + GoingsOn.ui.showToast(
365 + 'Send failed: ' + GoingsOn.utils.getErrorMessage(err),
366 + 'error',
367 + {
368 + duration: 12000,
369 + action: {
370 + label: 'Edit & retry',
371 + fn: () => {
372 + const prefill = _sendInputToComposeParams(input);
373 + if (GoingsOn.touch?.isTouchDevice) {
374 + openComposeModal(prefill);
375 + } else {
376 + GoingsOn.api.window.openCompose(prefill).catch(() => openComposeModal(prefill));
377 + }
378 + },
379 + },
380 + }
381 + );
382 + }
383 + },
384 + onUndo: () => {
385 + const prefill = _sendInputToComposeParams(input);
386 + if (GoingsOn.touch?.isTouchDevice) {
387 + openComposeModal(prefill);
388 + } else {
389 + GoingsOn.api.window.openCompose(prefill).catch(() => openComposeModal(prefill));
390 + }
391 + if (hadAttachments) {
392 + GoingsOn.ui.showToast('Re-attach your files — attachments cleared on undo.', 'info', { duration: 8000 });
393 + }
394 + },
395 + });
396 + }
397 +
398 + GoingsOn.emailsCompose = {
399 + openCompose,
400 + openComposeModal,
401 + openReply,
402 + openForward,
403 + openDraftsModal,
404 + openDraft,
405 + sendDraft,
406 + queueSend,
407 + };
408 +
409 + })();
@@ -0,0 +1,63 @@
1 + /**
2 + * GoingsOn - Emails Render
3 + * Row markup for the email/thread list. Extracted from emails.js so the
4 + * controller stays focused on data flow. Consumes the shared selection manager
5 + * via GoingsOn.emails.selection (resolved lazily at render time).
6 + */
7 +
8 + (function() {
9 + 'use strict';
10 +
11 + const esc = GoingsOn.utils.escapeHtml;
12 + const escAttr = GoingsOn.utils.escapeAttrValue;
13 + const escArg = GoingsOn.utils.escapeHandlerArg;
14 +
15 + /**
16 + * Render one thread row for the virtual scroller.
17 + * @param {Object} thread - Thread with mostRecentEmail + threadCount/hasUnread
18 + * @param {number} index - Row index (unused; kept for scroller signature parity)
19 + * @returns {string} HTML string
20 + */
21 + function renderEmailItem(thread, index) {
22 + const e = thread.mostRecentEmail;
23 + // Use pre-computed field from backend (avoids JS date calculation)
24 + const isSnoozed = e.isSnoozed;
25 + const isSelected = GoingsOn.emails.selection.isSelected(e.id);
26 + const threadBadge = thread.threadCount > 1
27 + ? `<span class="thread-badge" title="${thread.threadCount} messages in thread">${thread.threadCount}</span>`
28 + : '';
29 + const labelBadges = (e.labels || []).map(l =>
30 + `<span class="badge badge--xs badge--filled" data-color="blue">${esc(l)}</span>`
31 + ).join('');
32 +
33 + return `
34 + <div class="email-item email-item-with-checkbox ${thread.hasUnread ? 'unread' : ''} ${isSnoozed ? 'email-snoozed' : ''}"
35 + data-id="${escAttr(e.id)}"
36 + oncontextmenu="GoingsOn.contextMenus.showEmail(event, '${escArg(e.id)}')"
37 + data-email-archived="${e.isArchived}" data-email-read="${e.isRead}"
38 + data-email-snoozed="${isSnoozed}"
39 + tabindex="0" role="listitem" aria-label="Email from ${esc(e.from)}">
40 + <div class="email-checkbox-cell" onclick="event.stopPropagation();">
41 + <input type="checkbox" class="bulk-checkbox" data-id="${escAttr(e.id)}"
42 + ${isSelected ? 'checked' : ''}
43 + onchange="GoingsOn.emails.toggleSelection('${escArg(e.id)}', this, event)"
44 + aria-label="Select email">
45 + </div>
46 + <div class="email-content" onclick="GoingsOn.emails.open('${escArg(e.id)}')" role="button">
47 + <div class="email-header">
48 + <span class="email-from">${esc(e.from)}</span>
49 + ${threadBadge}
50 + ${isSnoozed ? `<span class="snooze-badge" title="Snoozed until ${escAttr(e.snoozedUntilFormatted || '')}" aria-label="Snoozed until ${escAttr(e.snoozedUntilFormatted || '')}">Snoozed</span>` : ''}
51 + <span class="email-date">${e.receivedFormatted}</span>
52 + </div>
53 + <div class="email-subject">${esc(e.subject)}${labelBadges ? ' ' + labelBadges : ''}</div>
54 + <div class="email-preview">${esc(e.bodyPreview)}...</div>
55 + </div>
56 + <button class="btn-icon kebab-btn" onclick="event.stopPropagation(); GoingsOn.contextMenus.showEmail(event, '${escArg(e.id)}')" title="Actions" aria-label="Email actions">&#x22EE;</button>
57 + </div>
58 + `;
59 + }
60 +
61 + GoingsOn.emailsRender = { renderEmailItem };
62 +
63 + })();
@@ -196,7 +196,7 @@
196 196 if (!emailScroller) {
197 197 emailScroller = new GoingsOn.VirtualScroller({
198 198 container: container,
199 - renderItem: renderEmailItem,
199 + renderItem: (t, i) => GoingsOn.emailsRender.renderEmailItem(t, i),
200 200 getItems: () => GoingsOn.state.emailThreads,
201 201 rowHeight: { estimated: 90, measure: true },
202 202 overscan: 5,
@@ -223,194 +223,6 @@
223 223 * @param {number} index - Item index
224 224 * @returns {string} HTML string
225 225 */
226 - function renderEmailItem(thread, index) {
227 - const e = thread.mostRecentEmail;
228 - // Use pre-computed field from backend (avoids JS date calculation)
229 - const isSnoozed = e.isSnoozed;
230 - const isSelected = emailSelection.isSelected(e.id);
231 - const threadBadge = thread.threadCount > 1
232 - ? `<span class="thread-badge" title="${thread.threadCount} messages in thread">${thread.threadCount}</span>`
233 - : '';
234 - const labelBadges = (e.labels || []).map(l =>
235 - `<span class="badge badge--xs badge--filled" data-color="blue">${esc(l)}</span>`
236 - ).join('');
237 -
238 - return `
239 - <div class="email-item email-item-with-checkbox ${thread.hasUnread ? 'unread' : ''} ${isSnoozed ? 'email-snoozed' : ''}"
240 - data-id="${escAttr(e.id)}"
241 - oncontextmenu="GoingsOn.contextMenus.showEmail(event, '${escArg(e.id)}')"
242 - data-email-archived="${e.isArchived}" data-email-read="${e.isRead}"
243 - data-email-snoozed="${isSnoozed}"
244 - tabindex="0" role="listitem" aria-label="Email from ${esc(e.from)}">
245 - <div class="email-checkbox-cell" onclick="event.stopPropagation();">
246 - <input type="checkbox" class="bulk-checkbox" data-id="${escAttr(e.id)}"
247 - ${isSelected ? 'checked' : ''}
248 - onchange="GoingsOn.emails.toggleSelection('${escArg(e.id)}', this, event)"
249 - aria-label="Select email">
250 - </div>
251 - <div class="email-content" onclick="GoingsOn.emails.open('${escArg(e.id)}')" role="button">
252 - <div class="email-header">
253 - <span class="email-from">${esc(e.from)}</span>
254 - ${threadBadge}
255 - ${isSnoozed ? `<span class="snooze-badge" title="Snoozed until ${escAttr(e.snoozedUntilFormatted || '')}" aria-label="Snoozed until ${escAttr(e.snoozedUntilFormatted || '')}">Snoozed</span>` : ''}
256 - <span class="email-date">${e.receivedFormatted}</span>
257 - </div>
258 - <div class="email-subject">${esc(e.subject)}${labelBadges ? ' ' + labelBadges : ''}</div>
259 - <div class="email-preview">${esc(e.bodyPreview)}...</div>
260 - </div>
261 - <button class="btn-icon kebab-btn" onclick="event.stopPropagation(); GoingsOn.contextMenus.showEmail(event, '${escArg(e.id)}')" title="Actions" aria-label="Email actions">&#x22EE;</button>
262 - </div>
263 - `;
264 - }
265 -
266 - /**
267 - * Open the email compose interface (new window on desktop, modal on mobile).
268 - */
269 - async function openCompose() {
270 - // On mobile, compose in-app since Tauri doesn't support multiple windows
271 - if (GoingsOn.touch?.isTouchDevice) {
272 - openComposeModal();
273 - return;
274 - }
275 -
276 - // Open compose in a new window using Tauri invoke
277 - try {
278 - await GoingsOn.api.window.openCompose();
279 - } catch (err) {
280 - console.error('Failed to open compose window:', err);
281 - // Fall back to in-app modal
282 - openComposeModal();
283 - }
284 - }
285 -
286 - // Modal compose controller — populated after openComposeModal mounts and
287 - // composeForm.bindBehaviors wires up the shared behaviors.
288 - let modalComposeCtrl = null;
289 -
290 - function openComposeModal(prefill) {
291 - modalComposeCtrl = null;
292 - const accounts = GoingsOn.getEmailAccountsCache();
293 - const pf = prefill || {};
294 -
295 - // Stage 3/4 of compose unification — markup and behaviors come from
296 - // composeForm. The chrome (modal wrapper, footer action row, Attach
297 - // button) stays modal-specific. CC/BCC sit after To, matching compose.html.
298 -
299 - // Pre-append signature for the default/selected account; bindBehaviors
300 - // tracks it so the first From-change knows what trailing block to strip.
301 - const selectedAccount = accounts.find(a => a.id === pf.accountId) || accounts[0];
302 - const selectedAccountId = selectedAccount?.id || null;
303 - const sig = selectedAccount?.emailSignature || '';
304 - const bodyWithSig = (pf.body || '') + (sig ? '\n\n-- \n' + sig : '');
305 -
306 - const ccPrefill = pf.cc || '';
307 - const bccPrefill = pf.bcc || '';
308 - const ccBccExpanded = !!(ccPrefill || bccPrefill);
309 -
310 - const fieldsHtml = GoingsOn.composeForm.buildFieldsHtml({
311 - accounts,
312 - selectedAccountId,
313 - prefill: {
314 - to: pf.to || '',
315 - cc: ccPrefill,
316 - bcc: bccPrefill,
317 - subject: pf.subject || '',
318 - body: bodyWithSig,
319 - },
320 - showCcBcc: ccBccExpanded,
321 - showAttachments: true,
322 - bodyRows: 8,
323 - });
324 -
325 - const replyContext = {
326 - inReplyTo: pf.inReplyTo || null,
327 - references: pf.references || null,
328 - threadId: pf.threadId || null,
329 - };
330 -
331 - const content = `
332 - <form id="compose-modal-form" onsubmit="event.preventDefault(); return false;">
333 - <span id="reply-indicator" class="reply-indicator"></span>
334 - ${fieldsHtml}
335 - <div class="form-actions" style="margin-top: 0.75rem;">
336 - <button type="button" class="btn btn-secondary" id="compose-modal-attach">Attach File</button>
337 - <button type="button" class="btn btn-secondary" id="compose-modal-save-draft">Save Draft</button>
338 - <div class="flex-1"></div>
339 - <button type="button" class="btn btn-secondary" id="compose-modal-cancel">Cancel</button>
340 - <button type="submit" class="btn btn-primary" id="compose-modal-send">Send</button>
341 - </div>
342 - </form>
343 - `;
344 -
345 - GoingsOn.modal.openModal('Compose Email', content);
346 -
347 - setTimeout(() => {
348 - const form = document.getElementById('compose-modal-form');
349 - if (!form) return;
350 -
351 - // Stage 4/5 — single bindBehaviors call owns autocomplete,
352 - // address highlight, CC/BCC toggle, signature swap, attachment
353 - // picker/render/remove, draft autosave, and reply indicator.
354 - modalComposeCtrl = GoingsOn.composeForm.bindBehaviors({
355 - accounts,
356 - initialSignature: sig,
357 - getContacts: GoingsOn.autocomplete.getContacts,
358 - onError: (msg) => GoingsOn.ui.showToast(msg, 'error', { duration: 8000 }),
359 - enableAutosave: true,
360 - initialDraftId: pf.draftId || null,
361 - saveDraft: (input) => GoingsOn.api.emails.saveDraft(input),
362 - getReplyContext: () => replyContext,
363 - onDraftStatus: (kind, message) => {
364 - if (kind === 'error') {
365 - GoingsOn.ui.showToast(message, 'error', { duration: 6000 });
366 - } else {
367 - GoingsOn.ui.showToast(message, 'success', { duration: 2500 });
368 - }
369 - },
370 - enableReplyIndicator: true,
371 - });
372 -
373 - const attachBtn = document.getElementById('compose-modal-attach');
374 - const cancelBtn = document.getElementById('compose-modal-cancel');
375 - const sendBtn = document.getElementById('compose-modal-send');
376 - const saveDraftBtn = document.getElementById('compose-modal-save-draft');
377 - if (attachBtn) attachBtn.addEventListener('click', modalComposeCtrl.pickAttachment);
378 - if (saveDraftBtn) saveDraftBtn.addEventListener('click', () => modalComposeCtrl.saveDraftNow());
379 - if (cancelBtn) cancelBtn.addEventListener('click', () => GoingsOn.ui.closeModal());
380 -
381 - const submit = async () => {
382 - modalComposeCtrl.setSending(true);
383 - const accountSelect = document.getElementById('from-account');
384 - const toEl = document.getElementById('to-address');
385 - const ccEl = document.getElementById('cc-address');
386 - const bccEl = document.getElementById('bcc-address');
387 - const subjectEl = document.getElementById('subject');
388 - const bodyEl = document.getElementById('body');
389 - const attachedFiles = modalComposeCtrl.getAttachedFiles();
390 - const input = GoingsOn.composeForm.collectInput({
391 - accountId: accountSelect ? accountSelect.value : null,
392 - toAddress: toEl ? toEl.value : '',
393 - ccAddress: ccEl ? ccEl.value : '',
394 - bccAddress: bccEl ? bccEl.value : '',
395 - subject: subjectEl ? subjectEl.value : '',
396 - body: bodyEl ? bodyEl.value : '',
397 - attachedFiles,
398 - replyContext,
399 - });
400 - const result = GoingsOn.composeForm.validateForSend(input, attachedFiles);
401 - if (!result.ok) {
402 - GoingsOn.ui.showToast(result.message, 'error', { duration: 8000 });
403 - modalComposeCtrl.setSending(false);
404 - return;
405 - }
406 - GoingsOn.ui.closeModal();
407 - queueSend({ input });
408 - };
409 - form.addEventListener('submit', (e) => { e.preventDefault(); submit(); });
410 - if (sendBtn) sendBtn.addEventListener('click', (e) => { e.preventDefault(); submit(); });
411 - }, 50);
412 - }
413 -
414 226 async function markAllRead() {
415 227 await GoingsOn.ui.apiCall(GoingsOn.api.emails.markAllRead(), {
416 228 successMessage: 'All emails marked as read!',
@@ -857,55 +669,6 @@
857 669 * @param {string} emailId - Email to reply to
858 670 * @param {boolean} replyAll - If true, include all recipients
859 671 */
860 - async function openReply(emailId, replyAll) {
861 - try {
862 - // Rust builds the reply prefill: recipients (reply-all excludes our
863 - // own accounts + de-dupes), Re: subject, and quoted body.
864 - const pf = await GoingsOn.api.emails.buildReplyPrefill(emailId, !!replyAll);
865 - if (!pf) return;
866 -
867 - const prefill = {
868 - to: pf.to,
869 - subject: pf.subject,
870 - body: pf.body,
871 - inReplyTo: pf.inReplyTo || null,
872 - references: pf.references || null,
873 - threadId: pf.threadId || null,
874 - accountId: pf.accountId || '',
875 - };
876 -
877 - if (GoingsOn.touch?.isTouchDevice) {
878 - openComposeModal(prefill);
879 - } else {
880 - await GoingsOn.api.window.openCompose(prefill);
881 - }
882 - } catch (err) {
883 - GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to open reply'), 'error');
884 - }
885 - }
886 -
887 - /**
888 - * Open compose window to forward an email.
889 - * @param {string} emailId - Email to forward
890 - */
891 - async function openForward(emailId) {
892 - try {
893 - // Rust builds the Fwd: subject and forwarded-message body block.
894 - const pf = await GoingsOn.api.emails.buildForwardPrefill(emailId);
895 - if (!pf) return;
896 -
897 - const prefill = { to: '', subject: pf.subject, body: pf.body, accountId: pf.accountId || '' };
898 -
899 - if (GoingsOn.touch?.isTouchDevice) {
900 - openComposeModal(prefill);
901 - } else {
902 - await GoingsOn.api.window.openCompose(prefill);
903 - }
904 - } catch (err) {
905 - GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to forward email'), 'error');
906 - }
907 - }
908 -
909 672 /**
910 673 * Open an email attachment blob with the system default app.
911 674 * @param {string} blobHash - SHA-256 hash of the blob
@@ -962,88 +725,6 @@
962 725 // renderModalAttachments / removeModalAttachment helpers and the
963 726 // modalAttachedFiles array are gone; access via modalComposeCtrl instead.
964 727
965 - // ============ Drafts ============
966 -
967 - /**
968 - * Load and display drafts in a modal.
969 - */
970 - async function openDraftsModal() {
971 - try {
972 - const drafts = await GoingsOn.api.emails.listDrafts();
973 -
974 - if (drafts.length === 0) {
975 - GoingsOn.ui.openModal('Drafts', '<div class="empty-state empty-state--compact"><p class="empty-state-text">No drafts</p></div>');
976 - return;
977 - }
978 -
979 - const listHtml = drafts.map(d => {
980 - const to = d.to || '(no recipient)';
981 - const subject = d.subject || '(no subject)';
982 - return `
983 - <div class="email-item email-draft-item"
984 - onclick="GoingsOn.emails.openDraft('${escArg(d.id)}')">
985 - <div class="email-draft-subject">${esc(subject)}</div>
986 - <div class="email-draft-meta">To: ${esc(to)} · ${d.receivedFormatted}</div>
987 - </div>
988 - `;
989 - }).join('');
990 -
991 - GoingsOn.ui.openModal('Drafts', `<div>${listHtml}</div>`);
992 - } catch (err) {
993 - GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load drafts'), 'error');
994 - }
995 - }
996 -
997 - /**
998 - * Open a draft for editing in the compose window.
999 - * @param {string} id - Draft email ID
1000 - */
1001 - async function openDraft(id) {
1002 - GoingsOn.ui.closeModal();
1003 - if (GoingsOn.touch?.isTouchDevice) {
1004 - // Mobile: open compose modal with draft data
1005 - try {
1006 - const draft = await GoingsOn.api.emails.get(id);
1007 - if (!draft) return;
1008 - openComposeModal({
1009 - to: draft.to || '',
1010 - cc: draft.ccAddress || '',
1011 - bcc: draft.bccAddress || '',
1012 - subject: draft.subject || '',
1013 - body: draft.body || '',
1014 - accountId: draft.draftAccountId || '',
1015 - inReplyTo: draft.inReplyTo || null,
1016 - threadId: draft.threadId || null,
1017 - draftId: draft.id,
1018 - });
1019 - } catch (err) {
1020 - GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to open draft'), 'error');
1021 - }
1022 - } else {
1023 - try {
1024 - await GoingsOn.api.window.openCompose({ draftId: id });
1025 - } catch (err) {
1026 - GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to open draft'), 'error');
1027 - }
1028 - }
1029 - }
1030 -
1031 - /**
1032 - * Send a draft directly.
1033 - * @param {string} id - Draft email ID
1034 - */
1035 - async function sendDraft(id) {
1036 - try {
1037 - await GoingsOn.api.emails.sendDraft(id);
1038 - GoingsOn.ui.showToast('Draft sent!', 'success');
1039 - GoingsOn.ui.closeModal();
1040 - GoingsOn.cache.invalidate('emails');
1041 - load();
1042 - } catch (err) {
1043 - GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to send draft'), 'error');
1044 - }
1045 - }
1046 -
1047 728 // ============ Folder & Label Filters ============
1048 729
1049 730 let activeFolder = '';
@@ -1266,7 +947,7 @@
1266 947 } else {
1267 948 emailScroller = new GoingsOn.VirtualScroller({
1268 949 container: container,
1269 - renderItem: renderEmailItem,
950 + renderItem: (t, i) => GoingsOn.emailsRender.renderEmailItem(t, i),
1270 951 getItems: () => GoingsOn.state.emailThreads,
1271 952 rowHeight: { estimated: 90, measure: true },
1272 953 overscan: 5,
@@ -1343,109 +1024,6 @@
1343 1024 }
1344 1025 }
1345 1026
1346 - // ============ Send-with-Delay (undo-send) ============
1347 -
1348 - /**
1349 - * Build a compose prefill object from a queued SendInput.
1350 - * Note: attachmentPaths are NOT preserved through compose re-open today
1351 - * (the compose UI uses a fresh file picker). When undo restores a message
1352 - * that had attachments, surface a notice so the user re-attaches.
1353 - */
1354 - function _sendInputToComposeParams(input) {
1355 - return {
1356 - to: input.to || input.toAddress || '',
1357 - subject: input.subject || '',
1358 - body: input.body || '',
1359 - accountId: input.accountId || null,
1360 - inReplyTo: input.inReplyTo || null,
1361 - references: input.references || null,
1362 - threadId: input.threadId || null,
1363 - };
1364 - }
1365 -
1366 - /**
1367 - * Queue an email send with an undo window. Returns immediately. The actual
1368 - * `api.emails.send` call fires when the undo window expires; if the user
1369 - * clicks Undo, the compose surface re-opens with the message restored.
1370 - *
1371 - * Charter rule: every irreversible operation gets an undo path (Phase 7
1372 - * Tier 1 #3 / Phase 3 #1).
1373 - *
1374 - * @param {Object} cfg
1375 - * @param {Object} cfg.input - SendInput (accountId / to / subject / body / inReplyTo / references / threadId / attachmentPaths)
1376 - * @param {number} [cfg.delaySeconds=5] - Undo window in seconds.
1377 - */
1378 - function queueSend({ input, delaySeconds = 5 }) {
1379 - const hadAttachments = Array.isArray(input.attachmentPaths) && input.attachmentPaths.length > 0;
1380 - const timeout = Math.max(1000, delaySeconds * 1000);
1381 - const message = input.inReplyTo ? 'Sending reply…' : 'Sending email…';
1382 -
1383 - GoingsOn.ui.showUndoToast(message, {
1384 - timeout,
1385 - onConfirm: async () => {
1386 - try {
1387 - const result = await GoingsOn.api.emails.send(input);
1388 - GoingsOn.ui.showToast('Email sent', 'success');
1389 - load();
1390 -
1391 - if (result?.newImplicitContacts?.length > 0) {
1392 - GoingsOn.autocomplete?.refresh?.();
1393 - for (const c of result.newImplicitContacts) {
1394 - const name = c.displayName || c.display_name || '';
1395 - GoingsOn.ui.showToast(`Save ${name} as a contact?`, 'info', {
1396 - duration: 8000,
1397 - action: {
1398 - label: 'Save',
1399 - fn: async () => {
1400 - try {
1401 - await GoingsOn.api.contacts.promoteContact(c.id);
1402 - GoingsOn.cache.invalidate('contacts');
1403 - GoingsOn.autocomplete?.refresh?.();
1404 - GoingsOn.ui.showToast(`${name} saved as contact`, 'success');
1405 - } catch (err) {
1406 - GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to save contact'), 'error');
1407 - }
1408 - },
1409 - },
1410 - });
1411 - }
1412 - }
1413 - } catch (err) {
1414 - // User is no longer in compose; offer to re-open with the message.
1415 - GoingsOn.ui.showToast(
1416 - 'Send failed: ' + GoingsOn.utils.getErrorMessage(err),
1417 - 'error',
1418 - {
1419 - duration: 12000,
1420 - action: {
1421 - label: 'Edit & retry',
1422 - fn: () => {
1423 - const prefill = _sendInputToComposeParams(input);
1424 - if (GoingsOn.touch?.isTouchDevice) {
1425 - openComposeModal(prefill);
1426 - } else {
1427 - GoingsOn.api.window.openCompose(prefill).catch(() => openComposeModal(prefill));
1428 - }
1429 - },
1430 - },
1431 - }
1432 - );
1433 - }
1434 - },
1435 - onUndo: () => {
1436 - const prefill = _sendInputToComposeParams(input);
1437 - if (GoingsOn.touch?.isTouchDevice) {
1438 - openComposeModal(prefill);
1439 - } else {
1440 - GoingsOn.api.window.openCompose(prefill).catch(() => openComposeModal(prefill));
1441 - }
1442 - if (hadAttachments) {
1443 - GoingsOn.ui.showToast('Re-attach your files — attachments cleared on undo.', 'info', { duration: 8000 });
1444 - }
1445 - },
1446 - });
1447 - }
1448 -
1449 1027 // ============ Populate GoingsOn.emails Namespace ============
1450 1028
1451 1029 /**
@@ -1468,8 +1046,6 @@
1468 1046
1469 1047 GoingsOn.emails = {
1470 1048 load,
1471 - openCompose,
1472 - openComposeModal,
1473 1049 markAllRead,
1474 1050 open,
1475 1051 loadFullBody,
@@ -1482,9 +1058,6 @@
1482 1058 createEventFromEmail,
1483 1059 createContactFromSender,
1484 1060 openInBrowser,
1485 - reply: (id) => openReply(id, false),
1486 - replyAll: (id) => openReply(id, true),
1487 - forward: openForward,
1488 1061 openBlob,
1489 1062 saveBlob,
1490 1063 search: searchEmails,
@@ -1494,10 +1067,17 @@
1494 1067 _saveLabels: saveLabels,
1495 1068 moveToFolder,
1496 1069 _doMoveToFolder: doMoveToFolder,
1497 - openDrafts: openDraftsModal,
1498 - openDraft,
1499 - sendDraft,
1500 - queueSend,
1070 + // Compose/reply/forward/drafts/send-delay live in emails-compose.js;
1071 + // delegate lazily so load order between the two files doesn't matter.
1072 + openCompose: (...a) => GoingsOn.emailsCompose.openCompose(...a),
1073 + openComposeModal: (...a) => GoingsOn.emailsCompose.openComposeModal(...a),
1074 + reply: (id) => GoingsOn.emailsCompose.openReply(id, false),
1075 + replyAll: (id) => GoingsOn.emailsCompose.openReply(id, true),
1076 + forward: (...a) => GoingsOn.emailsCompose.openForward(...a),
1077 + openDrafts: (...a) => GoingsOn.emailsCompose.openDraftsModal(...a),
1078 + openDraft: (...a) => GoingsOn.emailsCompose.openDraft(...a),
1079 + sendDraft: (...a) => GoingsOn.emailsCompose.sendDraft(...a),
1080 + queueSend: (...a) => GoingsOn.emailsCompose.queueSend(...a),
1501 1081 // Pagination
1502 1082 goToPage,
1503 1083 toggleSelection,
@@ -1507,8 +1087,8 @@
Lines truncated