Skip to main content

max / goingson

15.7 KB · 411 lines History Blame Raw
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 const escAttr = GoingsOn.utils.escapeAttrValue;
16
17 /**
18 * Open the email compose interface (new window on desktop, modal on mobile).
19 */
20 async function openCompose() {
21 // On mobile, compose in-app since Tauri doesn't support multiple windows
22 if (GoingsOn.touch?.isTouchDevice) {
23 openComposeModal();
24 return;
25 }
26
27 // Open compose in a new window using Tauri invoke
28 try {
29 await GoingsOn.api.window.openCompose();
30 } catch (err) {
31 console.error('Failed to open compose window:', err);
32 // Fall back to in-app modal
33 openComposeModal();
34 }
35 }
36
37 // Modal compose controller, populated after openComposeModal mounts and
38 // composeForm.bindBehaviors wires up the shared behaviors.
39 let modalComposeCtrl = null;
40
41 function openComposeModal(prefill) {
42 modalComposeCtrl = null;
43 const accounts = GoingsOn.getEmailAccountsCache();
44 const pf = prefill || {};
45
46 // Stage 3/4 of compose unification, markup and behaviors come from
47 // composeForm. The chrome (modal wrapper, footer action row, Attach
48 // button) stays modal-specific. CC/BCC sit after To, matching compose.html.
49
50 // Pre-append signature for the default/selected account; bindBehaviors
51 // tracks it so the first From-change knows what trailing block to strip.
52 const selectedAccount = accounts.find(a => a.id === pf.accountId) || accounts[0];
53 const selectedAccountId = selectedAccount?.id || null;
54 const sig = selectedAccount?.emailSignature || '';
55 const bodyWithSig = (pf.body || '') + (sig ? '\n\n-- \n' + sig : '');
56
57 const ccPrefill = pf.cc || '';
58 const bccPrefill = pf.bcc || '';
59 const ccBccExpanded = !!(ccPrefill || bccPrefill);
60
61 const fieldsHtml = GoingsOn.composeForm.buildFieldsHtml({
62 accounts,
63 selectedAccountId,
64 prefill: {
65 to: pf.to || '',
66 cc: ccPrefill,
67 bcc: bccPrefill,
68 subject: pf.subject || '',
69 body: bodyWithSig,
70 },
71 showCcBcc: ccBccExpanded,
72 showAttachments: true,
73 bodyRows: 8,
74 });
75
76 const replyContext = {
77 inReplyTo: pf.inReplyTo || null,
78 references: pf.references || null,
79 threadId: pf.threadId || null,
80 };
81
82 const content = `
83 <form id="compose-modal-form" data-submit="ui.noop">
84 <span id="reply-indicator" class="reply-indicator"></span>
85 ${fieldsHtml}
86 <div class="form-actions" style="margin-top: 0.75rem;">
87 <button type="button" class="btn btn-secondary" id="compose-modal-attach">Attach File</button>
88 <button type="button" class="btn btn-secondary" id="compose-modal-save-draft">Save Draft</button>
89 <div class="flex-1"></div>
90 <button type="button" class="btn btn-secondary" id="compose-modal-cancel">Cancel</button>
91 <button type="submit" class="btn btn-primary" id="compose-modal-send">Send</button>
92 </div>
93 </form>
94 `;
95
96 GoingsOn.modal.openModal('Compose Email', content);
97
98 setTimeout(() => {
99 const form = document.getElementById('compose-modal-form');
100 if (!form) return;
101
102 // Stage 4/5, single bindBehaviors call owns autocomplete,
103 // address highlight, CC/BCC toggle, signature swap, attachment
104 // picker/render/remove, draft autosave, and reply indicator.
105 modalComposeCtrl = GoingsOn.composeForm.bindBehaviors({
106 accounts,
107 initialSignature: sig,
108 getContacts: GoingsOn.autocomplete.getContacts,
109 onError: (msg) => GoingsOn.ui.showToast(msg, 'error', { duration: 8000 }),
110 enableAutosave: true,
111 initialDraftId: pf.draftId || null,
112 saveDraft: (input) => GoingsOn.api.emails.saveDraft(input),
113 getReplyContext: () => replyContext,
114 onDraftStatus: (kind, message) => {
115 if (kind === 'error') {
116 GoingsOn.ui.showToast(message, 'error', { duration: 6000 });
117 } else {
118 GoingsOn.ui.showToast(message, 'success', { duration: 2500 });
119 }
120 },
121 enableReplyIndicator: true,
122 });
123
124 const attachBtn = document.getElementById('compose-modal-attach');
125 const cancelBtn = document.getElementById('compose-modal-cancel');
126 const sendBtn = document.getElementById('compose-modal-send');
127 const saveDraftBtn = document.getElementById('compose-modal-save-draft');
128 if (attachBtn) attachBtn.addEventListener('click', modalComposeCtrl.pickAttachment);
129 if (saveDraftBtn) saveDraftBtn.addEventListener('click', () => modalComposeCtrl.saveDraftNow());
130 if (cancelBtn) cancelBtn.addEventListener('click', () => GoingsOn.ui.closeModal());
131
132 const submit = async () => {
133 modalComposeCtrl.setSending(true);
134 const accountSelect = document.getElementById('from-account');
135 const toEl = document.getElementById('to-address');
136 const ccEl = document.getElementById('cc-address');
137 const bccEl = document.getElementById('bcc-address');
138 const subjectEl = document.getElementById('subject');
139 const bodyEl = document.getElementById('body');
140 const attachedFiles = modalComposeCtrl.getAttachedFiles();
141 const input = GoingsOn.composeForm.collectInput({
142 accountId: accountSelect ? accountSelect.value : null,
143 toAddress: toEl ? toEl.value : '',
144 ccAddress: ccEl ? ccEl.value : '',
145 bccAddress: bccEl ? bccEl.value : '',
146 subject: subjectEl ? subjectEl.value : '',
147 body: bodyEl ? bodyEl.value : '',
148 attachedFiles,
149 replyContext,
150 });
151 const result = GoingsOn.composeForm.validateForSend(input, attachedFiles);
152 if (!result.ok) {
153 GoingsOn.ui.showToast(result.message, 'error', { duration: 8000 });
154 modalComposeCtrl.setSending(false);
155 return;
156 }
157 GoingsOn.ui.closeModal();
158 queueSend({ input });
159 };
160 form.addEventListener('submit', (e) => { e.preventDefault(); submit(); });
161 if (sendBtn) sendBtn.addEventListener('click', (e) => { e.preventDefault(); submit(); });
162 }, 50);
163 }
164
165 async function openReply(emailId, replyAll) {
166 try {
167 // Rust builds the reply prefill: recipients (reply-all excludes our
168 // own accounts + de-dupes), Re: subject, and quoted body.
169 const pf = await GoingsOn.api.emails.buildReplyPrefill(emailId, !!replyAll);
170 if (!pf) return;
171
172 const prefill = {
173 to: pf.to,
174 subject: pf.subject,
175 body: pf.body,
176 inReplyTo: pf.inReplyTo || null,
177 references: pf.references || null,
178 threadId: pf.threadId || null,
179 accountId: pf.accountId || '',
180 };
181
182 if (GoingsOn.touch?.isTouchDevice) {
183 openComposeModal(prefill);
184 } else {
185 await GoingsOn.api.window.openCompose(prefill);
186 }
187 } catch (err) {
188 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to open reply'), 'error');
189 }
190 }
191
192 /**
193 * Open compose window to forward an email.
194 * @param {string} emailId - Email to forward
195 */
196 async function openForward(emailId) {
197 try {
198 // Rust builds the Fwd: subject and forwarded-message body block.
199 const pf = await GoingsOn.api.emails.buildForwardPrefill(emailId);
200 if (!pf) return;
201
202 const prefill = { to: '', subject: pf.subject, body: pf.body, accountId: pf.accountId || '' };
203
204 if (GoingsOn.touch?.isTouchDevice) {
205 openComposeModal(prefill);
206 } else {
207 await GoingsOn.api.window.openCompose(prefill);
208 }
209 } catch (err) {
210 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to forward email'), 'error');
211 }
212 }
213
214 // Drafts
215
216 /**
217 * Load and display drafts in a modal.
218 */
219 async function openDraftsModal() {
220 try {
221 const drafts = await GoingsOn.api.emails.listDrafts();
222
223 if (drafts.length === 0) {
224 GoingsOn.ui.openModal('Drafts', '<div class="empty-state empty-state--compact"><p class="empty-state-text">No drafts</p></div>');
225 return;
226 }
227
228 const listHtml = drafts.map(d => {
229 const to = d.to || '(no recipient)';
230 const subject = d.subject || '(no subject)';
231 return `
232 <div class="email-item email-draft-item"
233 data-act="emails.openDraft" data-a1="${escAttr(d.id)}">
234 <div class="email-draft-subject">${esc(subject)}</div>
235 <div class="email-draft-meta">To: ${esc(to)} · ${d.receivedFormatted}</div>
236 </div>
237 `;
238 }).join('');
239
240 GoingsOn.ui.openModal('Drafts', `<div>${listHtml}</div>`);
241 } catch (err) {
242 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load drafts'), 'error');
243 }
244 }
245
246 /**
247 * Open a draft for editing in the compose window.
248 * @param {string} id - Draft email ID
249 */
250 async function openDraft(id) {
251 GoingsOn.ui.closeModal();
252 if (GoingsOn.touch?.isTouchDevice) {
253 // Mobile: open compose modal with draft data
254 try {
255 const draft = await GoingsOn.api.emails.get(id);
256 if (!draft) return;
257 openComposeModal({
258 to: draft.to || '',
259 cc: draft.ccAddress || '',
260 bcc: draft.bccAddress || '',
261 subject: draft.subject || '',
262 body: draft.body || '',
263 accountId: draft.draftAccountId || '',
264 inReplyTo: draft.inReplyTo || null,
265 threadId: draft.threadId || null,
266 draftId: draft.id,
267 });
268 } catch (err) {
269 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to open draft'), 'error');
270 }
271 } else {
272 try {
273 await GoingsOn.api.window.openCompose({ draftId: id });
274 } catch (err) {
275 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to open draft'), 'error');
276 }
277 }
278 }
279
280 /**
281 * Send a draft directly.
282 * @param {string} id - Draft email ID
283 */
284 async function sendDraft(id) {
285 try {
286 await GoingsOn.api.emails.sendDraft(id);
287 GoingsOn.ui.showToast('Draft sent!', 'success');
288 GoingsOn.ui.closeModal();
289 GoingsOn.cache.invalidate('emails');
290 GoingsOn.emails.load();
291 } catch (err) {
292 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to send draft'), 'error');
293 }
294 }
295
296 // Send-with-Delay (undo-send)
297
298 /**
299 * Build a compose prefill object from a queued SendInput.
300 * Note: attachmentPaths are NOT preserved through compose re-open today
301 * (the compose UI uses a fresh file picker). When undo restores a message
302 * that had attachments, surface a notice so the user re-attaches.
303 */
304 function _sendInputToComposeParams(input) {
305 return {
306 to: input.to || input.toAddress || '',
307 subject: input.subject || '',
308 body: input.body || '',
309 accountId: input.accountId || null,
310 inReplyTo: input.inReplyTo || null,
311 references: input.references || null,
312 threadId: input.threadId || null,
313 };
314 }
315
316 /**
317 * Queue an email send with an undo window. Returns immediately. The actual
318 * `api.emails.send` call fires when the undo window expires; if the user
319 * clicks Undo, the compose surface re-opens with the message restored.
320 *
321 * Charter rule: every irreversible operation gets an undo path (Phase 7
322 * Tier 1 #3 / Phase 3 #1).
323 *
324 * @param {Object} cfg
325 * @param {Object} cfg.input - SendInput (accountId / to / subject / body / inReplyTo / references / threadId / attachmentPaths)
326 * @param {number} [cfg.delaySeconds=5] - Undo window in seconds.
327 */
328 function queueSend({ input, delaySeconds = 5 }) {
329 const hadAttachments = Array.isArray(input.attachmentPaths) && input.attachmentPaths.length > 0;
330 const timeout = Math.max(1000, delaySeconds * 1000);
331 const message = input.inReplyTo ? 'Sending reply…' : 'Sending email…';
332
333 GoingsOn.ui.showUndoToast(message, {
334 timeout,
335 onConfirm: async () => {
336 try {
337 const result = await GoingsOn.api.emails.send(input);
338 GoingsOn.ui.showToast('Email sent', 'success');
339 GoingsOn.emails.load();
340
341 if (result?.newImplicitContacts?.length > 0) {
342 GoingsOn.autocomplete?.refresh?.();
343 for (const c of result.newImplicitContacts) {
344 const name = c.displayName || c.display_name || '';
345 GoingsOn.ui.showToast(`Save ${name} as a contact?`, 'info', {
346 duration: 8000,
347 action: {
348 label: 'Save',
349 fn: async () => {
350 try {
351 await GoingsOn.api.contacts.promoteContact(c.id);
352 GoingsOn.cache.invalidate('contacts');
353 GoingsOn.autocomplete?.refresh?.();
354 GoingsOn.ui.showToast(`${name} saved as contact`, 'success');
355 } catch (err) {
356 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to save contact'), 'error');
357 }
358 },
359 },
360 });
361 }
362 }
363 } catch (err) {
364 // User is no longer in compose; offer to re-open with the message.
365 GoingsOn.ui.showToast(
366 'Send failed: ' + GoingsOn.utils.getErrorMessage(err),
367 'error',
368 {
369 duration: 12000,
370 action: {
371 label: 'Edit & retry',
372 fn: () => {
373 const prefill = _sendInputToComposeParams(input);
374 if (GoingsOn.touch?.isTouchDevice) {
375 openComposeModal(prefill);
376 } else {
377 GoingsOn.api.window.openCompose(prefill).catch(() => openComposeModal(prefill));
378 }
379 },
380 },
381 }
382 );
383 }
384 },
385 onUndo: () => {
386 const prefill = _sendInputToComposeParams(input);
387 if (GoingsOn.touch?.isTouchDevice) {
388 openComposeModal(prefill);
389 } else {
390 GoingsOn.api.window.openCompose(prefill).catch(() => openComposeModal(prefill));
391 }
392 if (hadAttachments) {
393 GoingsOn.ui.showToast('Re-attach your files; attachments cleared on undo.', 'info', { duration: 8000 });
394 }
395 },
396 });
397 }
398
399 GoingsOn.emailsCompose = {
400 openCompose,
401 openComposeModal,
402 openReply,
403 openForward,
404 openDraftsModal,
405 openDraft,
406 sendDraft,
407 queueSend,
408 };
409
410 })();
411