Skip to main content

max / goingson

GO-30-CSP W8: externalize inline scripts, drop script-src 'unsafe-inline' The actual fix for dead clicks on Linux. With every inline on* handler now delegated (W1-W3), the remaining blockers to a nonce-only script policy were the two inline <script> blocks: - index.html UI-mode bootstrap -> js/bootstrap-uimode.js - compose.html page controller -> js/compose-page.js CSP script-src is now 'self' (Tauri still adds its own nonce for the IPC bootstrap); 'unsafe-inline' is gone and the temporary dangerousDisableAssetCspModification scaffold is removed. This closes the untrusted-email -> __TAURI__ command-execution chain from the fuzz run and makes clicks work on WebKitGTK. style-src keeps 'unsafe-inline' (inline styles remain). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-13 21:05 UTC
Commit: bd16eb36a71d4d1a8e4a1ba6521bf74988e4e42f
Parent: fd23ac5
5 files changed, +320 insertions, -318 deletions
@@ -28,292 +28,6 @@
28 28 <script src="js/escape.js"></script>
29 29 <script src="js/address-highlight.js"></script>
30 30 <script src="js/compose-form.js"></script>
31 - <script>
32 - let accounts = [];
33 - let tauriInvoke = null;
34 -
35 - // Reply context from URL params (set when opening reply/reply-all)
36 - const replyContext = {
37 - inReplyTo: null,
38 - references: null,
39 - threadId: null,
40 - };
41 -
42 - function getUrlParams() {
43 - const params = new URLSearchParams(window.location.search);
44 - return {
45 - to: params.get('to') || '',
46 - subject: params.get('subject') || '',
47 - body: params.get('body') || '',
48 - inReplyTo: params.get('inReplyTo') || null,
49 - references: params.get('references') || null,
50 - threadId: params.get('threadId') || null,
51 - accountId: params.get('accountId') || null,
52 - draftId: params.get('draftId') || null,
53 - };
54 - }
55 -
56 - async function initTauri() {
57 - if (window.__TAURI__) {
58 - tauriInvoke = window.__TAURI__.core.invoke;
59 - return true;
60 - }
61 - return false;
62 - }
63 -
64 - async function invoke(command, args = {}) {
65 - if (!tauriInvoke) await initTauri();
66 - if (!tauriInvoke) throw new Error('Tauri not available');
67 - return tauriInvoke(command, args);
68 - }
69 -
70 - function setStatus(message, type = '') {
71 - const statusBar = document.getElementById('status-bar');
72 - statusBar.textContent = message;
73 - statusBar.className = 'status-bar' + (type ? ' ' + type : '');
74 - }
75 -
76 - async function loadAccounts() {
77 - try {
78 - accounts = await invoke('list_email_accounts');
79 - const select = document.getElementById('from-account');
80 -
81 - if (accounts.length === 0) {
82 - select.innerHTML = '<option value="">No email accounts configured</option>';
83 - document.getElementById('send-btn').disabled = true;
84 - setStatus('No email accounts configured. Add one in Settings.', 'error');
85 - return;
86 - }
87 -
88 - select.innerHTML = accounts.map(a =>
89 - `<option value="${a.id}">${escapeHtml(a.account_name)} &lt;${escapeHtml(a.email_address)}&gt;</option>`
90 - ).join('');
91 -
92 - setStatus('Ready');
93 - } catch (err) {
94 - setStatus('Failed to load accounts: ' + err, 'error');
95 - }
96 - }
97 -
98 - async function sendEmail() {
99 - if (composeCtrl) composeCtrl.setSending(true);
100 -
101 - // Stages 1/4 of compose unification — payload shape + validation
102 - // live in compose-form.js; attached files come from the controller.
103 - const attachedFiles = composeCtrl ? composeCtrl.getAttachedFiles() : [];
104 - const input = composeForm.collectInput({
105 - accountId: document.getElementById('from-account').value,
106 - toAddress: document.getElementById('to-address').value,
107 - ccAddress: document.getElementById('cc-address').value,
108 - bccAddress: document.getElementById('bcc-address').value,
109 - subject: document.getElementById('subject').value,
110 - body: document.getElementById('body').value,
111 - attachedFiles,
112 - replyContext,
113 - });
114 - const result = composeForm.validateForSend(input, attachedFiles);
115 - if (!result.ok) {
116 - setStatus(result.message, 'error');
117 - if (composeCtrl) composeCtrl.setSending(false);
118 - return;
119 - }
120 -
121 - document.body.classList.add('compose-loading');
122 - const isReply = !!input.inReplyTo;
123 - setStatus(isReply ? 'Queueing reply…' : 'Queueing send…');
124 -
125 - // Send is queued in the main app with a 5 s undo window. The
126 - // compose window closes immediately; the main app handles the
127 - // actual send and any error / save-implicit-contact follow-up.
128 - // Implicit-contact prompts now live on the main-app shell instead
129 - // of the compose window — Phase 7 Tier 1 #3.
130 - try {
131 - await window.__TAURI__.event.emit('compose:queue-send', {
132 - input,
133 - delaySeconds: 5,
134 - });
135 - setStatus(isReply ? 'Reply queued — undo in main window' : 'Email queued — undo in main window', 'success');
136 - setTimeout(() => {
137 - window.__TAURI__.webviewWindow.getCurrentWebviewWindow().close();
138 - }, 250);
139 - } catch (err) {
140 - document.body.classList.remove('compose-loading');
141 - if (composeCtrl) composeCtrl.setSending(false);
142 - setStatus('Failed to queue send: ' + err, 'error');
143 - }
144 - }
145 -
146 - function saveDraft() {
147 - if (composeCtrl) composeCtrl.saveDraftNow();
148 - }
149 -
150 - function discardAndClose() {
151 - const body = document.getElementById('body').value;
152 - const subject = document.getElementById('subject').value;
153 - const to = document.getElementById('to-address').value;
154 -
155 - if (body || subject || to) {
156 - if (!confirm('Discard this message?')) {
157 - return;
158 - }
159 - }
160 -
161 - window.__TAURI__.webviewWindow.getCurrentWebviewWindow().close();
162 - }
163 -
164 - // Stage 4 of compose unification — autocomplete, address highlight,
165 - // CC/BCC toggle, signature swap, and attachment picker/render/remove
166 - // now live in composeForm.bindBehaviors. This inline script keeps a thin
167 - // contacts loader (contactEmails); the controller's getContacts callback
168 - // reads it. escape.js bootstraps GoingsOn here, so the compose API is on
169 - // the namespace.
170 -
171 - const composeForm = GoingsOn.composeForm;
172 - let composeCtrl = null;
173 - let contactEmails = []; // [{name, email, isImplicit}]
174 -
175 - async function loadContactEmails() {
176 - try {
177 - // Lightweight directory (one JOIN, name + address only) instead of
178 - // hydrating every contact's sub-collections just for autocomplete.
179 - const entries = await invoke('list_contact_email_directory', { includeImplicit: true });
180 - contactEmails = entries.map(e => ({
181 - name: e.name || '',
182 - email: e.email,
183 - isImplicit: e.isImplicit || false,
184 - }));
185 - } catch (_) { /* contacts unavailable */ }
186 - }
187 -
188 - // Handle keyboard shortcuts
189 - document.addEventListener('keydown', (e) => {
190 - if (e.key === 'Escape') {
191 - discardAndClose();
192 - }
193 - if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
194 - sendEmail();
195 - }
196 - if ((e.ctrlKey || e.metaKey) && e.key === 's') {
197 - e.preventDefault();
198 - saveDraft();
199 - }
200 - });
201 -
202 - // Initialize
203 - document.addEventListener('DOMContentLoaded', async () => {
204 - const form = document.getElementById('compose-form');
205 - form.innerHTML = composeForm.buildFieldsHtml({
206 - accountsLoading: true,
207 - showAttachments: false, // window keeps attachments-bar outside the form
208 - });
209 -
210 - // Toolbar + form wiring (was inline on* attributes; moved to
211 - // addEventListener so the CSP nonce can drop 'unsafe-inline').
212 - document.getElementById('send-btn').addEventListener('click', sendEmail);
213 - document.getElementById('save-draft-btn').addEventListener('click', saveDraft);
214 - document.getElementById('discard-btn').addEventListener('click', discardAndClose);
215 - form.addEventListener('submit', (e) => e.preventDefault());
216 -
217 - await initTauri();
218 - await loadAccounts();
219 - await loadContactEmails();
220 -
221 - // Stage 4 — single bindBehaviors call replaces the old
222 - // setupAutocomplete / attachAddressHighlight / appendSignatureForAccount
223 - // / toggleCcBcc / pickAttachment / renderAttachments wiring. Must
224 - // happen after loadAccounts so the controller sees the account list
225 - // for signature swaps.
226 - composeCtrl = composeForm.bindBehaviors({
227 - accounts,
228 - getContacts: () => contactEmails,
229 - onError: (msg) => setStatus(msg, 'error'),
230 - enableAutosave: true,
231 - saveDraft: (input) => invoke('save_email_draft', { input }),
232 - getReplyContext: () => replyContext,
233 - onDraftStatus: (kind, message) => setStatus(message, kind === 'error' ? 'error' : 'success'),
234 - enableReplyIndicator: true,
235 - });
236 - // Pause autosave during init so the URL-param / draft-resume path
237 - // doesn't race with the first keystroke and create an orphan draft.
238 - composeCtrl.setSending(true);
239 - document.getElementById('attach-btn').addEventListener('click', composeCtrl.pickAttachment);
240 -
241 - // Check for draft ID to resume editing
242 - const params = getUrlParams();
243 - if (params.draftId) {
244 - try {
245 - const draft = await invoke('get_email', { id: params.draftId });
246 - if (draft && draft.isDraft) {
247 - composeCtrl.setCurrentDraftId(draft.id);
248 - document.getElementById('to-address').value = draft.to || '';
249 - document.getElementById('cc-address').value = draft.ccAddress || '';
250 - document.getElementById('bcc-address').value = draft.bccAddress || '';
251 - document.getElementById('subject').value = draft.subject || '';
252 - document.getElementById('body').value = draft.body || '';
253 - if (draft.ccAddress || draft.bccAddress) composeCtrl.toggleCcBcc();
254 - if (draft.draftAccountId) {
255 - const select = document.getElementById('from-account');
256 - if (select.querySelector(`option[value="${draft.draftAccountId}"]`)) {
257 - select.value = draft.draftAccountId;
258 - }
259 - }
260 - if (draft.inReplyTo) {
261 - replyContext.inReplyTo = draft.inReplyTo;
262 - replyContext.threadId = draft.threadId;
263 - }
264 - setStatus('Editing draft');
265 - // Trigger address highlight sync for loaded values
266 - for (const id of ['to-address', 'cc-address', 'bcc-address']) {
267 - document.getElementById(id).dispatchEvent(new Event('input'));
268 - }
269 - }
270 - } catch (_) { /* draft not found, start fresh */ }
271 - }
272 -
273 - const hasResumedDraft = !!composeCtrl.getCurrentDraftId();
274 - // Apply reply context from URL params (skip if draft was loaded)
275 - if (!hasResumedDraft && params.to) {
276 - document.getElementById('to-address').value = params.to;
277 - document.getElementById('to-address').dispatchEvent(new Event('input'));
278 - }
279 - if (!hasResumedDraft && params.subject) {
280 - document.getElementById('subject').value = params.subject;
281 - }
282 - if (!hasResumedDraft && params.body) {
283 - document.getElementById('body').value = params.body;
284 - }
285 - if (!hasResumedDraft && params.inReplyTo) {
286 - replyContext.inReplyTo = params.inReplyTo;
287 - replyContext.references = params.references;
288 - replyContext.threadId = params.threadId;
289 - }
290 - // Auto-select the From account that received the original email
291 - if (params.accountId) {
292 - const select = document.getElementById('from-account');
293 - if (select.querySelector(`option[value="${params.accountId}"]`)) {
294 - select.value = params.accountId;
295 - }
296 - }
297 -
298 - // Reply indicator + send-button relabel — controller reads replyContext.
299 - composeCtrl.updateReplyIndicator();
300 -
301 - // Append signature for the selected account
302 - composeCtrl.appendSignatureForAccount(document.getElementById('from-account').value);
303 -
304 - // Focus: body for replies (to/subject already filled), to for new compose
305 - if (params.inReplyTo) {
306 - document.getElementById('body').focus();
307 - // Place cursor at the start (before quoted text)
308 - const bodyEl = document.getElementById('body');
309 - bodyEl.setSelectionRange(0, 0);
310 - } else {
311 - document.getElementById('to-address').focus();
312 - }
313 -
314 - // Init done — re-enable autosave on user input.
315 - composeCtrl.setSending(false);
316 - });
317 - </script>
31 + <script src="js/compose-page.js"></script>
318 32 </body>
319 33 </html>
@@ -26,35 +26,7 @@
26 26 on Safari 13+ reports as Mac by default; the maxTouchPoints branch
27 27 catches that. Production lockdown of overrides is deferred to
28 28 phase 5 of ui_mode_separation_plan.md. -->
29 - <script>
30 - (function () {
31 - var mode;
32 - try {
33 - var p = new URLSearchParams(location.search).get('ui');
34 - if (p === 'mobile' || p === 'desktop') mode = p;
35 - } catch (e) {}
36 - if (!mode) {
37 - try {
38 - var ls = localStorage.getItem('goingson.uiMode');
39 - if (ls === 'mobile' || ls === 'desktop') mode = ls;
40 - } catch (e) {}
41 - }
42 - if (!mode) {
43 - var uad = navigator.userAgentData;
44 - if (uad && typeof uad.mobile === 'boolean') {
45 - mode = uad.mobile ? 'mobile' : 'desktop';
46 - }
47 - }
48 - if (!mode) {
49 - var ua = navigator.userAgent;
50 - var isMobileUA = /iPhone OS|iPad|Android/.test(ua);
51 - var isIPadAsMac = /Mac/.test(navigator.platform || '') && navigator.maxTouchPoints > 1;
52 - mode = (isMobileUA || isIPadAsMac) ? 'mobile' : 'desktop';
53 - }
54 - document.documentElement.classList.add('ui-mode-' + mode);
55 - window.__GO_UI_MODE__ = mode;
56 - })();
57 - </script>
29 + <script src="js/bootstrap-uimode.js"></script>
58 30
59 31 <link rel="stylesheet" href="css/styles.min.css">
60 32 </head>
@@ -0,0 +1,30 @@
1 + /* UI-mode bootstrap. Runs before the stylesheet so the ui-mode class is on
2 + <html> when CSS evaluates. Externalized from an inline <script> so the CSP
3 + script-src can drop 'unsafe-inline'. */
4 + (function () {
5 + var mode;
6 + try {
7 + var p = new URLSearchParams(location.search).get('ui');
8 + if (p === 'mobile' || p === 'desktop') mode = p;
9 + } catch (e) {}
10 + if (!mode) {
11 + try {
12 + var ls = localStorage.getItem('goingson.uiMode');
13 + if (ls === 'mobile' || ls === 'desktop') mode = ls;
14 + } catch (e) {}
15 + }
16 + if (!mode) {
17 + var uad = navigator.userAgentData;
18 + if (uad && typeof uad.mobile === 'boolean') {
19 + mode = uad.mobile ? 'mobile' : 'desktop';
20 + }
21 + }
22 + if (!mode) {
23 + var ua = navigator.userAgent;
24 + var isMobileUA = /iPhone OS|iPad|Android/.test(ua);
25 + var isIPadAsMac = /Mac/.test(navigator.platform || '') && navigator.maxTouchPoints > 1;
26 + mode = (isMobileUA || isIPadAsMac) ? 'mobile' : 'desktop';
27 + }
28 + document.documentElement.classList.add('ui-mode-' + mode);
29 + window.__GO_UI_MODE__ = mode;
30 + })();
@@ -0,0 +1,287 @@
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 + });
@@ -23,8 +23,7 @@
23 23 }
24 24 ],
25 25 "security": {
26 - "csp": "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: asset: http://asset.localhost; font-src 'self'; connect-src 'self' ipc: http://ipc.localhost; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'",
27 - "dangerousDisableAssetCspModification": ["script-src"]
26 + "csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: asset: http://asset.localhost; font-src 'self'; connect-src 'self' ipc: http://ipc.localhost; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'"
28 27 }
29 28 },
30 29 "bundle": {