Skip to main content

max / goingson

11.1 KB · 288 lines History Blame Raw
1 /* Compose-window page controller. Externalized from an inline <script> so
2 the CSP script-src can drop 'unsafe-inline'. */
3 let accounts = [];
4 let tauriInvoke = null;
5
6 // Reply context from URL params (set when opening reply/reply-all)
7 const replyContext = {
8 inReplyTo: null,
9 references: null,
10 threadId: null,
11 };
12
13 function getUrlParams() {
14 const params = new URLSearchParams(window.location.search);
15 return {
16 to: params.get('to') || '',
17 subject: params.get('subject') || '',
18 body: params.get('body') || '',
19 inReplyTo: params.get('inReplyTo') || null,
20 references: params.get('references') || null,
21 threadId: params.get('threadId') || null,
22 accountId: params.get('accountId') || null,
23 draftId: params.get('draftId') || null,
24 };
25 }
26
27 async function initTauri() {
28 if (window.__TAURI__) {
29 tauriInvoke = window.__TAURI__.core.invoke;
30 return true;
31 }
32 return false;
33 }
34
35 async function invoke(command, args = {}) {
36 if (!tauriInvoke) await initTauri();
37 if (!tauriInvoke) throw new Error('Tauri not available');
38 return tauriInvoke(command, args);
39 }
40
41 function setStatus(message, type = '') {
42 const statusBar = document.getElementById('status-bar');
43 statusBar.textContent = message;
44 statusBar.className = 'status-bar' + (type ? ' ' + type : '');
45 }
46
47 async function loadAccounts() {
48 try {
49 accounts = await invoke('list_email_accounts');
50 const select = document.getElementById('from-account');
51
52 if (accounts.length === 0) {
53 select.innerHTML = '<option value="">No email accounts configured</option>';
54 document.getElementById('send-btn').disabled = true;
55 setStatus('No email accounts configured. Add one in Settings.', 'error');
56 return;
57 }
58
59 select.innerHTML = accounts.map(a =>
60 `<option value="${a.id}">${escapeHtml(a.account_name)} &lt;${escapeHtml(a.email_address)}&gt;</option>`
61 ).join('');
62
63 setStatus('Ready');
64 } catch (err) {
65 setStatus('Failed to load accounts: ' + err, 'error');
66 }
67 }
68
69 async function sendEmail() {
70 if (composeCtrl) composeCtrl.setSending(true);
71
72 // Stages 1/4 of compose unification, payload shape + validation
73 // live in compose-form.js; attached files come from the controller.
74 const attachedFiles = composeCtrl ? composeCtrl.getAttachedFiles() : [];
75 const input = composeForm.collectInput({
76 accountId: document.getElementById('from-account').value,
77 toAddress: document.getElementById('to-address').value,
78 ccAddress: document.getElementById('cc-address').value,
79 bccAddress: document.getElementById('bcc-address').value,
80 subject: document.getElementById('subject').value,
81 body: document.getElementById('body').value,
82 attachedFiles,
83 replyContext,
84 });
85 const result = composeForm.validateForSend(input, attachedFiles);
86 if (!result.ok) {
87 setStatus(result.message, 'error');
88 if (composeCtrl) composeCtrl.setSending(false);
89 return;
90 }
91
92 document.body.classList.add('compose-loading');
93 const isReply = !!input.inReplyTo;
94 setStatus(isReply ? 'Queueing reply…' : 'Queueing send…');
95
96 // Send is queued in the main app with a 5 s undo window. The
97 // compose window closes immediately; the main app handles the
98 // actual send and any error / save-implicit-contact follow-up.
99 // Implicit-contact prompts now live on the main-app shell instead
100 // of the compose window, Phase 7 Tier 1 #3.
101 try {
102 await window.__TAURI__.event.emit('compose:queue-send', {
103 input,
104 delaySeconds: 5,
105 });
106 setStatus(isReply ? 'Reply queued; undo in main window' : 'Email queued; undo in main window', 'success');
107 setTimeout(() => {
108 window.__TAURI__.webviewWindow.getCurrentWebviewWindow().close();
109 }, 250);
110 } catch (err) {
111 document.body.classList.remove('compose-loading');
112 if (composeCtrl) composeCtrl.setSending(false);
113 setStatus('Failed to queue send: ' + err, 'error');
114 }
115 }
116
117 function saveDraft() {
118 if (composeCtrl) composeCtrl.saveDraftNow();
119 }
120
121 function discardAndClose() {
122 const body = document.getElementById('body').value;
123 const subject = document.getElementById('subject').value;
124 const to = document.getElementById('to-address').value;
125
126 if (body || subject || to) {
127 if (!confirm('Discard this message?')) {
128 return;
129 }
130 }
131
132 window.__TAURI__.webviewWindow.getCurrentWebviewWindow().close();
133 }
134
135 // Stage 4 of compose unification, autocomplete, address highlight,
136 // CC/BCC toggle, signature swap, and attachment picker/render/remove
137 // now live in composeForm.bindBehaviors. This inline script keeps a thin
138 // contacts loader (contactEmails); the controller's getContacts callback
139 // reads it. escape.js bootstraps GoingsOn here, so the compose API is on
140 // the namespace.
141
142 const composeForm = GoingsOn.composeForm;
143 let composeCtrl = null;
144 let contactEmails = []; // [{name, email, isImplicit}]
145
146 async function loadContactEmails() {
147 try {
148 // Lightweight directory (one JOIN, name + address only) instead of
149 // hydrating every contact's sub-collections just for autocomplete.
150 const entries = await invoke('list_contact_email_directory', { includeImplicit: true });
151 contactEmails = entries.map(e => ({
152 name: e.name || '',
153 email: e.email,
154 isImplicit: e.isImplicit || false,
155 }));
156 } catch (_) { /* contacts unavailable */ }
157 }
158
159 // Handle keyboard shortcuts
160 document.addEventListener('keydown', (e) => {
161 if (e.key === 'Escape') {
162 discardAndClose();
163 }
164 if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
165 sendEmail();
166 }
167 if ((e.ctrlKey || e.metaKey) && e.key === 's') {
168 e.preventDefault();
169 saveDraft();
170 }
171 });
172
173 // Initialize
174 document.addEventListener('DOMContentLoaded', async () => {
175 const form = document.getElementById('compose-form');
176 form.innerHTML = composeForm.buildFieldsHtml({
177 accountsLoading: true,
178 showAttachments: false, // window keeps attachments-bar outside the form
179 });
180
181 // Toolbar + form wiring (was inline on* attributes; moved to
182 // addEventListener so the CSP nonce can drop 'unsafe-inline').
183 document.getElementById('send-btn').addEventListener('click', sendEmail);
184 document.getElementById('save-draft-btn').addEventListener('click', saveDraft);
185 document.getElementById('discard-btn').addEventListener('click', discardAndClose);
186 form.addEventListener('submit', (e) => e.preventDefault());
187
188 await initTauri();
189 await loadAccounts();
190 await loadContactEmails();
191
192 // Stage 4, single bindBehaviors call replaces the old
193 // setupAutocomplete / attachAddressHighlight / appendSignatureForAccount
194 // / toggleCcBcc / pickAttachment / renderAttachments wiring. Must
195 // happen after loadAccounts so the controller sees the account list
196 // for signature swaps.
197 composeCtrl = composeForm.bindBehaviors({
198 accounts,
199 getContacts: () => contactEmails,
200 onError: (msg) => setStatus(msg, 'error'),
201 enableAutosave: true,
202 saveDraft: (input) => invoke('save_email_draft', { input }),
203 getReplyContext: () => replyContext,
204 onDraftStatus: (kind, message) => setStatus(message, kind === 'error' ? 'error' : 'success'),
205 enableReplyIndicator: true,
206 });
207 // Pause autosave during init so the URL-param / draft-resume path
208 // doesn't race with the first keystroke and create an orphan draft.
209 composeCtrl.setSending(true);
210 document.getElementById('attach-btn').addEventListener('click', composeCtrl.pickAttachment);
211
212 // Check for draft ID to resume editing
213 const params = getUrlParams();
214 if (params.draftId) {
215 try {
216 const draft = await invoke('get_email', { id: params.draftId });
217 if (draft && draft.isDraft) {
218 composeCtrl.setCurrentDraftId(draft.id);
219 document.getElementById('to-address').value = draft.to || '';
220 document.getElementById('cc-address').value = draft.ccAddress || '';
221 document.getElementById('bcc-address').value = draft.bccAddress || '';
222 document.getElementById('subject').value = draft.subject || '';
223 document.getElementById('body').value = draft.body || '';
224 if (draft.ccAddress || draft.bccAddress) composeCtrl.toggleCcBcc();
225 if (draft.draftAccountId) {
226 const select = document.getElementById('from-account');
227 if (select.querySelector(`option[value="${draft.draftAccountId}"]`)) {
228 select.value = draft.draftAccountId;
229 }
230 }
231 if (draft.inReplyTo) {
232 replyContext.inReplyTo = draft.inReplyTo;
233 replyContext.threadId = draft.threadId;
234 }
235 setStatus('Editing draft');
236 // Trigger address highlight sync for loaded values
237 for (const id of ['to-address', 'cc-address', 'bcc-address']) {
238 document.getElementById(id).dispatchEvent(new Event('input'));
239 }
240 }
241 } catch (_) { /* draft not found, start fresh */ }
242 }
243
244 const hasResumedDraft = !!composeCtrl.getCurrentDraftId();
245 // Apply reply context from URL params (skip if draft was loaded)
246 if (!hasResumedDraft && params.to) {
247 document.getElementById('to-address').value = params.to;
248 document.getElementById('to-address').dispatchEvent(new Event('input'));
249 }
250 if (!hasResumedDraft && params.subject) {
251 document.getElementById('subject').value = params.subject;
252 }
253 if (!hasResumedDraft && params.body) {
254 document.getElementById('body').value = params.body;
255 }
256 if (!hasResumedDraft && params.inReplyTo) {
257 replyContext.inReplyTo = params.inReplyTo;
258 replyContext.references = params.references;
259 replyContext.threadId = params.threadId;
260 }
261 // Auto-select the From account that received the original email
262 if (params.accountId) {
263 const select = document.getElementById('from-account');
264 if (select.querySelector(`option[value="${params.accountId}"]`)) {
265 select.value = params.accountId;
266 }
267 }
268
269 // Reply indicator + send-button relabel, controller reads replyContext.
270 composeCtrl.updateReplyIndicator();
271
272 // Append signature for the selected account
273 composeCtrl.appendSignatureForAccount(document.getElementById('from-account').value);
274
275 // Focus: body for replies (to/subject already filled), to for new compose
276 if (params.inReplyTo) {
277 document.getElementById('body').focus();
278 // Place cursor at the start (before quoted text)
279 const bodyEl = document.getElementById('body');
280 bodyEl.setSelectionRange(0, 0);
281 } else {
282 document.getElementById('to-address').focus();
283 }
284
285 // Init done, re-enable autosave on user input.
286 composeCtrl.setSending(false);
287 });
288