| 196 |
196 |
|
if (!emailScroller) {
|
| 197 |
197 |
|
emailScroller = new GoingsOn.VirtualScroller({
|
| 198 |
198 |
|
container: container,
|
| 199 |
|
- |
renderItem: renderEmailItem,
|
|
199 |
+ |
renderItem: (t, i) => GoingsOn.emailsRender.renderEmailItem(t, i),
|
| 200 |
200 |
|
getItems: () => GoingsOn.state.emailThreads,
|
| 201 |
201 |
|
rowHeight: { estimated: 90, measure: true },
|
| 202 |
202 |
|
overscan: 5,
|
| 223 |
223 |
|
* @param {number} index - Item index
|
| 224 |
224 |
|
* @returns {string} HTML string
|
| 225 |
225 |
|
*/
|
| 226 |
|
- |
function renderEmailItem(thread, index) {
|
| 227 |
|
- |
const e = thread.mostRecentEmail;
|
| 228 |
|
- |
// Use pre-computed field from backend (avoids JS date calculation)
|
| 229 |
|
- |
const isSnoozed = e.isSnoozed;
|
| 230 |
|
- |
const isSelected = emailSelection.isSelected(e.id);
|
| 231 |
|
- |
const threadBadge = thread.threadCount > 1
|
| 232 |
|
- |
? `<span class="thread-badge" title="${thread.threadCount} messages in thread">${thread.threadCount}</span>`
|
| 233 |
|
- |
: '';
|
| 234 |
|
- |
const labelBadges = (e.labels || []).map(l =>
|
| 235 |
|
- |
`<span class="badge badge--xs badge--filled" data-color="blue">${esc(l)}</span>`
|
| 236 |
|
- |
).join('');
|
| 237 |
|
- |
|
| 238 |
|
- |
return `
|
| 239 |
|
- |
<div class="email-item email-item-with-checkbox ${thread.hasUnread ? 'unread' : ''} ${isSnoozed ? 'email-snoozed' : ''}"
|
| 240 |
|
- |
data-id="${escAttr(e.id)}"
|
| 241 |
|
- |
oncontextmenu="GoingsOn.contextMenus.showEmail(event, '${escArg(e.id)}')"
|
| 242 |
|
- |
data-email-archived="${e.isArchived}" data-email-read="${e.isRead}"
|
| 243 |
|
- |
data-email-snoozed="${isSnoozed}"
|
| 244 |
|
- |
tabindex="0" role="listitem" aria-label="Email from ${esc(e.from)}">
|
| 245 |
|
- |
<div class="email-checkbox-cell" onclick="event.stopPropagation();">
|
| 246 |
|
- |
<input type="checkbox" class="bulk-checkbox" data-id="${escAttr(e.id)}"
|
| 247 |
|
- |
${isSelected ? 'checked' : ''}
|
| 248 |
|
- |
onchange="GoingsOn.emails.toggleSelection('${escArg(e.id)}', this, event)"
|
| 249 |
|
- |
aria-label="Select email">
|
| 250 |
|
- |
</div>
|
| 251 |
|
- |
<div class="email-content" onclick="GoingsOn.emails.open('${escArg(e.id)}')" role="button">
|
| 252 |
|
- |
<div class="email-header">
|
| 253 |
|
- |
<span class="email-from">${esc(e.from)}</span>
|
| 254 |
|
- |
${threadBadge}
|
| 255 |
|
- |
${isSnoozed ? `<span class="snooze-badge" title="Snoozed until ${escAttr(e.snoozedUntilFormatted || '')}" aria-label="Snoozed until ${escAttr(e.snoozedUntilFormatted || '')}">Snoozed</span>` : ''}
|
| 256 |
|
- |
<span class="email-date">${e.receivedFormatted}</span>
|
| 257 |
|
- |
</div>
|
| 258 |
|
- |
<div class="email-subject">${esc(e.subject)}${labelBadges ? ' ' + labelBadges : ''}</div>
|
| 259 |
|
- |
<div class="email-preview">${esc(e.bodyPreview)}...</div>
|
| 260 |
|
- |
</div>
|
| 261 |
|
- |
<button class="btn-icon kebab-btn" onclick="event.stopPropagation(); GoingsOn.contextMenus.showEmail(event, '${escArg(e.id)}')" title="Actions" aria-label="Email actions">⋮</button>
|
| 262 |
|
- |
</div>
|
| 263 |
|
- |
`;
|
| 264 |
|
- |
}
|
| 265 |
|
- |
|
| 266 |
|
- |
/**
|
| 267 |
|
- |
* Open the email compose interface (new window on desktop, modal on mobile).
|
| 268 |
|
- |
*/
|
| 269 |
|
- |
async function openCompose() {
|
| 270 |
|
- |
// On mobile, compose in-app since Tauri doesn't support multiple windows
|
| 271 |
|
- |
if (GoingsOn.touch?.isTouchDevice) {
|
| 272 |
|
- |
openComposeModal();
|
| 273 |
|
- |
return;
|
| 274 |
|
- |
}
|
| 275 |
|
- |
|
| 276 |
|
- |
// Open compose in a new window using Tauri invoke
|
| 277 |
|
- |
try {
|
| 278 |
|
- |
await GoingsOn.api.window.openCompose();
|
| 279 |
|
- |
} catch (err) {
|
| 280 |
|
- |
console.error('Failed to open compose window:', err);
|
| 281 |
|
- |
// Fall back to in-app modal
|
| 282 |
|
- |
openComposeModal();
|
| 283 |
|
- |
}
|
| 284 |
|
- |
}
|
| 285 |
|
- |
|
| 286 |
|
- |
// Modal compose controller — populated after openComposeModal mounts and
|
| 287 |
|
- |
// composeForm.bindBehaviors wires up the shared behaviors.
|
| 288 |
|
- |
let modalComposeCtrl = null;
|
| 289 |
|
- |
|
| 290 |
|
- |
function openComposeModal(prefill) {
|
| 291 |
|
- |
modalComposeCtrl = null;
|
| 292 |
|
- |
const accounts = GoingsOn.getEmailAccountsCache();
|
| 293 |
|
- |
const pf = prefill || {};
|
| 294 |
|
- |
|
| 295 |
|
- |
// Stage 3/4 of compose unification — markup and behaviors come from
|
| 296 |
|
- |
// composeForm. The chrome (modal wrapper, footer action row, Attach
|
| 297 |
|
- |
// button) stays modal-specific. CC/BCC sit after To, matching compose.html.
|
| 298 |
|
- |
|
| 299 |
|
- |
// Pre-append signature for the default/selected account; bindBehaviors
|
| 300 |
|
- |
// tracks it so the first From-change knows what trailing block to strip.
|
| 301 |
|
- |
const selectedAccount = accounts.find(a => a.id === pf.accountId) || accounts[0];
|
| 302 |
|
- |
const selectedAccountId = selectedAccount?.id || null;
|
| 303 |
|
- |
const sig = selectedAccount?.emailSignature || '';
|
| 304 |
|
- |
const bodyWithSig = (pf.body || '') + (sig ? '\n\n-- \n' + sig : '');
|
| 305 |
|
- |
|
| 306 |
|
- |
const ccPrefill = pf.cc || '';
|
| 307 |
|
- |
const bccPrefill = pf.bcc || '';
|
| 308 |
|
- |
const ccBccExpanded = !!(ccPrefill || bccPrefill);
|
| 309 |
|
- |
|
| 310 |
|
- |
const fieldsHtml = GoingsOn.composeForm.buildFieldsHtml({
|
| 311 |
|
- |
accounts,
|
| 312 |
|
- |
selectedAccountId,
|
| 313 |
|
- |
prefill: {
|
| 314 |
|
- |
to: pf.to || '',
|
| 315 |
|
- |
cc: ccPrefill,
|
| 316 |
|
- |
bcc: bccPrefill,
|
| 317 |
|
- |
subject: pf.subject || '',
|
| 318 |
|
- |
body: bodyWithSig,
|
| 319 |
|
- |
},
|
| 320 |
|
- |
showCcBcc: ccBccExpanded,
|
| 321 |
|
- |
showAttachments: true,
|
| 322 |
|
- |
bodyRows: 8,
|
| 323 |
|
- |
});
|
| 324 |
|
- |
|
| 325 |
|
- |
const replyContext = {
|
| 326 |
|
- |
inReplyTo: pf.inReplyTo || null,
|
| 327 |
|
- |
references: pf.references || null,
|
| 328 |
|
- |
threadId: pf.threadId || null,
|
| 329 |
|
- |
};
|
| 330 |
|
- |
|
| 331 |
|
- |
const content = `
|
| 332 |
|
- |
<form id="compose-modal-form" onsubmit="event.preventDefault(); return false;">
|
| 333 |
|
- |
<span id="reply-indicator" class="reply-indicator"></span>
|
| 334 |
|
- |
${fieldsHtml}
|
| 335 |
|
- |
<div class="form-actions" style="margin-top: 0.75rem;">
|
| 336 |
|
- |
<button type="button" class="btn btn-secondary" id="compose-modal-attach">Attach File</button>
|
| 337 |
|
- |
<button type="button" class="btn btn-secondary" id="compose-modal-save-draft">Save Draft</button>
|
| 338 |
|
- |
<div class="flex-1"></div>
|
| 339 |
|
- |
<button type="button" class="btn btn-secondary" id="compose-modal-cancel">Cancel</button>
|
| 340 |
|
- |
<button type="submit" class="btn btn-primary" id="compose-modal-send">Send</button>
|
| 341 |
|
- |
</div>
|
| 342 |
|
- |
</form>
|
| 343 |
|
- |
`;
|
| 344 |
|
- |
|
| 345 |
|
- |
GoingsOn.modal.openModal('Compose Email', content);
|
| 346 |
|
- |
|
| 347 |
|
- |
setTimeout(() => {
|
| 348 |
|
- |
const form = document.getElementById('compose-modal-form');
|
| 349 |
|
- |
if (!form) return;
|
| 350 |
|
- |
|
| 351 |
|
- |
// Stage 4/5 — single bindBehaviors call owns autocomplete,
|
| 352 |
|
- |
// address highlight, CC/BCC toggle, signature swap, attachment
|
| 353 |
|
- |
// picker/render/remove, draft autosave, and reply indicator.
|
| 354 |
|
- |
modalComposeCtrl = GoingsOn.composeForm.bindBehaviors({
|
| 355 |
|
- |
accounts,
|
| 356 |
|
- |
initialSignature: sig,
|
| 357 |
|
- |
getContacts: GoingsOn.autocomplete.getContacts,
|
| 358 |
|
- |
onError: (msg) => GoingsOn.ui.showToast(msg, 'error', { duration: 8000 }),
|
| 359 |
|
- |
enableAutosave: true,
|
| 360 |
|
- |
initialDraftId: pf.draftId || null,
|
| 361 |
|
- |
saveDraft: (input) => GoingsOn.api.emails.saveDraft(input),
|
| 362 |
|
- |
getReplyContext: () => replyContext,
|
| 363 |
|
- |
onDraftStatus: (kind, message) => {
|
| 364 |
|
- |
if (kind === 'error') {
|
| 365 |
|
- |
GoingsOn.ui.showToast(message, 'error', { duration: 6000 });
|
| 366 |
|
- |
} else {
|
| 367 |
|
- |
GoingsOn.ui.showToast(message, 'success', { duration: 2500 });
|
| 368 |
|
- |
}
|
| 369 |
|
- |
},
|
| 370 |
|
- |
enableReplyIndicator: true,
|
| 371 |
|
- |
});
|
| 372 |
|
- |
|
| 373 |
|
- |
const attachBtn = document.getElementById('compose-modal-attach');
|
| 374 |
|
- |
const cancelBtn = document.getElementById('compose-modal-cancel');
|
| 375 |
|
- |
const sendBtn = document.getElementById('compose-modal-send');
|
| 376 |
|
- |
const saveDraftBtn = document.getElementById('compose-modal-save-draft');
|
| 377 |
|
- |
if (attachBtn) attachBtn.addEventListener('click', modalComposeCtrl.pickAttachment);
|
| 378 |
|
- |
if (saveDraftBtn) saveDraftBtn.addEventListener('click', () => modalComposeCtrl.saveDraftNow());
|
| 379 |
|
- |
if (cancelBtn) cancelBtn.addEventListener('click', () => GoingsOn.ui.closeModal());
|
| 380 |
|
- |
|
| 381 |
|
- |
const submit = async () => {
|
| 382 |
|
- |
modalComposeCtrl.setSending(true);
|
| 383 |
|
- |
const accountSelect = document.getElementById('from-account');
|
| 384 |
|
- |
const toEl = document.getElementById('to-address');
|
| 385 |
|
- |
const ccEl = document.getElementById('cc-address');
|
| 386 |
|
- |
const bccEl = document.getElementById('bcc-address');
|
| 387 |
|
- |
const subjectEl = document.getElementById('subject');
|
| 388 |
|
- |
const bodyEl = document.getElementById('body');
|
| 389 |
|
- |
const attachedFiles = modalComposeCtrl.getAttachedFiles();
|
| 390 |
|
- |
const input = GoingsOn.composeForm.collectInput({
|
| 391 |
|
- |
accountId: accountSelect ? accountSelect.value : null,
|
| 392 |
|
- |
toAddress: toEl ? toEl.value : '',
|
| 393 |
|
- |
ccAddress: ccEl ? ccEl.value : '',
|
| 394 |
|
- |
bccAddress: bccEl ? bccEl.value : '',
|
| 395 |
|
- |
subject: subjectEl ? subjectEl.value : '',
|
| 396 |
|
- |
body: bodyEl ? bodyEl.value : '',
|
| 397 |
|
- |
attachedFiles,
|
| 398 |
|
- |
replyContext,
|
| 399 |
|
- |
});
|
| 400 |
|
- |
const result = GoingsOn.composeForm.validateForSend(input, attachedFiles);
|
| 401 |
|
- |
if (!result.ok) {
|
| 402 |
|
- |
GoingsOn.ui.showToast(result.message, 'error', { duration: 8000 });
|
| 403 |
|
- |
modalComposeCtrl.setSending(false);
|
| 404 |
|
- |
return;
|
| 405 |
|
- |
}
|
| 406 |
|
- |
GoingsOn.ui.closeModal();
|
| 407 |
|
- |
queueSend({ input });
|
| 408 |
|
- |
};
|
| 409 |
|
- |
form.addEventListener('submit', (e) => { e.preventDefault(); submit(); });
|
| 410 |
|
- |
if (sendBtn) sendBtn.addEventListener('click', (e) => { e.preventDefault(); submit(); });
|
| 411 |
|
- |
}, 50);
|
| 412 |
|
- |
}
|
| 413 |
|
- |
|
| 414 |
226 |
|
async function markAllRead() {
|
| 415 |
227 |
|
await GoingsOn.ui.apiCall(GoingsOn.api.emails.markAllRead(), {
|
| 416 |
228 |
|
successMessage: 'All emails marked as read!',
|
| 857 |
669 |
|
* @param {string} emailId - Email to reply to
|
| 858 |
670 |
|
* @param {boolean} replyAll - If true, include all recipients
|
| 859 |
671 |
|
*/
|
| 860 |
|
- |
async function openReply(emailId, replyAll) {
|
| 861 |
|
- |
try {
|
| 862 |
|
- |
// Rust builds the reply prefill: recipients (reply-all excludes our
|
| 863 |
|
- |
// own accounts + de-dupes), Re: subject, and quoted body.
|
| 864 |
|
- |
const pf = await GoingsOn.api.emails.buildReplyPrefill(emailId, !!replyAll);
|
| 865 |
|
- |
if (!pf) return;
|
| 866 |
|
- |
|
| 867 |
|
- |
const prefill = {
|
| 868 |
|
- |
to: pf.to,
|
| 869 |
|
- |
subject: pf.subject,
|
| 870 |
|
- |
body: pf.body,
|
| 871 |
|
- |
inReplyTo: pf.inReplyTo || null,
|
| 872 |
|
- |
references: pf.references || null,
|
| 873 |
|
- |
threadId: pf.threadId || null,
|
| 874 |
|
- |
accountId: pf.accountId || '',
|
| 875 |
|
- |
};
|
| 876 |
|
- |
|
| 877 |
|
- |
if (GoingsOn.touch?.isTouchDevice) {
|
| 878 |
|
- |
openComposeModal(prefill);
|
| 879 |
|
- |
} else {
|
| 880 |
|
- |
await GoingsOn.api.window.openCompose(prefill);
|
| 881 |
|
- |
}
|
| 882 |
|
- |
} catch (err) {
|
| 883 |
|
- |
GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to open reply'), 'error');
|
| 884 |
|
- |
}
|
| 885 |
|
- |
}
|
| 886 |
|
- |
|
| 887 |
|
- |
/**
|
| 888 |
|
- |
* Open compose window to forward an email.
|
| 889 |
|
- |
* @param {string} emailId - Email to forward
|
| 890 |
|
- |
*/
|
| 891 |
|
- |
async function openForward(emailId) {
|
| 892 |
|
- |
try {
|
| 893 |
|
- |
// Rust builds the Fwd: subject and forwarded-message body block.
|
| 894 |
|
- |
const pf = await GoingsOn.api.emails.buildForwardPrefill(emailId);
|
| 895 |
|
- |
if (!pf) return;
|
| 896 |
|
- |
|
| 897 |
|
- |
const prefill = { to: '', subject: pf.subject, body: pf.body, accountId: pf.accountId || '' };
|
| 898 |
|
- |
|
| 899 |
|
- |
if (GoingsOn.touch?.isTouchDevice) {
|
| 900 |
|
- |
openComposeModal(prefill);
|
| 901 |
|
- |
} else {
|
| 902 |
|
- |
await GoingsOn.api.window.openCompose(prefill);
|
| 903 |
|
- |
}
|
| 904 |
|
- |
} catch (err) {
|
| 905 |
|
- |
GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to forward email'), 'error');
|
| 906 |
|
- |
}
|
| 907 |
|
- |
}
|
| 908 |
|
- |
|
| 909 |
672 |
|
/**
|
| 910 |
673 |
|
* Open an email attachment blob with the system default app.
|
| 911 |
674 |
|
* @param {string} blobHash - SHA-256 hash of the blob
|
| 962 |
725 |
|
// renderModalAttachments / removeModalAttachment helpers and the
|
| 963 |
726 |
|
// modalAttachedFiles array are gone; access via modalComposeCtrl instead.
|
| 964 |
727 |
|
|
| 965 |
|
- |
// ============ Drafts ============
|
| 966 |
|
- |
|
| 967 |
|
- |
/**
|
| 968 |
|
- |
* Load and display drafts in a modal.
|
| 969 |
|
- |
*/
|
| 970 |
|
- |
async function openDraftsModal() {
|
| 971 |
|
- |
try {
|
| 972 |
|
- |
const drafts = await GoingsOn.api.emails.listDrafts();
|
| 973 |
|
- |
|
| 974 |
|
- |
if (drafts.length === 0) {
|
| 975 |
|
- |
GoingsOn.ui.openModal('Drafts', '<div class="empty-state empty-state--compact"><p class="empty-state-text">No drafts</p></div>');
|
| 976 |
|
- |
return;
|
| 977 |
|
- |
}
|
| 978 |
|
- |
|
| 979 |
|
- |
const listHtml = drafts.map(d => {
|
| 980 |
|
- |
const to = d.to || '(no recipient)';
|
| 981 |
|
- |
const subject = d.subject || '(no subject)';
|
| 982 |
|
- |
return `
|
| 983 |
|
- |
<div class="email-item email-draft-item"
|
| 984 |
|
- |
onclick="GoingsOn.emails.openDraft('${escArg(d.id)}')">
|
| 985 |
|
- |
<div class="email-draft-subject">${esc(subject)}</div>
|
| 986 |
|
- |
<div class="email-draft-meta">To: ${esc(to)} · ${d.receivedFormatted}</div>
|
| 987 |
|
- |
</div>
|
| 988 |
|
- |
`;
|
| 989 |
|
- |
}).join('');
|
| 990 |
|
- |
|
| 991 |
|
- |
GoingsOn.ui.openModal('Drafts', `<div>${listHtml}</div>`);
|
| 992 |
|
- |
} catch (err) {
|
| 993 |
|
- |
GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load drafts'), 'error');
|
| 994 |
|
- |
}
|
| 995 |
|
- |
}
|
| 996 |
|
- |
|
| 997 |
|
- |
/**
|
| 998 |
|
- |
* Open a draft for editing in the compose window.
|
| 999 |
|
- |
* @param {string} id - Draft email ID
|
| 1000 |
|
- |
*/
|
| 1001 |
|
- |
async function openDraft(id) {
|
| 1002 |
|
- |
GoingsOn.ui.closeModal();
|
| 1003 |
|
- |
if (GoingsOn.touch?.isTouchDevice) {
|
| 1004 |
|
- |
// Mobile: open compose modal with draft data
|
| 1005 |
|
- |
try {
|
| 1006 |
|
- |
const draft = await GoingsOn.api.emails.get(id);
|
| 1007 |
|
- |
if (!draft) return;
|
| 1008 |
|
- |
openComposeModal({
|
| 1009 |
|
- |
to: draft.to || '',
|
| 1010 |
|
- |
cc: draft.ccAddress || '',
|
| 1011 |
|
- |
bcc: draft.bccAddress || '',
|
| 1012 |
|
- |
subject: draft.subject || '',
|
| 1013 |
|
- |
body: draft.body || '',
|
| 1014 |
|
- |
accountId: draft.draftAccountId || '',
|
| 1015 |
|
- |
inReplyTo: draft.inReplyTo || null,
|
| 1016 |
|
- |
threadId: draft.threadId || null,
|
| 1017 |
|
- |
draftId: draft.id,
|
| 1018 |
|
- |
});
|
| 1019 |
|
- |
} catch (err) {
|
| 1020 |
|
- |
GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to open draft'), 'error');
|
| 1021 |
|
- |
}
|
| 1022 |
|
- |
} else {
|
| 1023 |
|
- |
try {
|
| 1024 |
|
- |
await GoingsOn.api.window.openCompose({ draftId: id });
|
| 1025 |
|
- |
} catch (err) {
|
| 1026 |
|
- |
GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to open draft'), 'error');
|
| 1027 |
|
- |
}
|
| 1028 |
|
- |
}
|
| 1029 |
|
- |
}
|
| 1030 |
|
- |
|
| 1031 |
|
- |
/**
|
| 1032 |
|
- |
* Send a draft directly.
|
| 1033 |
|
- |
* @param {string} id - Draft email ID
|
| 1034 |
|
- |
*/
|
| 1035 |
|
- |
async function sendDraft(id) {
|
| 1036 |
|
- |
try {
|
| 1037 |
|
- |
await GoingsOn.api.emails.sendDraft(id);
|
| 1038 |
|
- |
GoingsOn.ui.showToast('Draft sent!', 'success');
|
| 1039 |
|
- |
GoingsOn.ui.closeModal();
|
| 1040 |
|
- |
GoingsOn.cache.invalidate('emails');
|
| 1041 |
|
- |
load();
|
| 1042 |
|
- |
} catch (err) {
|
| 1043 |
|
- |
GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to send draft'), 'error');
|
| 1044 |
|
- |
}
|
| 1045 |
|
- |
}
|
| 1046 |
|
- |
|
| 1047 |
728 |
|
// ============ Folder & Label Filters ============
|
| 1048 |
729 |
|
|
| 1049 |
730 |
|
let activeFolder = '';
|
| 1266 |
947 |
|
} else {
|
| 1267 |
948 |
|
emailScroller = new GoingsOn.VirtualScroller({
|
| 1268 |
949 |
|
container: container,
|
| 1269 |
|
- |
renderItem: renderEmailItem,
|
|
950 |
+ |
renderItem: (t, i) => GoingsOn.emailsRender.renderEmailItem(t, i),
|
| 1270 |
951 |
|
getItems: () => GoingsOn.state.emailThreads,
|
| 1271 |
952 |
|
rowHeight: { estimated: 90, measure: true },
|
| 1272 |
953 |
|
overscan: 5,
|
| 1343 |
1024 |
|
}
|
| 1344 |
1025 |
|
}
|
| 1345 |
1026 |
|
|
| 1346 |
|
- |
// ============ Send-with-Delay (undo-send) ============
|
| 1347 |
|
- |
|
| 1348 |
|
- |
/**
|
| 1349 |
|
- |
* Build a compose prefill object from a queued SendInput.
|
| 1350 |
|
- |
* Note: attachmentPaths are NOT preserved through compose re-open today
|
| 1351 |
|
- |
* (the compose UI uses a fresh file picker). When undo restores a message
|
| 1352 |
|
- |
* that had attachments, surface a notice so the user re-attaches.
|
| 1353 |
|
- |
*/
|
| 1354 |
|
- |
function _sendInputToComposeParams(input) {
|
| 1355 |
|
- |
return {
|
| 1356 |
|
- |
to: input.to || input.toAddress || '',
|
| 1357 |
|
- |
subject: input.subject || '',
|
| 1358 |
|
- |
body: input.body || '',
|
| 1359 |
|
- |
accountId: input.accountId || null,
|
| 1360 |
|
- |
inReplyTo: input.inReplyTo || null,
|
| 1361 |
|
- |
references: input.references || null,
|
| 1362 |
|
- |
threadId: input.threadId || null,
|
| 1363 |
|
- |
};
|
| 1364 |
|
- |
}
|
| 1365 |
|
- |
|
| 1366 |
|
- |
/**
|
| 1367 |
|
- |
* Queue an email send with an undo window. Returns immediately. The actual
|
| 1368 |
|
- |
* `api.emails.send` call fires when the undo window expires; if the user
|
| 1369 |
|
- |
* clicks Undo, the compose surface re-opens with the message restored.
|
| 1370 |
|
- |
*
|
| 1371 |
|
- |
* Charter rule: every irreversible operation gets an undo path (Phase 7
|
| 1372 |
|
- |
* Tier 1 #3 / Phase 3 #1).
|
| 1373 |
|
- |
*
|
| 1374 |
|
- |
* @param {Object} cfg
|
| 1375 |
|
- |
* @param {Object} cfg.input - SendInput (accountId / to / subject / body / inReplyTo / references / threadId / attachmentPaths)
|
| 1376 |
|
- |
* @param {number} [cfg.delaySeconds=5] - Undo window in seconds.
|
| 1377 |
|
- |
*/
|
| 1378 |
|
- |
function queueSend({ input, delaySeconds = 5 }) {
|
| 1379 |
|
- |
const hadAttachments = Array.isArray(input.attachmentPaths) && input.attachmentPaths.length > 0;
|
| 1380 |
|
- |
const timeout = Math.max(1000, delaySeconds * 1000);
|
| 1381 |
|
- |
const message = input.inReplyTo ? 'Sending reply…' : 'Sending email…';
|
| 1382 |
|
- |
|
| 1383 |
|
- |
GoingsOn.ui.showUndoToast(message, {
|
| 1384 |
|
- |
timeout,
|
| 1385 |
|
- |
onConfirm: async () => {
|
| 1386 |
|
- |
try {
|
| 1387 |
|
- |
const result = await GoingsOn.api.emails.send(input);
|
| 1388 |
|
- |
GoingsOn.ui.showToast('Email sent', 'success');
|
| 1389 |
|
- |
load();
|
| 1390 |
|
- |
|
| 1391 |
|
- |
if (result?.newImplicitContacts?.length > 0) {
|
| 1392 |
|
- |
GoingsOn.autocomplete?.refresh?.();
|
| 1393 |
|
- |
for (const c of result.newImplicitContacts) {
|
| 1394 |
|
- |
const name = c.displayName || c.display_name || '';
|
| 1395 |
|
- |
GoingsOn.ui.showToast(`Save ${name} as a contact?`, 'info', {
|
| 1396 |
|
- |
duration: 8000,
|
| 1397 |
|
- |
action: {
|
| 1398 |
|
- |
label: 'Save',
|
| 1399 |
|
- |
fn: async () => {
|
| 1400 |
|
- |
try {
|
| 1401 |
|
- |
await GoingsOn.api.contacts.promoteContact(c.id);
|
| 1402 |
|
- |
GoingsOn.cache.invalidate('contacts');
|
| 1403 |
|
- |
GoingsOn.autocomplete?.refresh?.();
|
| 1404 |
|
- |
GoingsOn.ui.showToast(`${name} saved as contact`, 'success');
|
| 1405 |
|
- |
} catch (err) {
|
| 1406 |
|
- |
GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to save contact'), 'error');
|
| 1407 |
|
- |
}
|
| 1408 |
|
- |
},
|
| 1409 |
|
- |
},
|
| 1410 |
|
- |
});
|
| 1411 |
|
- |
}
|
| 1412 |
|
- |
}
|
| 1413 |
|
- |
} catch (err) {
|
| 1414 |
|
- |
// User is no longer in compose; offer to re-open with the message.
|
| 1415 |
|
- |
GoingsOn.ui.showToast(
|
| 1416 |
|
- |
'Send failed: ' + GoingsOn.utils.getErrorMessage(err),
|
| 1417 |
|
- |
'error',
|
| 1418 |
|
- |
{
|
| 1419 |
|
- |
duration: 12000,
|
| 1420 |
|
- |
action: {
|
| 1421 |
|
- |
label: 'Edit & retry',
|
| 1422 |
|
- |
fn: () => {
|
| 1423 |
|
- |
const prefill = _sendInputToComposeParams(input);
|
| 1424 |
|
- |
if (GoingsOn.touch?.isTouchDevice) {
|
| 1425 |
|
- |
openComposeModal(prefill);
|
| 1426 |
|
- |
} else {
|
| 1427 |
|
- |
GoingsOn.api.window.openCompose(prefill).catch(() => openComposeModal(prefill));
|
| 1428 |
|
- |
}
|
| 1429 |
|
- |
},
|
| 1430 |
|
- |
},
|
| 1431 |
|
- |
}
|
| 1432 |
|
- |
);
|
| 1433 |
|
- |
}
|
| 1434 |
|
- |
},
|
| 1435 |
|
- |
onUndo: () => {
|
| 1436 |
|
- |
const prefill = _sendInputToComposeParams(input);
|
| 1437 |
|
- |
if (GoingsOn.touch?.isTouchDevice) {
|
| 1438 |
|
- |
openComposeModal(prefill);
|
| 1439 |
|
- |
} else {
|
| 1440 |
|
- |
GoingsOn.api.window.openCompose(prefill).catch(() => openComposeModal(prefill));
|
| 1441 |
|
- |
}
|
| 1442 |
|
- |
if (hadAttachments) {
|
| 1443 |
|
- |
GoingsOn.ui.showToast('Re-attach your files — attachments cleared on undo.', 'info', { duration: 8000 });
|
| 1444 |
|
- |
}
|
| 1445 |
|
- |
},
|
| 1446 |
|
- |
});
|
| 1447 |
|
- |
}
|
| 1448 |
|
- |
|
| 1449 |
1027 |
|
// ============ Populate GoingsOn.emails Namespace ============
|
| 1450 |
1028 |
|
|
| 1451 |
1029 |
|
/**
|
| 1468 |
1046 |
|
|
| 1469 |
1047 |
|
GoingsOn.emails = {
|
| 1470 |
1048 |
|
load,
|
| 1471 |
|
- |
openCompose,
|
| 1472 |
|
- |
openComposeModal,
|
| 1473 |
1049 |
|
markAllRead,
|
| 1474 |
1050 |
|
open,
|
| 1475 |
1051 |
|
loadFullBody,
|
| 1482 |
1058 |
|
createEventFromEmail,
|
| 1483 |
1059 |
|
createContactFromSender,
|
| 1484 |
1060 |
|
openInBrowser,
|
| 1485 |
|
- |
reply: (id) => openReply(id, false),
|
| 1486 |
|
- |
replyAll: (id) => openReply(id, true),
|
| 1487 |
|
- |
forward: openForward,
|
| 1488 |
1061 |
|
openBlob,
|
| 1489 |
1062 |
|
saveBlob,
|
| 1490 |
1063 |
|
search: searchEmails,
|
| 1494 |
1067 |
|
_saveLabels: saveLabels,
|
| 1495 |
1068 |
|
moveToFolder,
|
| 1496 |
1069 |
|
_doMoveToFolder: doMoveToFolder,
|
| 1497 |
|
- |
openDrafts: openDraftsModal,
|
| 1498 |
|
- |
openDraft,
|
| 1499 |
|
- |
sendDraft,
|
| 1500 |
|
- |
queueSend,
|
|
1070 |
+ |
// Compose/reply/forward/drafts/send-delay live in emails-compose.js;
|
|
1071 |
+ |
// delegate lazily so load order between the two files doesn't matter.
|
|
1072 |
+ |
openCompose: (...a) => GoingsOn.emailsCompose.openCompose(...a),
|
|
1073 |
+ |
openComposeModal: (...a) => GoingsOn.emailsCompose.openComposeModal(...a),
|
|
1074 |
+ |
reply: (id) => GoingsOn.emailsCompose.openReply(id, false),
|
|
1075 |
+ |
replyAll: (id) => GoingsOn.emailsCompose.openReply(id, true),
|
|
1076 |
+ |
forward: (...a) => GoingsOn.emailsCompose.openForward(...a),
|
|
1077 |
+ |
openDrafts: (...a) => GoingsOn.emailsCompose.openDraftsModal(...a),
|
|
1078 |
+ |
openDraft: (...a) => GoingsOn.emailsCompose.openDraft(...a),
|
|
1079 |
+ |
sendDraft: (...a) => GoingsOn.emailsCompose.sendDraft(...a),
|
|
1080 |
+ |
queueSend: (...a) => GoingsOn.emailsCompose.queueSend(...a),
|
| 1501 |
1081 |
|
// Pagination
|
| 1502 |
1082 |
|
goToPage,
|
| 1503 |
1083 |
|
toggleSelection,
|