max / goingson
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, '<')}</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">×</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})">×</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})">×</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, |