Skip to main content

max / goingson

Compose unification stage 1: shared SendEmailInput contract New js/compose-form.js module owns the SendEmailInput payload shape, attachment caps (warn at 20 MB, hard cap at 25 MB), and per-surface validation. Both compose surfaces — the desktop compose.html window and the in-app modal in emails.js — now call into composeForm.collectInput, composeForm.validateForSend, and composeForm.formatBytes instead of maintaining parallel copies. The to vs toAddress payload shim that shipped both keys is gone. Send-with-delay (5 s undo toast) is the unified send path. compose.html emits compose:queue-send to the main window; app.js listens for the event and routes through emails.queueSend, which is the same undo path the modal uses. queueSend handles implicit-contact "Save as contact?" prompts after the actual send fires, replacing compose.html's inline post-send save-contact bar. New get_file_size Tauri command lets the modal display a per-message attachment running total with the cap warning, matching the desktop window's behavior. Migration plan and stage breakdown land with stage 2. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-20 23:27 UTC
Commit: a3b937e36e4d80a493c1f216e73c6a1115cb0e2e
Parent: b79f301
8 files changed, +474 insertions, -110 deletions
@@ -166,6 +166,22 @@
166 166 padding: 0 0.25rem;
167 167 }
168 168
169 + .compose-attachment-total {
170 + display: flex;
171 + gap: 0.5rem;
172 + align-items: baseline;
173 + padding-top: 0.5rem;
174 + margin-top: 0.25rem;
175 + font-size: 0.8125rem;
176 + color: var(--text-muted);
177 + border-top: 1px dashed var(--border-color);
178 + }
179 + .compose-attachment-total.over-warn { color: var(--accent-yellow); }
180 + .compose-attachment-total.over-cap { color: var(--accent-red); }
181 + .compose-attachment-warn {
182 + font-style: italic;
183 + }
184 +
169 185 /* Autocomplete dropdown */
170 186 .autocomplete-wrapper {
171 187 position: relative;
@@ -205,12 +221,62 @@
205 221 color: var(--text-secondary);
206 222 margin-left: 0.5rem;
207 223 }
224 +
225 + .reply-indicator {
226 + display: none;
227 + color: var(--text-secondary);
228 + font-size: 0.8125rem;
229 + align-self: center;
230 + }
231 + .header-row--tight {
232 + padding: 0.25rem 1rem;
233 + }
234 + .toggle-cc-btn {
235 + background: none;
236 + border: none;
237 + color: var(--text-secondary);
238 + font-size: 0.8125rem;
239 + cursor: pointer;
240 + padding: 0;
241 + }
242 + .save-contact-bar {
243 + position: fixed;
244 + bottom: 2rem;
245 + left: 50%;
246 + transform: translateX(-50%);
247 + background: var(--bg-secondary);
248 + border: 1px solid var(--border-color);
249 + border-radius: var(--radius-md);
250 + padding: 0.75rem 1rem;
251 + display: flex;
252 + align-items: center;
253 + gap: 0.75rem;
254 + z-index: 1000;
255 + box-shadow: var(--shadow-brutal-md);
256 + font-size: 0.875rem;
257 + }
258 + .save-contact-bar .save-btn {
259 + background: var(--accent-primary);
260 + color: var(--text-on-accent);
261 + border: none;
262 + padding: 0.375rem 0.75rem;
263 + border-radius: var(--radius-xs);
264 + cursor: pointer;
265 + font-size: 0.8125rem;
266 + }
267 + .save-contact-bar .dismiss-btn {
268 + background: none;
269 + border: none;
270 + color: var(--text-secondary);
271 + cursor: pointer;
272 + font-size: 0.8125rem;
273 + }
208 274 </style>
209 275 </head>
210 276 <body>
211 277 <div class="compose-toolbar">
212 278 <button class="btn btn-primary" id="send-btn" onclick="sendEmail()">Send</button>
213 - <span id="reply-indicator" style="display:none; color: var(--text-secondary); font-size: 0.8125rem; align-self: center;"></span>
279 + <span id="reply-indicator" class="reply-indicator"></span>
214 280 <button class="btn btn-secondary" onclick="saveDraft()">Save Draft</button>
215 281 <button class="btn btn-secondary" onclick="pickAttachment()">Attach</button>
216 282 <div class="toolbar-spacer"></div>
@@ -242,9 +308,9 @@
242 308 <input type="text" class="header-input" id="bcc-address" placeholder="bcc@example.com (comma-separated)" autocomplete="off">
243 309 </div>
244 310 </div>
245 - <div class="header-row" style="padding: 0.25rem 1rem;">
311 + <div class="header-row header-row--tight">
246 312 <span class="header-label"></span>
247 - <button type="button" id="toggle-cc" style="background: none; border: none; color: var(--text-secondary); font-size: 0.8125rem; cursor: pointer; padding: 0;" onclick="toggleCcBcc()">Show CC/BCC</button>
313 + <button type="button" id="toggle-cc" class="toggle-cc-btn" onclick="toggleCcBcc()">Show CC/BCC</button>
248 314 </div>
249 315 <div class="header-row">
250 316 <label class="header-label">Subject:</label>
@@ -259,6 +325,7 @@
259 325 <div class="status-bar" id="status-bar">Ready</div>
260 326
261 327 <script src="js/address-highlight.js"></script>
328 + <script src="js/compose-form.js"></script>
262 329 <script>
263 330 let accounts = [];
264 331 let tauriInvoke = null;
@@ -328,11 +395,11 @@
328 395
329 396 function showSaveContactPrompt(name, onSave, onDismiss) {
330 397 const bar = document.createElement('div');
331 - bar.style.cssText = 'position: fixed; bottom: 2rem; left: 50%; transform: translateX(-50%); background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 8px; padding: 0.75rem 1rem; display: flex; align-items: center; gap: 0.75rem; z-index: 1000; box-shadow: 0 4px 12px rgba(0,0,0,0.15); font-size: 0.875rem;';
398 + bar.className = 'save-contact-bar';
332 399 bar.innerHTML = `
333 400 <span>Save <strong>${name.replace(/</g, '&lt;')}</strong> as a contact?</span>
334 - <button style="background: var(--accent-primary); color: white; border: none; padding: 0.375rem 0.75rem; border-radius: 4px; cursor: pointer; font-size: 0.8125rem;">Save</button>
335 - <button style="background: none; border: none; color: var(--text-secondary); cursor: pointer; font-size: 0.8125rem;">Dismiss</button>
401 + <button class="save-btn">Save</button>
402 + <button class="dismiss-btn">Dismiss</button>
336 403 `;
337 404 bar.querySelector('button:first-of-type').onclick = () => { bar.remove(); onSave(); };
338 405 bar.querySelector('button:last-of-type').onclick = () => { bar.remove(); onDismiss(); };
@@ -342,82 +409,48 @@
342 409 async function sendEmail() {
343 410 isSending = true;
344 411 clearTimeout(autosaveTimer);
345 - const accountId = document.getElementById('from-account').value;
346 - const toAddress = document.getElementById('to-address').value.trim();
347 - const ccAddress = document.getElementById('cc-address').value.trim();
348 - const bccAddress = document.getElementById('bcc-address').value.trim();
349 - const subject = document.getElementById('subject').value.trim();
350 - const body = document.getElementById('body').value;
351 412
352 - if (!accountId) {
353 - setStatus('Please select a From account', 'error');
354 - return;
355 - }
356 -
357 - if (!toAddress) {
358 - setStatus('Please enter a recipient', 'error');
359 - return;
360 - }
361 -
362 - if (!subject) {
363 - setStatus('Please enter a subject', 'error');
413 + // Stage 1 of compose unification — payload shape + validation
414 + // now live in compose-form.js, shared with the in-app modal.
415 + const input = composeForm.collectInput({
416 + accountId: document.getElementById('from-account').value,
417 + toAddress: document.getElementById('to-address').value,
418 + ccAddress: document.getElementById('cc-address').value,
419 + bccAddress: document.getElementById('bcc-address').value,
420 + subject: document.getElementById('subject').value,
421 + body: document.getElementById('body').value,
422 + attachedFiles,
423 + replyContext,
424 + });
425 + const result = composeForm.validateForSend(input, attachedFiles);
426 + if (!result.ok) {
427 + setStatus(result.message, 'error');
428 + isSending = false;
364 429 return;
365 430 }
366 431
367 432 document.body.classList.add('compose-loading');
368 - const isReply = !!replyContext.inReplyTo;
369 - setStatus(isReply ? 'Sending reply...' : 'Sending...');
370 -
433 + const isReply = !!input.inReplyTo;
434 + setStatus(isReply ? 'Queueing reply…' : 'Queueing send…');
435 +
436 + // Send is queued in the main app with a 5 s undo window. The
437 + // compose window closes immediately; the main app handles the
438 + // actual send and any error / save-implicit-contact follow-up.
439 + // Implicit-contact prompts now live on the main-app shell instead
440 + // of the compose window — Phase 7 Tier 1 #3.
371 441 try {
372 - const result = await invoke('send_email', {
373 - input: {
374 - accountId: accountId,
375 - toAddress: toAddress,
376 - ccAddress: ccAddress || null,
377 - bccAddress: bccAddress || null,
378 - subject: subject,
379 - body: body,
380 - projectId: null,
381 - inReplyTo: replyContext.inReplyTo,
382 - references: replyContext.references,
383 - threadId: replyContext.threadId,
384 - attachmentPaths: attachedFiles.map(f => f.path),
385 - }
442 + await window.__TAURI__.event.emit('compose:queue-send', {
443 + input,
444 + delaySeconds: 5,
386 445 });
387 -
388 - setStatus(isReply ? 'Reply sent!' : 'Email sent!', 'success');
389 -
390 - const implicitContacts = result?.newImplicitContacts || [];
391 - if (implicitContacts.length === 0) {
392 - setTimeout(() => {
393 - window.__TAURI__.webviewWindow.getCurrentWebviewWindow().close();
394 - }, 1000);
395 - } else {
396 - // Show save-contact prompts, delay close
397 - const closeWindow = () => window.__TAURI__.webviewWindow.getCurrentWebviewWindow().close();
398 - let closeTimer = setTimeout(closeWindow, 8000);
399 -
400 - for (const c of implicitContacts) {
401 - const name = c.displayName || c.display_name || '';
402 - showSaveContactPrompt(name, async () => {
403 - clearTimeout(closeTimer);
404 - try {
405 - await invoke('promote_contact', { id: c.id });
406 - setStatus(`${name} saved as contact`, 'success');
407 - setTimeout(closeWindow, 1500);
408 - } catch (_) {
409 - closeWindow();
410 - }
411 - }, () => {
412 - // Dismiss — close after short delay if no other prompts
413 - clearTimeout(closeTimer);
414 - closeTimer = setTimeout(closeWindow, 1000);
415 - });
416 - }
417 - }
446 + setStatus(isReply ? 'Reply queued — undo in main window' : 'Email queued — undo in main window', 'success');
447 + setTimeout(() => {
448 + window.__TAURI__.webviewWindow.getCurrentWebviewWindow().close();
449 + }, 250);
418 450 } catch (err) {
419 451 document.body.classList.remove('compose-loading');
420 - setStatus('Failed to send: ' + err, 'error');
452 + isSending = false;
453 + setStatus('Failed to queue send: ' + err, 'error');
421 454 }
422 455 }
423 456
@@ -522,6 +555,23 @@
522 555
523 556 let attachedFiles = []; // [{path, name, size}]
524 557
558 + // Phase 7 Tier 6 #3 stage 1: caps + formatters now live on
559 + // window.GoingsOnComposeForm. The compose webview doesn't bootstrap
560 + // the GoingsOn namespace, hence the `window.GoingsOnComposeForm`
561 + // export rather than `GoingsOn.composeForm`.
562 + const composeForm = window.GoingsOnComposeForm;
563 + const ATTACHMENT_HARD_CAP = composeForm.ATTACHMENT_HARD_CAP_BYTES;
564 + const ATTACHMENT_WARN_CAP = composeForm.ATTACHMENT_WARN_CAP_BYTES;
565 + const formatBytes = composeForm.formatBytes;
566 +
567 + function totalAttachmentBytes() {
568 + return composeForm.totalAttachmentBytes(attachedFiles);
569 + }
570 +
571 + function attachmentsExceedCap() {
572 + return composeForm.exceedsAttachmentCap(attachedFiles).over;
573 + }
574 +
525 575 async function pickAttachment() {
526 576 try {
527 577 const { open } = window.__TAURI__.dialog;
@@ -538,7 +588,14 @@
538 588 // Avoid duplicates
539 589 if (attachedFiles.some(f => f.path === filePath)) continue;
540 590 const name = filePath.split(/[/\\]/).pop() || 'file';
541 - attachedFiles.push({ path: filePath, name });
591 + let size = 0;
592 + try {
593 + size = await invoke('get_file_size', { filePath });
594 + } catch (_) {
595 + // If we can't stat the file, leave size 0; the running
596 + // total will under-report but won't block the send.
597 + }
598 + attachedFiles.push({ path: filePath, name, size });
542 599 }
543 600 renderAttachments();
544 601 } catch (err) {
@@ -559,13 +616,30 @@
559 616 bar.innerHTML = '';
560 617 return;
561 618 }
619 + const total = totalAttachmentBytes();
620 + const overCap = total > ATTACHMENT_HARD_CAP;
621 + const overWarn = total > ATTACHMENT_WARN_CAP;
622 + const totalClass = overCap ? 'compose-attachment-total over-cap'
623 + : overWarn ? 'compose-attachment-total over-warn'
624 + : 'compose-attachment-total';
625 +
562 626 bar.style.display = 'block';
563 - bar.innerHTML = attachedFiles.map((f, i) => `
627 + const items = attachedFiles.map((f, i) => `
564 628 <div class="compose-attachment-item">
565 629 <span class="compose-attachment-name" title="${escapeHtml(f.path)}">${escapeHtml(f.name)}</span>
630 + <span class="compose-attachment-size">${formatBytes(f.size || 0)}</span>
566 631 <button class="compose-attachment-remove" onclick="removeAttachment(${i})" title="Remove">&times;</button>
567 632 </div>
568 633 `).join('');
634 + const totalLine = `
635 + <div class="${totalClass}">
636 + <span>Total: ${formatBytes(total)} / ${formatBytes(ATTACHMENT_HARD_CAP)}</span>
637 + ${overCap ? '<span class="compose-attachment-warn">— most mail servers will reject this. Remove some files or use a file-share link.</span>'
638 + : overWarn ? '<span class="compose-attachment-warn">— large attachment; your mail server may reject this.</span>'
639 + : ''}
640 + </div>
641 + `;
642 + bar.innerHTML = items + totalLine;
569 643 }
570 644
571 645 // ============ Email Signature ============
@@ -301,6 +301,7 @@ const api = {
301 301 convertFromEmail: (emailId, taskId) => invoke('convert_email_attachments', { emailId, taskId }),
302 302 openEmailBlob: (blobHash, filename) => invoke('open_email_blob', { blobHash, filename }),
303 303 saveEmailBlob: (blobHash, destination) => invoke('save_email_blob', { blobHash, destination }),
304 + fileSize: (filePath) => invoke('get_file_size', { filePath }),
304 305 },
305 306
306 307 // External Import — vCard contacts and iCalendar events
@@ -99,6 +99,18 @@ document.addEventListener('click', (e) => {
99 99 if (window.__TAURI__) {
100 100 const { listen } = window.__TAURI__.event;
101 101
102 + // Fired by compose.html sendEmail() so the compose window can close
103 + // immediately while the main app holds the 5 s undo toast.
104 + listen('compose:queue-send', (event) => {
105 + const payload = event?.payload || {};
106 + if (payload.input) {
107 + GoingsOn.emails.queueSend({
108 + input: payload.input,
109 + delaySeconds: payload.delaySeconds || 5,
110 + });
111 + }
112 + });
113 +
102 114 // File menu
103 115 listen('menu:new_task', () => GoingsOn.tasks.openNew());
104 116 listen('menu:new_project', () => GoingsOn.projects.openNew());
@@ -0,0 +1,145 @@
1 + /**
2 + * GoingsOn — Shared compose-form contract (Phase 7 Tier 6 #3, stage 1).
3 + *
4 + * This module is the single source of truth for:
5 + * - attachment-cap thresholds (`ATTACHMENT_WARN_CAP_BYTES`, `ATTACHMENT_HARD_CAP_BYTES`)
6 + * - the SendEmailInput payload shape sent to `send_email` (camelCase,
7 + * matching `commands/email.rs::SendEmailInput`)
8 + * - the validation rules every compose surface must enforce
9 + *
10 + * Both compose surfaces — the desktop `compose.html` window and the in-app
11 + * `openComposeModal` — used to maintain their own copies. The drift that
12 + * produced is documented in `docs/ux-audit/compose-migration.md`; this
13 + * module is stage 1 of that migration. Later stages will collapse the
14 + * markup and behaviors; for now, just the data contract.
15 + *
16 + * The module is safe to load in either context (main app webview *or*
17 + * the standalone compose webview) since it touches neither GoingsOn
18 + * state nor the DOM.
19 + */
20 + (function() {
21 + 'use strict';
22 +
23 + // ============ Attachment caps ============
24 + //
25 + // 25 MB matches the practical SMTP cap (Gmail, Fastmail, Outlook).
26 + // Warn from 20 MB so the user has runway to drop a file before the
27 + // hard block kicks in. Both surfaces previously hard-coded these;
28 + // changing the numbers here changes both at once.
29 + const ATTACHMENT_HARD_CAP_BYTES = 25 * 1024 * 1024;
30 + const ATTACHMENT_WARN_CAP_BYTES = 20 * 1024 * 1024;
31 +
32 + function totalAttachmentBytes(files) {
33 + return (files || []).reduce((sum, f) => sum + (f && f.size ? f.size : 0), 0);
34 + }
35 +
36 + function exceedsAttachmentCap(files) {
37 + const total = totalAttachmentBytes(files);
38 + return {
39 + totalBytes: total,
40 + warn: total > ATTACHMENT_WARN_CAP_BYTES,
41 + over: total > ATTACHMENT_HARD_CAP_BYTES,
42 + };
43 + }
44 +
45 + function formatBytes(n) {
46 + if (n == null || n < 1024) return (n || 0) + ' B';
47 + if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
48 + if (n < 1024 * 1024 * 1024) return (n / (1024 * 1024)).toFixed(1) + ' MB';
49 + return (n / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
50 + }
51 +
52 + // ============ Payload contract ============
53 + //
54 + // Build a SendEmailInput from raw form values + reply context +
55 + // attachments. The wire shape must match `commands/email.rs::SendEmailInput`
56 + // (camelCase via serde). Callers should:
57 + //
58 + // const input = composeForm.collectInput({
59 + // accountId, toAddress, ccAddress, bccAddress, subject, body,
60 + // attachedFiles, replyContext,
61 + // });
62 + // const result = composeForm.validate(input);
63 + // if (!result.ok) { showError(result.field, result.message); return; }
64 + // queueSend({ input });
65 + //
66 + // `replyContext` is optional and may contain { inReplyTo, references, threadId }.
67 + function collectInput(raw) {
68 + const r = raw || {};
69 + const reply = r.replyContext || {};
70 + const trim = (s) => (typeof s === 'string' ? s.trim() : '');
71 + const orNull = (s) => (s && s.length ? s : null);
72 +
73 + return {
74 + accountId: r.accountId || null,
75 + toAddress: trim(r.toAddress),
76 + ccAddress: orNull(trim(r.ccAddress)),
77 + bccAddress: orNull(trim(r.bccAddress)),
78 + subject: trim(r.subject),
79 + body: typeof r.body === 'string' ? r.body : '',
80 + projectId: r.projectId || null,
81 + inReplyTo: reply.inReplyTo || null,
82 + references: reply.references || null,
83 + threadId: reply.threadId || null,
84 + attachmentPaths: (r.attachedFiles || [])
85 + .map(f => f && f.path)
86 + .filter(Boolean),
87 + };
88 + }
89 +
90 + // ============ Validation ============
91 + //
92 + // Returns { ok: true } | { ok: false, field, message }. `field` is one
93 + // of 'accountId' | 'toAddress' | 'subject' | 'attachments' so each
94 + // surface can focus the offending input (or show an inline error).
95 + function validate(input) {
96 + if (!input || !input.accountId) {
97 + return { ok: false, field: 'accountId', message: 'Please select a From account' };
98 + }
99 + if (!input.toAddress) {
100 + return { ok: false, field: 'toAddress', message: 'Please enter a recipient' };
101 + }
102 + if (!input.subject) {
103 + return { ok: false, field: 'subject', message: 'Please enter a subject' };
104 + }
105 + return { ok: true };
106 + }
107 +
108 + // Combined check: validate + attachment cap. Use when the caller has
109 + // attached files in memory but hasn't folded them into `input` yet.
110 + function validateForSend(input, attachedFiles) {
111 + const base = validate(input);
112 + if (!base.ok) return base;
113 + const cap = exceedsAttachmentCap(attachedFiles);
114 + if (cap.over) {
115 + return {
116 + ok: false,
117 + field: 'attachments',
118 + message: `Attachments exceed ${formatBytes(ATTACHMENT_HARD_CAP_BYTES)} — remove some files or use a file-share link.`,
119 + };
120 + }
121 + return { ok: true };
122 + }
123 +
124 + // ============ Export ============
125 +
126 + const api = {
127 + ATTACHMENT_HARD_CAP_BYTES,
128 + ATTACHMENT_WARN_CAP_BYTES,
129 + totalAttachmentBytes,
130 + exceedsAttachmentCap,
131 + formatBytes,
132 + collectInput,
133 + validate,
134 + validateForSend,
135 + };
136 +
137 + // Compose.html is a standalone webview that doesn't bootstrap the
138 + // GoingsOn namespace, so we attach to window as well for that path.
139 + if (typeof window !== 'undefined') {
140 + window.GoingsOnComposeForm = api;
141 + if (window.GoingsOn) {
142 + window.GoingsOn.composeForm = api;
143 + }
144 + }
145 + })();
@@ -198,43 +198,30 @@
198 198 </div>
199 199 `,
200 200 onSubmit: async (data) => {
201 - const result = await GoingsOn.api.emails.send({
201 + // Stage 1 of compose unification — collectInput owns the
202 + // canonical SendEmailInput shape; validateForSend owns the
203 + // attachment cap. Both surfaces share the contract now.
204 + const input = GoingsOn.composeForm.collectInput({
202 205 accountId: data.account_id,
203 - to: data.to,
206 + toAddress: data.to,
207 + ccAddress: '',
208 + bccAddress: '',
204 209 subject: data.subject,
205 210 body: data.body || '',
206 - inReplyTo: pf.inReplyTo || null,
207 - references: pf.references || null,
208 - threadId: pf.threadId || null,
209 - attachmentPaths: modalAttachedFiles.map(f => f.path),
211 + attachedFiles: modalAttachedFiles,
212 + replyContext: {
213 + inReplyTo: pf.inReplyTo || null,
214 + references: pf.references || null,
215 + threadId: pf.threadId || null,
216 + },
210 217 });
211 - modalAttachedFiles = [];
212 - GoingsOn.ui.showToast('Email sent!', 'success');
213 - load();
214 -
215 - // Prompt to save newly created implicit contacts
216 - if (result?.newImplicitContacts?.length > 0) {
217 - GoingsOn.autocomplete.refresh();
218 - for (const c of result.newImplicitContacts) {
219 - const name = c.displayName || c.display_name || '';
220 - GoingsOn.ui.showToast(`Save ${name} as a contact?`, 'info', {
221 - duration: 8000,
222 - action: {
223 - label: 'Save',
224 - fn: async () => {
225 - try {
226 - await GoingsOn.api.contacts.promoteContact(c.id);
227 - GoingsOn.cache.invalidate('contacts');
228 - GoingsOn.autocomplete.refresh();
229 - GoingsOn.ui.showToast(`${name} saved as contact`, 'success');
230 - } catch (err) {
231 - GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to save contact'), 'error');
232 - }
233 - },
234 - },
235 - });
236 - }
218 + const result = GoingsOn.composeForm.validateForSend(input, modalAttachedFiles);
219 + if (!result.ok) {
220 + GoingsOn.ui.showToast(result.message, 'error', { duration: 8000 });
221 + return;
237 222 }
223 + modalAttachedFiles = [];
224 + queueSend({ input });
238 225 },
239 226 });
240 227
@@ -862,6 +849,13 @@
862 849 }
863 850 }
864 851
852 + // Phase 7 Tier 6 #3 stage 1: attachment caps + formatBytes now live on
853 + // `GoingsOn.composeForm`. These local aliases keep the surrounding call
854 + // sites readable.
855 + const ATTACHMENT_HARD_CAP = GoingsOn.composeForm.ATTACHMENT_HARD_CAP_BYTES;
856 + const ATTACHMENT_WARN_CAP = GoingsOn.composeForm.ATTACHMENT_WARN_CAP_BYTES;
857 + const _formatBytes = GoingsOn.composeForm.formatBytes;
858 +
865 859 async function pickModalAttachment() {
866 860 try {
867 861 const { open } = window.__TAURI__.dialog;
@@ -872,7 +866,9 @@
872 866 const filePath = typeof p === 'string' ? p : p.path;
873 867 if (!filePath || modalAttachedFiles.some(f => f.path === filePath)) continue;
874 868 const name = filePath.split(/[/\\]/).pop() || 'file';
875 - modalAttachedFiles.push({ path: filePath, name });
869 + let size = 0;
870 + try { size = await GoingsOn.api.attachments.fileSize(filePath); } catch (_) { /* leave 0 */ }
871 + modalAttachedFiles.push({ path: filePath, name, size });
876 872 }
877 873 renderModalAttachments();
878 874 } catch (err) {
@@ -883,9 +879,25 @@
883 879 function renderModalAttachments() {
884 880 const el = document.getElementById('modal-attachments');
885 881 if (!el) return;
886 - el.innerHTML = modalAttachedFiles.map((f, i) =>
887 - `<span style="margin-right: 0.5rem;">${esc(f.name)} <button type="button" style="background:none;border:none;color:var(--accent-red);cursor:pointer;" onclick="GoingsOn.emails._removeModalAttachment(${i})">&times;</button></span>`
882 + const tags = modalAttachedFiles.map((f, i) =>
883 + `<span class="email-attachment-tag">${esc(f.name)} <span class="email-attachment-size">${_formatBytes(f.size || 0)}</span> <button type="button" class="email-attachment-remove-btn" onclick="GoingsOn.emails._removeModalAttachment(${i})">&times;</button></span>`
888 884 ).join('');
885 + const total = modalAttachedFiles.reduce((s, f) => s + (f.size || 0), 0);
886 + let totalLine = '';
887 + if (modalAttachedFiles.length > 0) {
888 + const overCap = total > ATTACHMENT_HARD_CAP;
889 + const overWarn = total > ATTACHMENT_WARN_CAP;
890 + const cls = overCap ? 'compose-attachment-total over-cap'
891 + : overWarn ? 'compose-attachment-total over-warn'
892 + : 'compose-attachment-total';
893 + const warn = overCap
894 + ? '<span class="compose-attachment-warn">— most servers will reject this.</span>'
895 + : overWarn
896 + ? '<span class="compose-attachment-warn">— may be rejected by your mail server.</span>'
897 + : '';
898 + totalLine = `<div class="${cls}"><span>Total: ${_formatBytes(total)} / ${_formatBytes(ATTACHMENT_HARD_CAP)}</span>${warn}</div>`;
899 + }
900 + el.innerHTML = tags + totalLine;
889 901 }
890 902
891 903 function removeModalAttachment(index) {
@@ -1200,6 +1212,106 @@
1200 1212 emailSelection.clear();
1201 1213 }
1202 1214
1215 + // ============ Send-with-Delay (undo-send) ============
1216 +
1217 + /**
1218 + * Build a compose prefill object from a queued SendInput.
1219 + * Note: attachmentPaths are NOT preserved through compose re-open today
1220 + * (the compose UI uses a fresh file picker). When undo restores a message
1221 + * that had attachments, surface a notice so the user re-attaches.
1222 + */
1223 + function _sendInputToComposeParams(input) {
1224 + return {
1225 + to: input.to || input.toAddress || '',
1226 + subject: input.subject || '',
1227 + body: input.body || '',
1228 + accountId: input.accountId || null,
1229 + inReplyTo: input.inReplyTo || null,
1230 + references: input.references || null,
1231 + threadId: input.threadId || null,
1232 + };
1233 + }
1234 +
1235 + /**
1236 + * Queue an email send with an undo window. Returns immediately. The actual
1237 + * `api.emails.send` call fires when the undo window expires; if the user
1238 + * clicks Undo, the compose surface re-opens with the message restored.
1239 + *
1240 + * @param {Object} cfg
1241 + * @param {Object} cfg.input - SendInput (accountId / to / subject / body / inReplyTo / references / threadId / attachmentPaths)
1242 + * @param {number} [cfg.delaySeconds=5] - Undo window in seconds.
1243 + */
1244 + function queueSend({ input, delaySeconds = 5 }) {
1245 + const hadAttachments = Array.isArray(input.attachmentPaths) && input.attachmentPaths.length > 0;
1246 + const timeout = Math.max(1000, delaySeconds * 1000);
1247 + const message = input.inReplyTo ? 'Sending reply…' : 'Sending email…';
1248 +
1249 + GoingsOn.ui.showUndoToast(message, {
1250 + timeout,
1251 + onConfirm: async () => {
1252 + try {
1253 + const result = await GoingsOn.api.emails.send(input);
1254 + GoingsOn.ui.showToast('Email sent', 'success');
1255 + load();
1256 +
1257 + if (result?.newImplicitContacts?.length > 0) {
1258 + GoingsOn.autocomplete?.refresh?.();
1259 + for (const c of result.newImplicitContacts) {
1260 + const name = c.displayName || c.display_name || '';
1261 + GoingsOn.ui.showToast(`Save ${name} as a contact?`, 'info', {
1262 + duration: 8000,
1263 + action: {
1264 + label: 'Save',
1265 + fn: async () => {
1266 + try {
1267 + await GoingsOn.api.contacts.promoteContact(c.id);
1268 + GoingsOn.cache.invalidate('contacts');
1269 + GoingsOn.autocomplete?.refresh?.();
1270 + GoingsOn.ui.showToast(`${name} saved as contact`, 'success');
1271 + } catch (err) {
1272 + GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to save contact'), 'error');
1273 + }
1274 + },
1275 + },
1276 + });
1277 + }
1278 + }
1279 + } catch (err) {
1280 + // User is no longer in compose; offer to re-open with the message.
1281 + GoingsOn.ui.showToast(
1282 + 'Send failed: ' + GoingsOn.utils.getErrorMessage(err),
1283 + 'error',
1284 + {
1285 + duration: 12000,
1286 + action: {
1287 + label: 'Edit & retry',
1288 + fn: () => {
1289 + const prefill = _sendInputToComposeParams(input);
1290 + if (GoingsOn.touch?.isTouchDevice) {
1291 + openComposeModal(prefill);
1292 + } else {
1293 + GoingsOn.api.window.openCompose(prefill).catch(() => openComposeModal(prefill));
1294 + }
1295 + },
1296 + },
1297 + }
1298 + );
1299 + }
1300 + },
1301 + onUndo: () => {
1302 + const prefill = _sendInputToComposeParams(input);
1303 + if (GoingsOn.touch?.isTouchDevice) {
1304 + openComposeModal(prefill);
1305 + } else {
1306 + GoingsOn.api.window.openCompose(prefill).catch(() => openComposeModal(prefill));
1307 + }
1308 + if (hadAttachments) {
1309 + GoingsOn.ui.showToast('Re-attach your files — attachments cleared on undo.', 'info', { duration: 8000 });
1310 + }
1311 + },
1312 + });
1313 + }
1314 +
1203 1315 // ============ Populate GoingsOn.emails Namespace ============
1204 1316
1205 1317 GoingsOn.emails = {
@@ -1234,6 +1346,7 @@
1234 1346 openDrafts: openDraftsModal,
1235 1347 openDraft,
1236 1348 sendDraft,
1349 + queueSend,
1237 1350 // Pagination
1238 1351 goToPage,
1239 1352 toggleSelection,
@@ -22,6 +22,23 @@ use super::{ApiError, OptionNotFound, ResultApiError};
22 22 /// Maximum file size for attachments (50 MB).
23 23 const MAX_FILE_SIZE: u64 = 50 * 1024 * 1024;
24 24
25 + /// Get the size (in bytes) of a file on disk. Used by the compose UI to surface
26 + /// a per-message attachment total and warn before SMTP rejects an oversized send.
27 + #[tauri::command]
28 + #[instrument(skip_all)]
29 + pub async fn get_file_size(file_path: String) -> Result<u64, ApiError> {
30 + let p = Path::new(&file_path);
31 + if file_path.contains("..") {
32 + return Err(ApiError::validation("filePath", "Path traversal not allowed"));
33 + }
34 + if !p.is_file() {
35 + return Err(ApiError::validation("filePath", "File does not exist or is not a regular file"));
36 + }
37 + let metadata = std::fs::metadata(p)
38 + .map_api_err("Failed to read file metadata", ApiError::internal)?;
39 + Ok(metadata.len())
40 + }
41 +
25 42 // ============ Types ============
26 43
27 44 /// Attachment response with pre-computed display fields.
@@ -108,6 +108,7 @@ pub fn build_mobile_app() -> tauri::Builder<tauri::Wry> {
108 108 commands::convert_email_attachments,
109 109 commands::open_email_blob,
110 110 commands::save_email_blob,
111 + commands::get_file_size,
111 112 commands::list_projects,
112 113 commands::get_project,
113 114 commands::create_project,
@@ -392,6 +392,7 @@ fn main() {
392 392 commands::convert_email_attachments,
393 393 commands::open_email_blob,
394 394 commands::save_email_blob,
395 + commands::get_file_size,
395 396 // Projects
396 397 commands::list_projects,
397 398 commands::get_project,