/** * GoingsOn — Shared compose-form contract (Phase 7 Tier 6 #3, stage 1). * * This module is the single source of truth for: * - attachment-cap thresholds (`ATTACHMENT_WARN_CAP_BYTES`, `ATTACHMENT_HARD_CAP_BYTES`) * - the SendEmailInput payload shape sent to `send_email` (camelCase, * matching `commands/email.rs::SendEmailInput`) * - the validation rules every compose surface must enforce * * Both compose surfaces — the desktop `compose.html` window and the in-app * `openComposeModal` — used to maintain their own copies. The drift that * produced is documented in `docs/ux-audit/compose-migration.md`; this * module is stage 1 of that migration. Later stages will collapse the * markup and behaviors; for now, just the data contract. * * The module is safe to load in either context (main app webview *or* * the standalone compose webview) since it touches neither GoingsOn * state nor the DOM. */ (function() { 'use strict'; // ============ Attachment caps ============ // // 25 MB matches the practical SMTP cap (Gmail, Fastmail, Outlook). // Warn from 20 MB so the user has runway to drop a file before the // hard block kicks in. Both surfaces previously hard-coded these; // changing the numbers here changes both at once. const ATTACHMENT_HARD_CAP_BYTES = 25 * 1024 * 1024; const ATTACHMENT_WARN_CAP_BYTES = 20 * 1024 * 1024; function totalAttachmentBytes(files) { return (files || []).reduce((sum, f) => sum + (f && f.size ? f.size : 0), 0); } function exceedsAttachmentCap(files) { const total = totalAttachmentBytes(files); return { totalBytes: total, warn: total > ATTACHMENT_WARN_CAP_BYTES, over: total > ATTACHMENT_HARD_CAP_BYTES, }; } function formatBytes(n) { if (n == null || n < 1024) return (n || 0) + ' B'; if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'; if (n < 1024 * 1024 * 1024) return (n / (1024 * 1024)).toFixed(1) + ' MB'; return (n / (1024 * 1024 * 1024)).toFixed(2) + ' GB'; } // ============ Payload contract ============ // // Build a SendEmailInput from raw form values + reply context + // attachments. The wire shape must match `commands/email.rs::SendEmailInput` // (camelCase via serde). Callers should: // // const input = composeForm.collectInput({ // accountId, toAddress, ccAddress, bccAddress, subject, body, // attachedFiles, replyContext, // }); // const result = composeForm.validate(input); // if (!result.ok) { showError(result.field, result.message); return; } // queueSend({ input }); // // `replyContext` is optional and may contain { inReplyTo, references, threadId }. function collectInput(raw) { const r = raw || {}; const reply = r.replyContext || {}; const trim = (s) => (typeof s === 'string' ? s.trim() : ''); const orNull = (s) => (s && s.length ? s : null); return { accountId: r.accountId || null, toAddress: trim(r.toAddress), ccAddress: orNull(trim(r.ccAddress)), bccAddress: orNull(trim(r.bccAddress)), subject: trim(r.subject), body: typeof r.body === 'string' ? r.body : '', projectId: r.projectId || null, inReplyTo: reply.inReplyTo || null, references: reply.references || null, threadId: reply.threadId || null, attachmentPaths: (r.attachedFiles || []) .map(f => f && f.path) .filter(Boolean), }; } // ============ Validation ============ // // Returns { ok: true } | { ok: false, field, message }. `field` is one // of 'accountId' | 'toAddress' | 'subject' | 'attachments' so each // surface can focus the offending input (or show an inline error). function validate(input) { if (!input || !input.accountId) { return { ok: false, field: 'accountId', message: 'Please select a From account' }; } if (!input.toAddress) { return { ok: false, field: 'toAddress', message: 'Please enter a recipient' }; } if (!input.subject) { return { ok: false, field: 'subject', message: 'Please enter a subject' }; } return { ok: true }; } // Combined check: validate + attachment cap. Use when the caller has // attached files in memory but hasn't folded them into `input` yet. function validateForSend(input, attachedFiles) { const base = validate(input); if (!base.ok) return base; const cap = exceedsAttachmentCap(attachedFiles); if (cap.over) { return { ok: false, field: 'attachments', message: `Attachments exceed ${formatBytes(ATTACHMENT_HARD_CAP_BYTES)} — remove some files or use a file-share link.`, }; } return { ok: true }; } // ============ Shared HTML template (stage 3) ============ // // `buildFieldsHtml(opts)` returns the form-fields HTML used by both the // desktop `compose.html` window and the in-app `openComposeModal`. The // canonical IDs (`from-account`, `to-address`, `cc-row`, `cc-address`, // `bcc-row`, `bcc-address`, `subject`, `body`, `attachments-bar`, // `toggle-cc`) match compose.html's pre-existing inline JS so it // keeps binding by `getElementById` unchanged. The modal opts into the // same IDs to share behaviors. // // Each surface owns its chrome: // - compose.html: outer