| 1 |
|
| 2 |
* GoingsOn - Emails Module |
| 3 |
* Email list, compose, threading, actions (archive/delete/mark). |
| 4 |
* Account management and OAuth live in email-accounts.js. |
| 5 |
|
| 6 |
|
| 7 |
|
| 8 |
|
| 9 |
(function() { |
| 10 |
'use strict'; |
| 11 |
const esc = GoingsOn.utils.escapeHtml; |
| 12 |
const escAttr = GoingsOn.utils.escapeAttr; |
| 13 |
|
| 14 |
|
| 15 |
|
| 16 |
|
| 17 |
const emailSelection = new GoingsOn.SelectionManager('email', '#email-list', 'email-bulk-actions'); |
| 18 |
const emailPagination = new GoingsOn.PaginationManager('email', GoingsOn.state.itemsPerPage); |
| 19 |
|
| 20 |
|
| 21 |
const selectedEmailIds = emailSelection.selectedIds; |
| 22 |
|
| 23 |
|
| 24 |
let emailScroller = null; |
| 25 |
|
| 26 |
|
| 27 |
GoingsOn.state.set('emailThreads', []); |
| 28 |
|
| 29 |
|
| 30 |
|
| 31 |
|
| 32 |
* Fetch threaded emails and render via virtual scroller. |
| 33 |
|
| 34 |
async function load() { |
| 35 |
if (GoingsOn.cache.isFresh('emails')) return; |
| 36 |
|
| 37 |
|
| 38 |
|
| 39 |
|
| 40 |
restoreFiltersFromUrl(); |
| 41 |
const initialSearch = GoingsOn.queryState?.read('q'); |
| 42 |
if (initialSearch) { |
| 43 |
searchEmails(initialSearch); |
| 44 |
return; |
| 45 |
} |
| 46 |
|
| 47 |
const container = document.getElementById('email-list'); |
| 48 |
try { |
| 49 |
|
| 50 |
const response = await GoingsOn.api.emails.listThreaded({ |
| 51 |
includeArchived: false, |
| 52 |
offset: 0, |
| 53 |
limit: 500, |
| 54 |
folder: activeFolder || null, |
| 55 |
label: activeLabel || null, |
| 56 |
}); |
| 57 |
|
| 58 |
|
| 59 |
loadFilters(); |
| 60 |
|
| 61 |
|
| 62 |
GoingsOn.state.set('emails', response.threads.map(t => t.mostRecentEmail)); |
| 63 |
GoingsOn.state.set('emailThreads', response.threads); |
| 64 |
|
| 65 |
|
| 66 |
_updateEmailCount(response.total, response.threads.length); |
| 67 |
|
| 68 |
if (response.total === 0) { |
| 69 |
const hasAccounts = GoingsOn.getEmailAccountsCache().length > 0; |
| 70 |
container.innerHTML = hasAccounts |
| 71 |
? GoingsOn.ui.renderEmptyState('No emails yet.', 'Compose', 'GoingsOn.emails.openCompose()', 'emails') |
| 72 |
: GoingsOn.ui.renderEmptyState('Set up an email account to get started.', 'Add Account', 'GoingsOn.emails.openAccountsModal()', 'inbox'); |
| 73 |
|
| 74 |
const paginationEl = document.getElementById('email-pagination'); |
| 75 |
if (paginationEl) paginationEl.classList.add('hidden'); |
| 76 |
|
| 77 |
if (emailScroller) { |
| 78 |
emailScroller.destroy(); |
| 79 |
emailScroller = null; |
| 80 |
} |
| 81 |
return; |
| 82 |
} |
| 83 |
|
| 84 |
|
| 85 |
emailSelection.setItems(response.threads.map(t => ({ id: t.mostRecentEmail.id }))); |
| 86 |
|
| 87 |
|
| 88 |
const paginationEl = document.getElementById('email-pagination'); |
| 89 |
if (paginationEl) paginationEl.classList.add('hidden'); |
| 90 |
|
| 91 |
|
| 92 |
if (!emailScroller) { |
| 93 |
emailScroller = new GoingsOn.VirtualScroller({ |
| 94 |
container: container, |
| 95 |
renderItem: renderEmailItem, |
| 96 |
getItems: () => GoingsOn.state.emailThreads, |
| 97 |
rowHeight: { estimated: 90, measure: true }, |
| 98 |
overscan: 5, |
| 99 |
}); |
| 100 |
} else { |
| 101 |
emailScroller.refresh(); |
| 102 |
} |
| 103 |
GoingsOn.cache.markLoaded('emails'); |
| 104 |
} catch (err) { |
| 105 |
container.innerHTML = `<div class="loading loading--error">Failed to load emails. <button class="btn-link" onclick="GoingsOn.emails.load()">Try again</button></div>`; |
| 106 |
GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load emails'), 'error', { |
| 107 |
action: { label: 'Retry', fn: load }, |
| 108 |
duration: 8000, |
| 109 |
}); |
| 110 |
} |
| 111 |
} |
| 112 |
|
| 113 |
|
| 114 |
* Render a single email item (for virtual scrolling). |
| 115 |
* @param {Object} thread - Thread object with mostRecentEmail |
| 116 |
* @param {number} index - Item index |
| 117 |
* @returns {string} HTML string |
| 118 |
|
| 119 |
function renderEmailItem(thread, index) { |
| 120 |
const e = thread.mostRecentEmail; |
| 121 |
|
| 122 |
const isSnoozed = e.isSnoozed; |
| 123 |
const isSelected = emailSelection.isSelected(e.id); |
| 124 |
const threadBadge = thread.threadCount > 1 |
| 125 |
? `<span class="thread-badge" title="${thread.threadCount} messages in thread">${thread.threadCount}</span>` |
| 126 |
: ''; |
| 127 |
const labelBadges = (e.labels || []).map(l => |
| 128 |
`<span class="badge badge--xs badge--filled" data-color="blue">${esc(l)}</span>` |
| 129 |
).join(''); |
| 130 |
|
| 131 |
return ` |
| 132 |
<div class="email-item email-item-with-checkbox ${thread.hasUnread ? 'unread' : ''} ${isSnoozed ? 'email-snoozed' : ''}" |
| 133 |
data-id="${escAttr(e.id)}" |
| 134 |
oncontextmenu="GoingsOn.contextMenus.showEmail(event, '${escAttr(e.id)}')" |
| 135 |
data-email-archived="${e.isArchived}" data-email-read="${e.isRead}" |
| 136 |
data-email-snoozed="${isSnoozed}" |
| 137 |
tabindex="0" role="listitem" aria-label="Email from ${esc(e.from)}"> |
| 138 |
<div class="email-checkbox-cell" onclick="event.stopPropagation();"> |
| 139 |
<input type="checkbox" class="bulk-checkbox" data-id="${escAttr(e.id)}" |
| 140 |
${isSelected ? 'checked' : ''} |
| 141 |
onchange="GoingsOn.emails.toggleSelection('${escAttr(e.id)}', this, event)" |
| 142 |
aria-label="Select email"> |
| 143 |
</div> |
| 144 |
<div class="email-content" onclick="GoingsOn.emails.open('${escAttr(e.id)}')" role="button"> |
| 145 |
<div class="email-header"> |
| 146 |
<span class="email-from">${esc(e.from)}</span> |
| 147 |
${threadBadge} |
| 148 |
${isSnoozed ? `<span class="snooze-badge" title="Snoozed until ${escAttr(e.snoozedUntilFormatted || '')}" aria-label="Snoozed until ${escAttr(e.snoozedUntilFormatted || '')}">Snoozed</span>` : ''} |
| 149 |
<span class="email-date">${e.receivedFormatted}</span> |
| 150 |
</div> |
| 151 |
<div class="email-subject">${esc(e.subject)}${labelBadges ? ' ' + labelBadges : ''}</div> |
| 152 |
<div class="email-preview">${esc(e.body.substring(0, 100))}...</div> |
| 153 |
</div> |
| 154 |
<button class="btn-icon kebab-btn" onclick="event.stopPropagation(); GoingsOn.contextMenus.showEmail(event, '${escAttr(e.id)}')" title="Actions" aria-label="Email actions">⋮</button> |
| 155 |
</div> |
| 156 |
`; |
| 157 |
} |
| 158 |
|
| 159 |
|
| 160 |
* Open the email compose interface (new window on desktop, modal on mobile). |
| 161 |
|
| 162 |
async function openCompose() { |
| 163 |
|
| 164 |
if (GoingsOn.touch?.isTouchDevice) { |
| 165 |
openComposeModal(); |
| 166 |
return; |
| 167 |
} |
| 168 |
|
| 169 |
|
| 170 |
try { |
| 171 |
await GoingsOn.api.window.openCompose(); |
| 172 |
} catch (err) { |
| 173 |
console.error('Failed to open compose window:', err); |
| 174 |
|
| 175 |
openComposeModal(); |
| 176 |
} |
| 177 |
} |
| 178 |
|
| 179 |
|
| 180 |
|
| 181 |
let modalComposeCtrl = null; |
| 182 |
|
| 183 |
function openComposeModal(prefill) { |
| 184 |
modalComposeCtrl = null; |
| 185 |
const accounts = GoingsOn.getEmailAccountsCache(); |
| 186 |
const pf = prefill || {}; |
| 187 |
|
| 188 |
|
| 189 |
|
| 190 |
|
| 191 |
|
| 192 |
|
| 193 |
|
| 194 |
const selectedAccount = accounts.find(a => a.id === pf.accountId) || accounts[0]; |
| 195 |
const selectedAccountId = selectedAccount?.id || null; |
| 196 |
const sig = selectedAccount?.emailSignature || ''; |
| 197 |
const bodyWithSig = (pf.body || '') + (sig ? '\n\n-- \n' + sig : ''); |
| 198 |
|
| 199 |
const ccPrefill = pf.cc || ''; |
| 200 |
const bccPrefill = pf.bcc || ''; |
| 201 |
const ccBccExpanded = !!(ccPrefill || bccPrefill); |
| 202 |
|
| 203 |
const fieldsHtml = GoingsOn.composeForm.buildFieldsHtml({ |
| 204 |
accounts, |
| 205 |
selectedAccountId, |
| 206 |
prefill: { |
| 207 |
to: pf.to || '', |
| 208 |
cc: ccPrefill, |
| 209 |
bcc: bccPrefill, |
| 210 |
subject: pf.subject || '', |
| 211 |
body: bodyWithSig, |
| 212 |
}, |
| 213 |
showCcBcc: ccBccExpanded, |
| 214 |
showAttachments: true, |
| 215 |
bodyRows: 8, |
| 216 |
}); |
| 217 |
|
| 218 |
const replyContext = { |
| 219 |
inReplyTo: pf.inReplyTo || null, |
| 220 |
references: pf.references || null, |
| 221 |
threadId: pf.threadId || null, |
| 222 |
}; |
| 223 |
|
| 224 |
const content = ` |
| 225 |
<form id="compose-modal-form" onsubmit="event.preventDefault(); return false;"> |
| 226 |
<span id="reply-indicator" class="reply-indicator"></span> |
| 227 |
${fieldsHtml} |
| 228 |
<div class="form-actions" style="margin-top: 0.75rem;"> |
| 229 |
<button type="button" class="btn btn-secondary" id="compose-modal-attach">Attach File</button> |
| 230 |
<button type="button" class="btn btn-secondary" id="compose-modal-save-draft">Save Draft</button> |
| 231 |
<div class="flex-1"></div> |
| 232 |
<button type="button" class="btn btn-secondary" id="compose-modal-cancel">Cancel</button> |
| 233 |
<button type="submit" class="btn btn-primary" id="compose-modal-send">Send</button> |
| 234 |
</div> |
| 235 |
</form> |
| 236 |
`; |
| 237 |
|
| 238 |
GoingsOn.modal.openModal('Compose Email', content); |
| 239 |
|
| 240 |
setTimeout(() => { |
| 241 |
const form = document.getElementById('compose-modal-form'); |
| 242 |
if (!form) return; |
| 243 |
|
| 244 |
|
| 245 |
|
| 246 |
|
| 247 |
modalComposeCtrl = GoingsOn.composeForm.bindBehaviors({ |
| 248 |
accounts, |
| 249 |
initialSignature: sig, |
| 250 |
getContacts: GoingsOn.autocomplete.getContacts, |
| 251 |
onError: (msg) => GoingsOn.ui.showToast(msg, 'error', { duration: 8000 }), |
| 252 |
enableAutosave: true, |
| 253 |
initialDraftId: pf.draftId || null, |
| 254 |
saveDraft: (input) => GoingsOn.api.emails.saveDraft(input), |
| 255 |
getReplyContext: () => replyContext, |
| 256 |
onDraftStatus: (kind, message) => { |
| 257 |
if (kind === 'error') { |
| 258 |
GoingsOn.ui.showToast(message, 'error', { duration: 6000 }); |
| 259 |
} else { |
| 260 |
GoingsOn.ui.showToast(message, 'success', { duration: 2500 }); |
| 261 |
} |
| 262 |
}, |
| 263 |
enableReplyIndicator: true, |
| 264 |
}); |
| 265 |
|
| 266 |
const attachBtn = document.getElementById('compose-modal-attach'); |
| 267 |
const cancelBtn = document.getElementById('compose-modal-cancel'); |
| 268 |
const sendBtn = document.getElementById('compose-modal-send'); |
| 269 |
const saveDraftBtn = document.getElementById('compose-modal-save-draft'); |
| 270 |
if (attachBtn) attachBtn.addEventListener('click', modalComposeCtrl.pickAttachment); |
| 271 |
if (saveDraftBtn) saveDraftBtn.addEventListener('click', () => modalComposeCtrl.saveDraftNow()); |
| 272 |
if (cancelBtn) cancelBtn.addEventListener('click', () => GoingsOn.ui.closeModal()); |
| 273 |
|
| 274 |
const submit = async () => { |
| 275 |
modalComposeCtrl.setSending(true); |
| 276 |
const accountSelect = document.getElementById('from-account'); |
| 277 |
const toEl = document.getElementById('to-address'); |
| 278 |
const ccEl = document.getElementById('cc-address'); |
| 279 |
const bccEl = document.getElementById('bcc-address'); |
| 280 |
const subjectEl = document.getElementById('subject'); |
| 281 |
const bodyEl = document.getElementById('body'); |
| 282 |
const attachedFiles = modalComposeCtrl.getAttachedFiles(); |
| 283 |
const input = GoingsOn.composeForm.collectInput({ |
| 284 |
accountId: accountSelect ? accountSelect.value : null, |
| 285 |
toAddress: toEl ? toEl.value : '', |
| 286 |
ccAddress: ccEl ? ccEl.value : '', |
| 287 |
bccAddress: bccEl ? bccEl.value : '', |
| 288 |
subject: subjectEl ? subjectEl.value : '', |
| 289 |
body: bodyEl ? bodyEl.value : '', |
| 290 |
attachedFiles, |
| 291 |
replyContext, |
| 292 |
}); |
| 293 |
const result = GoingsOn.composeForm.validateForSend(input, attachedFiles); |
| 294 |
if (!result.ok) { |
| 295 |
GoingsOn.ui.showToast(result.message, 'error', { duration: 8000 }); |
| 296 |
modalComposeCtrl.setSending(false); |
| 297 |
return; |
| 298 |
} |
| 299 |
GoingsOn.ui.closeModal(); |
| 300 |
queueSend({ input }); |
| 301 |
}; |
| 302 |
form.addEventListener('submit', (e) => { e.preventDefault(); submit(); }); |
| 303 |
if (sendBtn) sendBtn.addEventListener('click', (e) => { e.preventDefault(); submit(); }); |
| 304 |
}, 50); |
| 305 |
} |
| 306 |
|
| 307 |
async function markAllRead() { |
| 308 |
GoingsOn.cache.invalidate('emails'); |
| 309 |
await GoingsOn.ui.apiCall(GoingsOn.api.emails.markAllRead(), { |
| 310 |
successMessage: 'All emails marked as read!', |
| 311 |
errorMessage: 'Failed to mark emails as read', |
| 312 |
closeModal: false, |
| 313 |
reload: load, |
| 314 |
}); |
| 315 |
} |
| 316 |
|
| 317 |
|
| 318 |
* Open an email in reader mode, loading its full thread if available. |
| 319 |
* Marks the email as read and shows sender contact info. |
| 320 |
* @param {string} id - Email ID to open |
| 321 |
|
| 322 |
async function open(id) { |
| 323 |
try { |
| 324 |
const email = await GoingsOn.api.emails.get(id); |
| 325 |
if (!email) return; |
| 326 |
|
| 327 |
|
| 328 |
await GoingsOn.api.emails.markRead(id); |
| 329 |
|
| 330 |
|
| 331 |
let threadEmails = [email]; |
| 332 |
if (email.threadId) { |
| 333 |
try { |
| 334 |
const thread = await GoingsOn.api.emails.listByThread(email.threadId); |
| 335 |
if (thread && thread.length > 1) { |
| 336 |
|
| 337 |
threadEmails = thread; |
| 338 |
} |
| 339 |
} catch (e) { |
| 340 |
console.error('Failed to load thread:', e); |
| 341 |
} |
| 342 |
} |
| 343 |
|
| 344 |
const isThread = threadEmails.length > 1; |
| 345 |
|
| 346 |
|
| 347 |
const latestEmail = threadEmails[threadEmails.length - 1]; |
| 348 |
const archiveBtn = latestEmail.isArchived |
| 349 |
? `<button class="btn btn-secondary" onclick="GoingsOn.emails.unarchive('${escAttr(latestEmail.id)}')">Unarchive</button>` |
| 350 |
: `<button class="btn btn-secondary" onclick="GoingsOn.emails.archive('${escAttr(latestEmail.id)}')">Archive</button>`; |
| 351 |
|
| 352 |
|
| 353 |
const isSnoozed = latestEmail.isSnoozed; |
| 354 |
const snoozeBtn = isSnoozed |
| 355 |
? `<button class="btn btn-secondary" onclick="GoingsOn.snooze.unsnooze('email', '${escAttr(latestEmail.id)}')">Unsnooze</button>` |
| 356 |
: `<button class="btn btn-secondary" onclick="GoingsOn.snooze.openModal('email', '${escAttr(latestEmail.id)}')">Snooze</button>`; |
| 357 |
|
| 358 |
|
| 359 |
const parsed = GoingsOn.utils.parseEmailAddress(email.from); |
| 360 |
let senderContact = null; |
| 361 |
if (parsed.email) { |
| 362 |
try { |
| 363 |
senderContact = await GoingsOn.api.contacts.findByEmail(parsed.email); |
| 364 |
} catch (e) { |
| 365 |
console.error('Failed to look up contact:', e); |
| 366 |
} |
| 367 |
} |
| 368 |
|
| 369 |
|
| 370 |
let contactCardHtml = ''; |
| 371 |
if (senderContact) { |
| 372 |
const initials = (senderContact.displayName || senderContact.display_name || '?') |
| 373 |
.split(/\s+/).map(w => w[0]).join('').substring(0, 2).toUpperCase(); |
| 374 |
const company = senderContact.company ? esc(senderContact.company) : ''; |
| 375 |
contactCardHtml = ` |
| 376 |
<div class="email-sender-contact row-flex row-flex-2"> |
| 377 |
<div class="avatar avatar--sm">${initials}</div> |
| 378 |
<div class="email-sender-info"> |
| 379 |
<span class="email-sender-name">${esc(senderContact.displayName || senderContact.display_name)}</span> |
| 380 |
${company ? `<span class="email-sender-company">${company}</span>` : ''} |
| 381 |
</div> |
| 382 |
<button class="btn btn-sm btn-secondary" onclick="GoingsOn.ui.closeModal(); GoingsOn.contacts.open('${escAttr(senderContact.id)}')">View Contact</button> |
| 383 |
</div> |
| 384 |
`; |
| 385 |
} else if (parsed.email) { |
| 386 |
contactCardHtml = ` |
| 387 |
<div class="email-sender-contact row-flex row-flex-2"> |
| 388 |
<div class="avatar avatar--sm avatar--unknown">?</div> |
| 389 |
<div class="email-sender-info"> |
| 390 |
<span class="email-sender-name">${esc(parsed.name || parsed.email)}</span> |
| 391 |
</div> |
| 392 |
<button class="btn btn-sm btn-secondary" onclick="GoingsOn.emails.createContactFromSender('${escAttr(id)}')">+ Save Contact</button> |
| 393 |
</div> |
| 394 |
`; |
| 395 |
} |
| 396 |
|
| 397 |
|
| 398 |
const allAttachments = threadEmails.flatMap(e => |
| 399 |
(e.attachments || []).map(a => ({ ...a, emailFrom: e.from })) |
| 400 |
); |
| 401 |
let attachmentHtml = ''; |
| 402 |
if (allAttachments.length > 0) { |
| 403 |
const attachmentItems = allAttachments.map(a => { |
| 404 |
const icon = GoingsOn.attachments.getIcon(a.mimeType); |
| 405 |
return ` |
| 406 |
<div class="email-attachment-row"> |
| 407 |
<span>${icon}</span> |
| 408 |
<span class="attachment-filename email-attachment-name" |
| 409 |
title="${escAttr(a.filename)}">${esc(a.filename)}</span> |
| 410 |
<span class="email-attachment-size">${esc(a.sizeFormatted)}</span> |
| 411 |
<button class="btn btn-sm btn-secondary" onclick="GoingsOn.emails.openBlob('${escAttr(a.blobHash)}', '${escAttr(a.filename)}')" title="Open">Open</button> |
| 412 |
<button class="btn btn-sm btn-secondary" onclick="GoingsOn.emails.saveBlob('${escAttr(a.blobHash)}', '${escAttr(a.filename)}')" title="Save">Save</button> |
| 413 |
</div> |
| 414 |
`; |
| 415 |
}).join(''); |
| 416 |
|
| 417 |
attachmentHtml = ` |
| 418 |
<div class="email-attachments-block"> |
| 419 |
<div class="email-attachments-heading">Attachments (${allAttachments.length})</div> |
| 420 |
${attachmentItems} |
| 421 |
</div> |
| 422 |
`; |
| 423 |
} |
| 424 |
|
| 425 |
|
| 426 |
const threadContent = threadEmails.map((e, index) => { |
| 427 |
const isLatest = index === threadEmails.length - 1; |
| 428 |
const dateStr = new Date(e.receivedAt).toLocaleString(); |
| 429 |
const directionIcon = e.isOutgoing ? '↗' : '↙'; |
| 430 |
const formattedBody = GoingsOn.utils.formatEmailBody(e.body); |
| 431 |
|
| 432 |
return ` |
| 433 |
<div class="thread-message ${isLatest ? 'thread-message-latest' : ''}"> |
| 434 |
<div class="thread-message-header"> |
| 435 |
<span>${directionIcon} <span class="thread-message-from">${esc(e.from)}</span></span> |
| 436 |
<span>${dateStr}</span> |
| 437 |
</div> |
| 438 |
<div class="email-reader-body">${formattedBody}</div> |
| 439 |
</div> |
| 440 |
`; |
| 441 |
}).join(''); |
| 442 |
|
| 443 |
const subjectLine = isThread |
| 444 |
? `${esc(email.subject)} <span class="email-thread-count">(${threadEmails.length} messages)</span>` |
| 445 |
: esc(email.subject); |
| 446 |
|
| 447 |
const content = ` |
| 448 |
<div class="email-reader-container"> |
| 449 |
<div class="email-reader-header"> |
| 450 |
<div class="email-subject-line">${subjectLine}</div> |
| 451 |
<div class="email-meta-line"> |
| 452 |
From: ${esc(email.from)} |
| 453 |
${email.isArchived ? ' · <em>Archived</em>' : ''} |
| 454 |
${email.sourceFolder ? ` · ${esc(email.sourceFolder)}` : ''} |
| 455 |
${(email.labels || []).length > 0 ? ' · ' + email.labels.map(l => `<span class="badge badge--xs badge--filled" data-color="blue">${esc(l)}</span>`).join(' ') : ''} |
| 456 |
${isSnoozed ? ` · <span class="email-snoozed-tag"><em>Snoozed until ${esc(latestEmail.snoozedUntilFormatted || '')}</em></span>` : ''} |
| 457 |
</div> |
| 458 |
${contactCardHtml} |
| 459 |
</div> |
| 460 |
${attachmentHtml} |
| 461 |
<div class="email-reader-thread"> |
| 462 |
${threadContent} |
| 463 |
</div> |
| 464 |
<div class="form-actions email-actions-bar"> |
| 465 |
<button class="btn btn-primary" onclick="GoingsOn.emails.reply('${escAttr(latestEmail.id)}')">Reply</button> |
| 466 |
<button class="btn btn-secondary" onclick="GoingsOn.emails.replyAll('${escAttr(latestEmail.id)}')">Reply All</button> |
| 467 |
<button class="btn btn-secondary" onclick="GoingsOn.emails.forward('${escAttr(latestEmail.id)}')">Forward</button> |
| 468 |
<button class="btn btn-secondary text-accent-red" onclick="GoingsOn.emails.delete('${escAttr(latestEmail.id)}')">Delete</button> |
| 469 |
${archiveBtn} |
| 470 |
${snoozeBtn} |
| 471 |
<button class="btn btn-secondary" onclick="GoingsOn.emails.createTaskFromEmail('${escAttr(latestEmail.id)}')">Create Task</button> |
| 472 |
<div class="dropdown" style="position: relative;"> |
| 473 |
<button class="btn btn-secondary" onclick="this.nextElementSibling.classList.toggle('show')"> |
| 474 |
Actions ▾ |
| 475 |
</button> |
| 476 |
<div class="dropdown-menu"> |
| 477 |
<button class="dropdown-item" onclick="GoingsOn.emails.createTaskFromEmail('${escAttr(latestEmail.id)}'); this.parentElement.classList.remove('show');"> |
| 478 |
Convert to Task |
| 479 |
</button> |
| 480 |
<button class="dropdown-item" onclick="GoingsOn.emails.createEventFromEmail('${escAttr(latestEmail.id)}'); this.parentElement.classList.remove('show');"> |
| 481 |
Convert to Event |
| 482 |
</button> |
| 483 |
<button class="dropdown-item" onclick="GoingsOn.emails.editLabels('${escAttr(latestEmail.id)}', ${escAttr(JSON.stringify(latestEmail.labels || []))}); this.parentElement.classList.remove('show');"> |
| 484 |
Edit Labels |
| 485 |
</button> |
| 486 |
<button class="dropdown-item" onclick="GoingsOn.emails.moveToFolder('${escAttr(latestEmail.id)}'); this.parentElement.classList.remove('show');"> |
| 487 |
Move to Folder |
| 488 |
</button> |
| 489 |
</div> |
| 490 |
</div> |
| 491 |
<div class="flex-1"></div> |
| 492 |
<button class="btn btn-secondary" onclick="GoingsOn.emails.openInBrowser('${escAttr(latestEmail.id)}')" title="Open in browser">Open in Browser</button> |
| 493 |
</div> |
| 494 |
</div> |
| 495 |
`; |
| 496 |
GoingsOn.ui.openModal(isThread ? 'Thread' : 'Email', content, { large: true }); |
| 497 |
load(); |
| 498 |
} catch (err) { |
| 499 |
GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load email'), 'error'); |
| 500 |
} |
| 501 |
} |
| 502 |
|
| 503 |
|
| 504 |
* Delete an email with confirmation dialog. |
| 505 |
* @param {string} id - Email ID to delete |
| 506 |
|
| 507 |
async function deleteEmail(id) { |
| 508 |
if (!await GoingsOn.ui.confirmDelete('email')) return; |
| 509 |
|
| 510 |
GoingsOn.cache.invalidate('emails'); |
| 511 |
await GoingsOn.ui.apiCall(GoingsOn.api.emails.delete(id), { |
| 512 |
successMessage: 'Email deleted!', |
| 513 |
errorMessage: 'Failed to delete email', |
| 514 |
reload: load, |
| 515 |
}); |
| 516 |
} |
| 517 |
|
| 518 |
|
| 519 |
* Archive an email (also moves on IMAP server if available). |
| 520 |
* @param {string} id - Email ID to archive |
| 521 |
|
| 522 |
async function archive(id) { |
| 523 |
GoingsOn.cache.invalidate('emails'); |
| 524 |
await GoingsOn.ui.apiCall(GoingsOn.api.emails.archive(id), { |
| 525 |
successMessage: 'Email archived!', |
| 526 |
errorMessage: 'Failed to archive email', |
| 527 |
reload: load, |
| 528 |
}); |
| 529 |
} |
| 530 |
|
| 531 |
|
| 532 |
* Unarchive an email. |
| 533 |
* @param {string} id - Email ID to unarchive |
| 534 |
|
| 535 |
async function unarchive(id) { |
| 536 |
GoingsOn.cache.invalidate('emails'); |
| 537 |
await GoingsOn.ui.apiCall(GoingsOn.api.emails.unarchive(id), { |
| 538 |
successMessage: 'Email unarchived!', |
| 539 |
errorMessage: 'Failed to unarchive email', |
| 540 |
reload: load, |
| 541 |
}); |
| 542 |
} |
| 543 |
|
| 544 |
|
| 545 |
* Mark an email as read. |
| 546 |
* @param {string} id - Email ID |
| 547 |
|
| 548 |
async function markRead(id) { |
| 549 |
GoingsOn.cache.invalidate('emails'); |
| 550 |
await GoingsOn.ui.apiCall(GoingsOn.api.emails.markRead(id), { |
| 551 |
errorMessage: 'Failed to mark email as read', |
| 552 |
closeModal: false, |
| 553 |
reload: load, |
| 554 |
}); |
| 555 |
} |
| 556 |
|
| 557 |
|
| 558 |
* Mark an email as unread. |
| 559 |
* @param {string} id - Email ID |
| 560 |
|
| 561 |
async function markUnread(id) { |
| 562 |
GoingsOn.cache.invalidate('emails'); |
| 563 |
await GoingsOn.ui.apiCall(GoingsOn.api.emails.markUnread(id), { |
| 564 |
errorMessage: 'Failed to mark email as unread', |
| 565 |
closeModal: false, |
| 566 |
reload: load, |
| 567 |
}); |
| 568 |
} |
| 569 |
|
| 570 |
|
| 571 |
* Create a new contact from an email's sender address. |
| 572 |
* Parses the From field, creates the contact, and adds the email address. |
| 573 |
* @param {string} emailId - Email ID to extract sender from |
| 574 |
|
| 575 |
async function createContactFromSender(emailId) { |
| 576 |
try { |
| 577 |
const email = await GoingsOn.api.emails.get(emailId); |
| 578 |
if (!email) { |
| 579 |
GoingsOn.ui.showToast('Email not found', 'error'); |
| 580 |
return; |
| 581 |
} |
| 582 |
|
| 583 |
const parsed = GoingsOn.utils.parseEmailAddress(email.from); |
| 584 |
if (!parsed.email) { |
| 585 |
GoingsOn.ui.showToast('Could not parse email address', 'error'); |
| 586 |
return; |
| 587 |
} |
| 588 |
|
| 589 |
|
| 590 |
const displayName = parsed.name || parsed.email.split('@')[0]; |
| 591 |
|
| 592 |
|
| 593 |
const contact = await GoingsOn.api.contacts.create({ |
| 594 |
displayName: displayName, |
| 595 |
}); |
| 596 |
|
| 597 |
|
| 598 |
await GoingsOn.api.contacts.addEmail(contact.id, { |
| 599 |
address: parsed.email, |
| 600 |
label: 'Work', |
| 601 |
isPrimary: true, |
| 602 |
}); |
| 603 |
|
| 604 |
|
| 605 |
GoingsOn.cache.invalidate('contacts'); |
| 606 |
const contacts = await GoingsOn.api.contacts.list(); |
| 607 |
GoingsOn.state.set('contacts', contacts); |
| 608 |
|
| 609 |
GoingsOn.ui.showToast('Contact saved!', 'success'); |
| 610 |
|
| 611 |
|
| 612 |
open(emailId); |
| 613 |
} catch (err) { |
| 614 |
GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to create contact'), 'error'); |
| 615 |
} |
| 616 |
} |
| 617 |
|
| 618 |
|
| 619 |
* Create a task from an email's subject and sender info. |
| 620 |
* Auto-links the sender's contact if one exists. |
| 621 |
* @param {string} emailId - Source email ID |
| 622 |
|
| 623 |
async function createTaskFromEmail(emailId) { |
| 624 |
try { |
| 625 |
const email = await GoingsOn.api.emails.get(emailId); |
| 626 |
if (!email) { |
| 627 |
GoingsOn.ui.showToast('Email not found', 'error'); |
| 628 |
return; |
| 629 |
} |
| 630 |
|
| 631 |
|
| 632 |
let contactId = null; |
| 633 |
const parsed = GoingsOn.utils.parseEmailAddress(email.from); |
| 634 |
if (parsed.email) { |
| 635 |
try { |
| 636 |
const contact = await GoingsOn.api.contacts.findByEmail(parsed.email); |
| 637 |
if (contact) contactId = contact.id; |
| 638 |
} catch (_) { } |
| 639 |
} |
| 640 |
|
| 641 |
const taskData = { |
| 642 |
description: email.subject, |
| 643 |
projectId: email.projectId || null, |
| 644 |
priority: 'Medium', |
| 645 |
due: null, |
| 646 |
tags: [], |
| 647 |
recurrence: 'None', |
| 648 |
sourceEmailId: emailId, |
| 649 |
contactId: contactId, |
| 650 |
}; |
| 651 |
|
| 652 |
await GoingsOn.api.tasks.create(taskData); |
| 653 |
GoingsOn.ui.showToast('Task created from email!', 'success'); |
| 654 |
GoingsOn.ui.closeModal(); |
| 655 |
GoingsOn.cache.invalidate('tasks'); |
| 656 |
GoingsOn.tasks.load(); |
| 657 |
} catch (err) { |
| 658 |
GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to create task'), 'error'); |
| 659 |
} |
| 660 |
} |
| 661 |
|
| 662 |
|
| 663 |
* Create a calendar event from an email's subject and body. |
| 664 |
* Defaults to a 1-hour event starting at the next hour. |
| 665 |
* @param {string} emailId - Source email ID |
| 666 |
|
| 667 |
async function createEventFromEmail(emailId) { |
| 668 |
try { |
| 669 |
const email = await GoingsOn.api.emails.get(emailId); |
| 670 |
if (!email) { |
| 671 |
GoingsOn.ui.showToast('Email not found', 'error'); |
| 672 |
return; |
| 673 |
} |
| 674 |
|
| 675 |
|
| 676 |
let contactId = null; |
| 677 |
const parsed = GoingsOn.utils.parseEmailAddress(email.from); |
| 678 |
if (parsed.email) { |
| 679 |
try { |
| 680 |
const contact = await GoingsOn.api.contacts.findByEmail(parsed.email); |
| 681 |
if (contact) contactId = contact.id; |
| 682 |
} catch (_) { } |
| 683 |
} |
| 684 |
|
| 685 |
|
| 686 |
const now = new Date(); |
| 687 |
now.setMinutes(0, 0, 0); |
| 688 |
now.setHours(now.getHours() + 1); |
| 689 |
const startTime = now.toISOString().slice(0, 16); |
| 690 |
|
| 691 |
const endTime = new Date(now.getTime() + 60 * 60 * 1000); |
| 692 |
const endTimeStr = endTime.toISOString().slice(0, 16); |
| 693 |
|
| 694 |
const eventData = { |
| 695 |
title: email.subject, |
| 696 |
projectId: email.projectId || null, |
| 697 |
startTime: startTime, |
| 698 |
endTime: endTimeStr, |
| 699 |
location: '', |
| 700 |
description: `From: ${email.from}\n\n${email.body.substring(0, 500)}${email.body.length > 500 ? '...' : ''}`, |
| 701 |
isAllDay: false, |
| 702 |
contactId: contactId, |
| 703 |
}; |
| 704 |
|
| 705 |
await GoingsOn.api.events.create(eventData); |
| 706 |
GoingsOn.ui.showToast('Event created from email!', 'success'); |
| 707 |
GoingsOn.ui.closeModal(); |
| 708 |
GoingsOn.cache.invalidate('events'); |
| 709 |
GoingsOn.events.load(); |
| 710 |
} catch (err) { |
| 711 |
GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to create event'), 'error'); |
| 712 |
} |
| 713 |
} |
| 714 |
|
| 715 |
|
| 716 |
* Open compose window for a reply. |
| 717 |
* @param {string} emailId - Email to reply to |
| 718 |
* @param {boolean} replyAll - If true, include all recipients |
| 719 |
|
| 720 |
async function openReply(emailId, replyAll) { |
| 721 |
try { |
| 722 |
const email = await GoingsOn.api.emails.get(emailId); |
| 723 |
if (!email) return; |
| 724 |
|
| 725 |
|
| 726 |
const accountId = email.emailAccountId || ''; |
| 727 |
|
| 728 |
|
| 729 |
let to; |
| 730 |
if (replyAll) { |
| 731 |
|
| 732 |
const accounts = GoingsOn.getEmailAccountsCache(); |
| 733 |
const ownAddresses = new Set(accounts.map(a => a.email.toLowerCase())); |
| 734 |
const allAddresses = []; |
| 735 |
|
| 736 |
|
| 737 |
const senderEmail = extractEmail(email.from); |
| 738 |
if (senderEmail && !ownAddresses.has(senderEmail.toLowerCase())) { |
| 739 |
allAddresses.push(senderEmail); |
| 740 |
} |
| 741 |
|
| 742 |
|
| 743 |
if (email.to) { |
| 744 |
email.to.split(',').map(a => extractEmail(a.trim())).forEach(addr => { |
| 745 |
if (addr && !ownAddresses.has(addr.toLowerCase()) && !allAddresses.includes(addr)) { |
| 746 |
allAddresses.push(addr); |
| 747 |
} |
| 748 |
}); |
| 749 |
} |
| 750 |
|
| 751 |
to = allAddresses.join(', '); |
| 752 |
} else { |
| 753 |
|
| 754 |
to = extractEmail(email.from) || email.from; |
| 755 |
} |
| 756 |
|
| 757 |
|
| 758 |
let subject = email.subject || ''; |
| 759 |
if (!subject.match(/^Re:/i)) { |
| 760 |
subject = 'Re: ' + subject; |
| 761 |
} |
| 762 |
|
| 763 |
|
| 764 |
const date = new Date(email.receivedAt).toLocaleString(); |
| 765 |
const quotedLines = (email.body || '').split('\n').map(l => '> ' + l).join('\n'); |
| 766 |
const body = '\n\nOn ' + date + ', ' + email.from + ' wrote:\n>\n' + quotedLines; |
| 767 |
|
| 768 |
|
| 769 |
let references = ''; |
| 770 |
if (email.messageId) { |
| 771 |
references = email.messageId; |
| 772 |
} |
| 773 |
|
| 774 |
|
| 775 |
if (GoingsOn.touch?.isTouchDevice) { |
| 776 |
openComposeModal({ |
| 777 |
to, |
| 778 |
subject, |
| 779 |
body, |
| 780 |
inReplyTo: email.messageId || null, |
| 781 |
references: references || null, |
| 782 |
threadId: email.threadId || null, |
| 783 |
accountId, |
| 784 |
}); |
| 785 |
} else { |
| 786 |
await GoingsOn.api.window.openCompose({ |
| 787 |
to, |
| 788 |
subject, |
| 789 |
body, |
| 790 |
inReplyTo: email.messageId || null, |
| 791 |
references: references || null, |
| 792 |
threadId: email.threadId || null, |
| 793 |
accountId, |
| 794 |
}); |
| 795 |
} |
| 796 |
} catch (err) { |
| 797 |
GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to open reply'), 'error'); |
| 798 |
} |
| 799 |
} |
| 800 |
|
| 801 |
|
| 802 |
* Open compose window to forward an email. |
| 803 |
* @param {string} emailId - Email to forward |
| 804 |
|
| 805 |
async function openForward(emailId) { |
| 806 |
try { |
| 807 |
const email = await GoingsOn.api.emails.get(emailId); |
| 808 |
if (!email) return; |
| 809 |
|
| 810 |
const accountId = email.emailAccountId || ''; |
| 811 |
|
| 812 |
|
| 813 |
let subject = email.subject || ''; |
| 814 |
if (!subject.match(/^Fwd:/i)) { |
| 815 |
subject = 'Fwd: ' + subject; |
| 816 |
} |
| 817 |
|
| 818 |
|
| 819 |
const date = new Date(email.receivedAt).toLocaleString(); |
| 820 |
const body = '\n\n---------- Forwarded message ----------\n' |
| 821 |
+ 'From: ' + email.from + '\n' |
| 822 |
+ 'Date: ' + date + '\n' |
| 823 |
+ 'Subject: ' + (email.subject || '') + '\n' |
| 824 |
+ 'To: ' + (email.to || '') + '\n\n' |
| 825 |
+ (email.body || ''); |
| 826 |
|
| 827 |
if (GoingsOn.touch?.isTouchDevice) { |
| 828 |
openComposeModal({ to: '', subject, body, accountId }); |
| 829 |
} else { |
| 830 |
await GoingsOn.api.window.openCompose({ |
| 831 |
to: '', |
| 832 |
subject, |
| 833 |
body, |
| 834 |
accountId, |
| 835 |
}); |
| 836 |
} |
| 837 |
} catch (err) { |
| 838 |
GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to forward email'), 'error'); |
| 839 |
} |
| 840 |
} |
| 841 |
|
| 842 |
|
| 843 |
* Open an email attachment blob with the system default app. |
| 844 |
* @param {string} blobHash - SHA-256 hash of the blob |
| 845 |
* @param {string} filename - Original filename |
| 846 |
|
| 847 |
async function openBlob(blobHash, filename) { |
| 848 |
try { |
| 849 |
await GoingsOn.api.attachments.openEmailBlob(blobHash, filename); |
| 850 |
} catch (err) { |
| 851 |
GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to open attachment'), 'error'); |
| 852 |
} |
| 853 |
} |
| 854 |
|
| 855 |
|
| 856 |
* Save an email attachment blob to a user-chosen location. |
| 857 |
* @param {string} blobHash - SHA-256 hash of the blob |
| 858 |
* @param {string} filename - Default filename for save dialog |
| 859 |
|
| 860 |
async function saveBlob(blobHash, filename) { |
| 861 |
try { |
| 862 |
const { save } = window.__TAURI__.dialog; |
| 863 |
const destination = await save({ |
| 864 |
defaultPath: filename, |
| 865 |
title: 'Save attachment as', |
| 866 |
}); |
| 867 |
if (!destination) return; |
| 868 |
|
| 869 |
await GoingsOn.api.attachments.saveEmailBlob(blobHash, destination); |
| 870 |
GoingsOn.ui.showToast('File saved!', 'success'); |
| 871 |
} catch (err) { |
| 872 |
if (err && err.toString().includes('cancelled')) return; |
| 873 |
GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to save attachment'), 'error'); |
| 874 |
} |
| 875 |
} |
| 876 |
|
| 877 |
|
| 878 |
* Extract bare email address from "Name <email>" format. |
| 879 |
|
| 880 |
function extractEmail(addr) { |
| 881 |
if (!addr) return ''; |
| 882 |
const match = addr.match(/<([^>]+)>/); |
| 883 |
return match ? match[1] : addr.trim(); |
| 884 |
} |
| 885 |
|
| 886 |
|
| 887 |
* Render email HTML to a temp file and open in the system browser. |
| 888 |
* @param {string} emailId - Email ID to open |
| 889 |
|
| 890 |
async function openInBrowser(emailId) { |
| 891 |
try { |
| 892 |
await GoingsOn.api.window.openEmailInBrowser(emailId); |
| 893 |
} catch (err) { |
| 894 |
GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to open email in browser'), 'error'); |
| 895 |
} |
| 896 |
} |
| 897 |
|
| 898 |
|
| 899 |
|
| 900 |
|
| 901 |
|
| 902 |
|
| 903 |
|
| 904 |
|
| 905 |
|
| 906 |
* Load and display drafts in a modal. |
| 907 |
|
| 908 |
async function openDraftsModal() { |
| 909 |
try { |
| 910 |
const drafts = await GoingsOn.api.emails.listDrafts(); |
| 911 |
|
| 912 |
if (drafts.length === 0) { |
| 913 |
GoingsOn.ui.openModal('Drafts', '<div class="empty-state empty-state--compact"><p class="empty-state-text">No drafts</p></div>'); |
| 914 |
return; |
| 915 |
} |
| 916 |
|
| 917 |
const listHtml = drafts.map(d => { |
| 918 |
const to = d.to || '(no recipient)'; |
| 919 |
const subject = d.subject || '(no subject)'; |
| 920 |
return ` |
| 921 |
<div class="email-item email-draft-item" |
| 922 |
onclick="GoingsOn.emails.openDraft('${escAttr(d.id)}')"> |
| 923 |
<div class="email-draft-subject">${esc(subject)}</div> |
| 924 |
<div class="email-draft-meta">To: ${esc(to)} · ${d.receivedFormatted}</div> |
| 925 |
</div> |
| 926 |
`; |
| 927 |
}).join(''); |
| 928 |
|
| 929 |
GoingsOn.ui.openModal('Drafts', `<div>${listHtml}</div>`); |
| 930 |
} catch (err) { |
| 931 |
GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load drafts'), 'error'); |
| 932 |
} |
| 933 |
} |
| 934 |
|
| 935 |
|
| 936 |
* Open a draft for editing in the compose window. |
| 937 |
* @param {string} id - Draft email ID |
| 938 |
|
| 939 |
async function openDraft(id) { |
| 940 |
GoingsOn.ui.closeModal(); |
| 941 |
if (GoingsOn.touch?.isTouchDevice) { |
| 942 |
|
| 943 |
try { |
| 944 |
const draft = await GoingsOn.api.emails.get(id); |
| 945 |
if (!draft) return; |
| 946 |
openComposeModal({ |
| 947 |
to: draft.to || '', |
| 948 |
cc: draft.ccAddress || '', |
| 949 |
bcc: draft.bccAddress || '', |
| 950 |
subject: draft.subject || '', |
| 951 |
body: draft.body || '', |
| 952 |
accountId: draft.draftAccountId || '', |
| 953 |
inReplyTo: draft.inReplyTo || null, |
| 954 |
threadId: draft.threadId || null, |
| 955 |
draftId: draft.id, |
| 956 |
}); |
| 957 |
} catch (err) { |
| 958 |
GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to open draft'), 'error'); |
| 959 |
} |
| 960 |
} else { |
| 961 |
try { |
| 962 |
await GoingsOn.api.window.openCompose({ draftId: id }); |
| 963 |
} catch (err) { |
| 964 |
GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to open draft'), 'error'); |
| 965 |
} |
| 966 |
} |
| 967 |
} |
| 968 |
|
| 969 |
|
| 970 |
* Send a draft directly. |
| 971 |
* @param {string} id - Draft email ID |
| 972 |
|
| 973 |
async function sendDraft(id) { |
| 974 |
try { |
| 975 |
await GoingsOn.api.emails.sendDraft(id); |
| 976 |
GoingsOn.ui.showToast('Draft sent!', 'success'); |
| 977 |
GoingsOn.ui.closeModal(); |
| 978 |
GoingsOn.cache.invalidate('emails'); |
| 979 |
load(); |
| 980 |
} catch (err) { |
| 981 |
GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to send draft'), 'error'); |
| 982 |
} |
| 983 |
} |
| 984 |
|
| 985 |
|
| 986 |
|
| 987 |
let activeFolder = ''; |
| 988 |
let activeLabel = ''; |
| 989 |
|
| 990 |
async function loadFilters() { |
| 991 |
try { |
| 992 |
const [folders, labels] = await Promise.all([ |
| 993 |
GoingsOn.api.emails.listFolders(), |
| 994 |
GoingsOn.api.emails.listLabels(), |
| 995 |
]); |
| 996 |
|
| 997 |
const folderSelect = document.getElementById('email-folder-filter'); |
| 998 |
if (folderSelect) { |
| 999 |
const current = folderSelect.value; |
| 1000 |
folderSelect.innerHTML = '<option value="">All folders</option>' + |
| 1001 |
folders.map(f => `<option value="${escAttr(f)}" ${f === current ? 'selected' : ''}>${esc(f)}</option>`).join(''); |
| 1002 |
} |
| 1003 |
|
| 1004 |
const labelSelect = document.getElementById('email-label-filter'); |
| 1005 |
if (labelSelect) { |
| 1006 |
const current = labelSelect.value; |
| 1007 |
labelSelect.innerHTML = '<option value="">All labels</option>' + |
| 1008 |
labels.map(l => `<option value="${escAttr(l)}" ${l === current ? 'selected' : ''}>${esc(l)}</option>`).join(''); |
| 1009 |
} |
| 1010 |
} catch (_) { } |
| 1011 |
} |
| 1012 |
|
| 1013 |
function filterByFolder(folder) { |
| 1014 |
activeFolder = folder; |
| 1015 |
GoingsOn.queryState?.write('folder', folder); |
| 1016 |
clearSelectionIfAny(); |
| 1017 |
GoingsOn.cache.invalidate('emails'); |
| 1018 |
load(); |
| 1019 |
} |
| 1020 |
|
| 1021 |
function filterByLabel(label) { |
| 1022 |
activeLabel = label; |
| 1023 |
GoingsOn.queryState?.write('label', label); |
| 1024 |
clearSelectionIfAny(); |
| 1025 |
GoingsOn.cache.invalidate('emails'); |
| 1026 |
load(); |
| 1027 |
} |
| 1028 |
|
| 1029 |
|
| 1030 |
* Phase 7 Tier 4 — restore folder / label / search from URL on init. |
| 1031 |
* Called once at first load; subsequent filter changes write back. |
| 1032 |
|
| 1033 |
function restoreFiltersFromUrl() { |
| 1034 |
if (!GoingsOn.queryState) return; |
| 1035 |
const q = GoingsOn.queryState.readMany(['folder', 'label', 'q']); |
| 1036 |
if (q.folder) { |
| 1037 |
activeFolder = q.folder; |
| 1038 |
const sel = document.getElementById('email-folder-filter'); |
| 1039 |
if (sel) sel.value = q.folder; |
| 1040 |
} |
| 1041 |
if (q.label) { |
| 1042 |
activeLabel = q.label; |
| 1043 |
const sel = document.getElementById('email-label-filter'); |
| 1044 |
if (sel) sel.value = q.label; |
| 1045 |
} |
| 1046 |
if (q.q) { |
| 1047 |
const input = document.getElementById('email-search'); |
| 1048 |
if (input) input.value = q.q; |
| 1049 |
} |
| 1050 |
} |
| 1051 |
|
| 1052 |
|
| 1053 |
|
| 1054 |
function clearSelectionIfAny() { |
| 1055 |
if (selectedEmailIds.size > 0) { |
| 1056 |
emailSelection.clear(); |
| 1057 |
GoingsOn.bulk?.updateBar?.(); |
| 1058 |
} |
| 1059 |
} |
| 1060 |
|
| 1061 |
|
| 1062 |
* Open a modal to edit labels on an email. |
| 1063 |
* @param {string} emailId - Email ID |
| 1064 |
* @param {string[]} currentLabels - Current labels |
| 1065 |
|
| 1066 |
async function editLabels(emailId, currentLabels) { |
| 1067 |
const existing = await GoingsOn.api.emails.listLabels(); |
| 1068 |
const content = ` |
| 1069 |
<form id="label-form" onsubmit="event.preventDefault(); GoingsOn.emails._saveLabels('${escAttr(emailId)}');"> |
| 1070 |
<div class="form-group"> |
| 1071 |
<label class="form-label">Labels (comma-separated)</label> |
| 1072 |
<input type="text" class="form-input" id="label-input" value="${escAttr((currentLabels || []).join(', '))}" |
| 1073 |
placeholder="work, important, follow-up" autofocus> |
| 1074 |
${existing.length > 0 ? `<div class="label-existing-line">Existing: ${existing.map(l => esc(l)).join(', ')}</div>` : ''} |
| 1075 |
</div> |
| 1076 |
<div class="form-actions"> |
| 1077 |
<button type="button" class="btn btn-secondary" onclick="GoingsOn.ui.closeModal()">Cancel</button> |
| 1078 |
<button type="submit" class="btn btn-primary">Save</button> |
| 1079 |
</div> |
| 1080 |
</form> |
| 1081 |
`; |
| 1082 |
GoingsOn.ui.openModal('Edit Labels', content); |
| 1083 |
} |
| 1084 |
|
| 1085 |
async function saveLabels(emailId) { |
| 1086 |
const input = document.getElementById('label-input'); |
| 1087 |
const labels = input.value.split(',').map(s => s.trim()).filter(Boolean); |
| 1088 |
try { |
| 1089 |
await GoingsOn.api.emails.setLabels(emailId, labels); |
| 1090 |
GoingsOn.ui.showToast('Labels updated!', 'success'); |
| 1091 |
GoingsOn.ui.closeModal(); |
| 1092 |
GoingsOn.cache.invalidate('emails'); |
| 1093 |
load(); |
| 1094 |
loadFilters(); |
| 1095 |
} catch (err) { |
| 1096 |
GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to update labels'), 'error'); |
| 1097 |
} |
| 1098 |
} |
| 1099 |
|
| 1100 |
|
| 1101 |
* Move an email to a different folder. |
| 1102 |
* @param {string} emailId - Email ID |
| 1103 |
|
| 1104 |
async function moveToFolder(emailId) { |
| 1105 |
try { |
| 1106 |
const folders = await GoingsOn.api.emails.listFolders(); |
| 1107 |
|
| 1108 |
const content = ` |
| 1109 |
<form onsubmit="event.preventDefault(); GoingsOn.emails._doMoveToFolder('${escAttr(emailId)}');"> |
| 1110 |
<div class="form-group"> |
| 1111 |
<label class="form-label">Move to folder</label> |
| 1112 |
<input type="text" class="form-input" id="move-folder-input" placeholder="INBOX, Archive, Sent, ..." |
| 1113 |
list="folder-suggestions" autofocus> |
| 1114 |
<datalist id="folder-suggestions"> |
| 1115 |
${folders.map(f => `<option value="${escAttr(f)}">`).join('')} |
| 1116 |
</datalist> |
| 1117 |
</div> |
| 1118 |
<div class="form-actions"> |
| 1119 |
<button type="button" class="btn btn-secondary" onclick="GoingsOn.ui.closeModal()">Cancel</button> |
| 1120 |
<button type="submit" class="btn btn-primary">Move</button> |
| 1121 |
</div> |
| 1122 |
</form> |
| 1123 |
`; |
| 1124 |
GoingsOn.ui.openModal('Move to Folder', content); |
| 1125 |
} catch (err) { |
| 1126 |
GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load folders'), 'error'); |
| 1127 |
} |
| 1128 |
} |
| 1129 |
|
| 1130 |
async function doMoveToFolder(emailId) { |
| 1131 |
const input = document.getElementById('move-folder-input'); |
| 1132 |
const folder = input.value.trim(); |
| 1133 |
if (!folder) return; |
| 1134 |
try { |
| 1135 |
await GoingsOn.api.emails.moveToFolder(emailId, folder); |
| 1136 |
GoingsOn.ui.showToast(`Moved to ${folder}`, 'success'); |
| 1137 |
GoingsOn.ui.closeModal(); |
| 1138 |
GoingsOn.cache.invalidate('emails'); |
| 1139 |
load(); |
| 1140 |
loadFilters(); |
| 1141 |
} catch (err) { |
| 1142 |
GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to move email'), 'error'); |
| 1143 |
} |
| 1144 |
} |
| 1145 |
|
| 1146 |
|
| 1147 |
|
| 1148 |
let searchDebounceTimer = null; |
| 1149 |
|
| 1150 |
|
| 1151 |
* Search emails using FTS5 backend. |
| 1152 |
* @param {string} query - Search query text |
| 1153 |
|
| 1154 |
function searchEmails(query) { |
| 1155 |
clearTimeout(searchDebounceTimer); |
| 1156 |
const trimmed = (query || '').trim(); |
| 1157 |
clearSelectionIfAny(); |
| 1158 |
GoingsOn.queryState?.write('q', trimmed); |
| 1159 |
|
| 1160 |
if (!trimmed) { |
| 1161 |
|
| 1162 |
GoingsOn.cache.invalidate('emails'); |
| 1163 |
load(); |
| 1164 |
return; |
| 1165 |
} |
| 1166 |
|
| 1167 |
searchDebounceTimer = setTimeout(async () => { |
| 1168 |
try { |
| 1169 |
const response = await GoingsOn.api.search.query({ |
| 1170 |
query: trimmed, |
| 1171 |
type: 'email', |
| 1172 |
limit: 100, |
| 1173 |
}); |
| 1174 |
|
| 1175 |
const container = document.getElementById('email-list'); |
| 1176 |
|
| 1177 |
if (response.results.length === 0) { |
| 1178 |
if (emailScroller) { emailScroller.destroy(); emailScroller = null; } |
| 1179 |
container.innerHTML = `<div class="loading search-no-results">No emails matching "${esc(trimmed)}"</div>`; |
| 1180 |
return; |
| 1181 |
} |
| 1182 |
|
| 1183 |
|
| 1184 |
const emailIds = new Set(response.results.map(r => r.id)); |
| 1185 |
const allThreads = GoingsOn.state.emailThreads || []; |
| 1186 |
const matchingThreads = allThreads.filter(t => emailIds.has(t.mostRecentEmail.id)); |
| 1187 |
|
| 1188 |
|
| 1189 |
if (matchingThreads.length > 0) { |
| 1190 |
GoingsOn.state.set('emailThreads', matchingThreads); |
| 1191 |
if (emailScroller) { |
| 1192 |
emailScroller.refresh(); |
| 1193 |
} else { |
| 1194 |
emailScroller = new GoingsOn.VirtualScroller({ |
| 1195 |
container: container, |
| 1196 |
renderItem: renderEmailItem, |
| 1197 |
getItems: () => GoingsOn.state.emailThreads, |
| 1198 |
rowHeight: { estimated: 90, measure: true }, |
| 1199 |
overscan: 5, |
| 1200 |
}); |
| 1201 |
} |
| 1202 |
} else { |
| 1203 |
|
| 1204 |
if (emailScroller) { emailScroller.destroy(); emailScroller = null; } |
| 1205 |
container.innerHTML = response.results.map(r => ` |
| 1206 |
<div class="email-item" data-id="${escAttr(r.id)}" |
| 1207 |
onclick="GoingsOn.emails.open('${escAttr(r.id)}')" |
| 1208 |
tabindex="0" role="listitem"> |
| 1209 |
<div class="email-content"> |
| 1210 |
<div class="email-header"> |
| 1211 |
<span class="email-subject">${esc(r.title)}</span> |
| 1212 |
</div> |
| 1213 |
${r.snippet ? `<div class="email-preview">${esc(r.snippet)}</div>` : ''} |
| 1214 |
</div> |
| 1215 |
</div> |
| 1216 |
`).join(''); |
| 1217 |
} |
| 1218 |
} catch (err) { |
| 1219 |
console.error('Email search failed:', err); |
| 1220 |
} |
| 1221 |
}, 250); |
| 1222 |
} |
| 1223 |
|
| 1224 |
|
| 1225 |
|
| 1226 |
|
| 1227 |
function goToPage(direction) { |
| 1228 |
emailPagination.goToPage(direction); |
| 1229 |
load(); |
| 1230 |
} |
| 1231 |
|
| 1232 |
|
| 1233 |
|
| 1234 |
function toggleSelection(id, checkbox, event) { |
| 1235 |
emailSelection.toggle(id, checkbox, event); |
| 1236 |
} |
| 1237 |
|
| 1238 |
function selectAll() { |
| 1239 |
emailSelection.selectAll(); |
| 1240 |
} |
| 1241 |
|
| 1242 |
function getSelected() { |
| 1243 |
return emailSelection.getSelected(); |
| 1244 |
} |
| 1245 |
|
| 1246 |
function clearSelected() { |
| 1247 |
emailSelection.clear(); |
| 1248 |
} |
| 1249 |
|
| 1250 |
|
| 1251 |
* Update the "N emails" count chip. Note: total is at thread granularity, |
| 1252 |
* shown is also at thread granularity, since the filter bar describes the |
| 1253 |
* visible list which renders one row per thread. |
| 1254 |
|
| 1255 |
function _updateEmailCount(total, shown) { |
| 1256 |
const el = document.getElementById('email-count'); |
| 1257 |
if (!el) return; |
| 1258 |
if (typeof total !== 'number' || total < 0) { |
| 1259 |
el.textContent = ''; |
| 1260 |
el.classList.remove('filter-count--capped'); |
| 1261 |
return; |
| 1262 |
} |
| 1263 |
const noun = total === 1 ? 'thread' : 'threads'; |
| 1264 |
if (shown < total) { |
| 1265 |
el.textContent = `${shown} of ${total} ${noun} — narrow with filters`; |
| 1266 |
el.classList.add('filter-count--capped'); |
| 1267 |
} else { |
| 1268 |
el.textContent = `${total} ${noun}`; |
| 1269 |
el.classList.remove('filter-count--capped'); |
| 1270 |
} |
| 1271 |
} |
| 1272 |
|
| 1273 |
|
| 1274 |
|
| 1275 |
|
| 1276 |
* Build a compose prefill object from a queued SendInput. |
| 1277 |
* Note: attachmentPaths are NOT preserved through compose re-open today |
| 1278 |
* (the compose UI uses a fresh file picker). When undo restores a message |
| 1279 |
* that had attachments, surface a notice so the user re-attaches. |
| 1280 |
|
| 1281 |
function _sendInputToComposeParams(input) { |
| 1282 |
return { |
| 1283 |
to: input.to || input.toAddress || '', |
| 1284 |
subject: input.subject || '', |
| 1285 |
body: input.body || '', |
| 1286 |
accountId: input.accountId || null, |
| 1287 |
inReplyTo: input.inReplyTo || null, |
| 1288 |
references: input.references || null, |
| 1289 |
threadId: input.threadId || null, |
| 1290 |
}; |
| 1291 |
} |
| 1292 |
|
| 1293 |
|
| 1294 |
* Queue an email send with an undo window. Returns immediately. The actual |
| 1295 |
* `api.emails.send` call fires when the undo window expires; if the user |
| 1296 |
* clicks Undo, the compose surface re-opens with the message restored. |
| 1297 |
* |
| 1298 |
* Charter rule: every irreversible operation gets an undo path (Phase 7 |
| 1299 |
* Tier 1 #3 / Phase 3 #1). |
| 1300 |
* |
| 1301 |
* @param {Object} cfg |
| 1302 |
* @param {Object} cfg.input - SendInput (accountId / to / subject / body / inReplyTo / references / threadId / attachmentPaths) |
| 1303 |
* @param {number} [cfg.delaySeconds=5] - Undo window in seconds. |
| 1304 |
|
| 1305 |
function queueSend({ input, delaySeconds = 5 }) { |
| 1306 |
const hadAttachments = Array.isArray(input.attachmentPaths) && input.attachmentPaths.length > 0; |
| 1307 |
const timeout = Math.max(1000, delaySeconds * 1000); |
| 1308 |
const message = input.inReplyTo ? 'Sending reply…' : 'Sending email…'; |
| 1309 |
|
| 1310 |
GoingsOn.ui.showUndoToast(message, { |
| 1311 |
timeout, |
| 1312 |
onConfirm: async () => { |
| 1313 |
try { |
| 1314 |
const result = await GoingsOn.api.emails.send(input); |
| 1315 |
GoingsOn.ui.showToast('Email sent', 'success'); |
| 1316 |
load(); |
| 1317 |
|
| 1318 |
if (result?.newImplicitContacts?.length > 0) { |
| 1319 |
GoingsOn.autocomplete?.refresh?.(); |
| 1320 |
for (const c of result.newImplicitContacts) { |
| 1321 |
const name = c.displayName || c.display_name || ''; |
| 1322 |
GoingsOn.ui.showToast(`Save ${name} as a contact?`, 'info', { |
| 1323 |
duration: 8000, |
| 1324 |
action: { |
| 1325 |
label: 'Save', |
| 1326 |
fn: async () => { |
| 1327 |
try { |
| 1328 |
await GoingsOn.api.contacts.promoteContact(c.id); |
| 1329 |
GoingsOn.cache.invalidate('contacts'); |
| 1330 |
GoingsOn.autocomplete?.refresh?.(); |
| 1331 |
GoingsOn.ui.showToast(`${name} saved as contact`, 'success'); |
| 1332 |
} catch (err) { |
| 1333 |
GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to save contact'), 'error'); |
| 1334 |
} |
| 1335 |
}, |
| 1336 |
}, |
| 1337 |
}); |
| 1338 |
} |
| 1339 |
} |
| 1340 |
} catch (err) { |
| 1341 |
|
| 1342 |
GoingsOn.ui.showToast( |
| 1343 |
'Send failed: ' + GoingsOn.utils.getErrorMessage(err), |
| 1344 |
'error', |
| 1345 |
{ |
| 1346 |
duration: 12000, |
| 1347 |
action: { |
| 1348 |
label: 'Edit & retry', |
| 1349 |
fn: () => { |
| 1350 |
const prefill = _sendInputToComposeParams(input); |
| 1351 |
if (GoingsOn.touch?.isTouchDevice) { |
| 1352 |
openComposeModal(prefill); |
| 1353 |
} else { |
| 1354 |
GoingsOn.api.window.openCompose(prefill).catch(() => openComposeModal(prefill)); |
| 1355 |
} |
| 1356 |
}, |
| 1357 |
}, |
| 1358 |
} |
| 1359 |
); |
| 1360 |
} |
| 1361 |
}, |
| 1362 |
onUndo: () => { |
| 1363 |
const prefill = _sendInputToComposeParams(input); |
| 1364 |
if (GoingsOn.touch?.isTouchDevice) { |
| 1365 |
openComposeModal(prefill); |
| 1366 |
} else { |
| 1367 |
GoingsOn.api.window.openCompose(prefill).catch(() => openComposeModal(prefill)); |
| 1368 |
} |
| 1369 |
if (hadAttachments) { |
| 1370 |
GoingsOn.ui.showToast('Re-attach your files — attachments cleared on undo.', 'info', { duration: 8000 }); |
| 1371 |
} |
| 1372 |
}, |
| 1373 |
}); |
| 1374 |
} |
| 1375 |
|
| 1376 |
|
| 1377 |
|
| 1378 |
GoingsOn.emails = { |
| 1379 |
load, |
| 1380 |
openCompose, |
| 1381 |
openComposeModal, |
| 1382 |
markAllRead, |
| 1383 |
open, |
| 1384 |
delete: deleteEmail, |
| 1385 |
archive, |
| 1386 |
unarchive, |
| 1387 |
markRead, |
| 1388 |
markUnread, |
| 1389 |
createTaskFromEmail, |
| 1390 |
createEventFromEmail, |
| 1391 |
createContactFromSender, |
| 1392 |
openInBrowser, |
| 1393 |
reply: (id) => openReply(id, false), |
| 1394 |
replyAll: (id) => openReply(id, true), |
| 1395 |
forward: openForward, |
| 1396 |
openBlob, |
| 1397 |
saveBlob, |
| 1398 |
search: searchEmails, |
| 1399 |
filterByFolder, |
| 1400 |
filterByLabel, |
| 1401 |
editLabels, |
| 1402 |
_saveLabels: saveLabels, |
| 1403 |
moveToFolder, |
| 1404 |
_doMoveToFolder: doMoveToFolder, |
| 1405 |
openDrafts: openDraftsModal, |
| 1406 |
openDraft, |
| 1407 |
sendDraft, |
| 1408 |
queueSend, |
| 1409 |
|
| 1410 |
goToPage, |
| 1411 |
toggleSelection, |
| 1412 |
selectAll, |
| 1413 |
getSelected, |
| 1414 |
clearSelected, |
| 1415 |
|
| 1416 |
selection: emailSelection, |
| 1417 |
pagination: emailPagination, |
| 1418 |
|
| 1419 |
renderEmailItem, |
| 1420 |
getScroller: () => emailScroller, |
| 1421 |
}; |
| 1422 |
|
| 1423 |
})(); |
| 1424 |
|