diff options
| author | Bobby <[email protected]> | 2025-12-24 17:17:15 +0530 |
|---|---|---|
| committer | Bobby <[email protected]> | 2025-12-24 17:17:15 +0530 |
| commit | d5ea2aa824eee4b7e2d169d21da0107d057e7bc6 (patch) | |
| tree | e608fea8cf91d6915b7b6ce5eb46896dbdc2ad79 /static/js | |
| parent | b77d75f05fb2059389c05f6c01484e0cd12e796e (diff) | |
| download | lain-d5ea2aa824eee4b7e2d169d21da0107d057e7bc6.tar.xz lain-d5ea2aa824eee4b7e2d169d21da0107d057e7bc6.zip | |
feat: Implement API endpoints for email details and actions, and refactor email preview for client-side rendering with Shadow DOM.
Diffstat (limited to 'static/js')
| -rw-r--r-- | static/js/dropdown.js | 30 | ||||
| -rw-r--r-- | static/js/filters.js | 202 | ||||
| -rw-r--r-- | static/js/mail.js | 323 | ||||
| -rw-r--r-- | static/js/shadow.js | 63 |
4 files changed, 506 insertions, 112 deletions
diff --git a/static/js/dropdown.js b/static/js/dropdown.js new file mode 100644 index 0000000..00b7ee6 --- /dev/null +++ b/static/js/dropdown.js @@ -0,0 +1,30 @@ +document.addEventListener('DOMContentLoaded', function () { + // Handle dropdown clicks + document.querySelectorAll('.options-subitem > a').forEach(function (item) { + item.addEventListener('click', function (e) { + e.preventDefault(); + const parent = this.parentElement; + + if (parent.classList.contains('disabled')) { + return; + } + + document.querySelectorAll('.options-subitem.open').forEach(function (other) { + if (other !== parent) { + other.classList.remove('open'); + } + }); + + parent.classList.toggle('open'); + }); + }); + + // Close dropdowns when clicking outside + document.addEventListener('click', function (e) { + if (!e.target.closest('.options-subitem')) { + document.querySelectorAll('.options-subitem.open').forEach(function (item) { + item.classList.remove('open'); + }); + } + }); +});
\ No newline at end of file diff --git a/static/js/filters.js b/static/js/filters.js index db48a4b..2506a1b 100644 --- a/static/js/filters.js +++ b/static/js/filters.js @@ -1,8 +1,23 @@ document.addEventListener('DOMContentLoaded', function () { const tagInputs = { - 'from': { input: document.getElementById('from-input'), tags: document.getElementById('from-tags'), hidden: document.getElementById('from-hidden'), values: [] }, - 'to': { input: document.getElementById('to-input'), tags: document.getElementById('to-tags'), hidden: document.getElementById('to-hidden'), values: [] }, - 'filename': { input: document.getElementById('filename-input'), tags: document.getElementById('filename-tags'), hidden: document.getElementById('filename-hidden'), values: [] } + 'from': { + input: document.getElementById('from-input'), + tags: document.getElementById('from-tags'), + hidden: document.getElementById('from-hidden'), + values: [] + }, + 'to': { + input: document.getElementById('to-input'), + tags: document.getElementById('to-tags'), + hidden: document.getElementById('to-hidden'), + values: [] + }, + 'filename': { + input: document.getElementById('filename-input'), + tags: document.getElementById('filename-tags'), + hidden: document.getElementById('filename-hidden'), + values: [] + } }; const autocompleteDropdown = document.getElementById('autocomplete-dropdown'); @@ -10,43 +25,45 @@ document.addEventListener('DOMContentLoaded', function () { let autocompleteResults = []; let selectedIndex = -1; + // Initialize tag inputs Object.keys(tagInputs).forEach(key => { const config = tagInputs[key]; - if (!config.input) return; - config.input.addEventListener('keydown', function (e) { - if (e.key === 'Enter' || e.key === ',') { - e.preventDefault(); - const value = this.value.trim(); - if (value && !config.values.includes(value)) { - addTag(key, value); - this.value = ''; - } - } else if (e.key === 'Backspace' && this.value === '' && config.values.length > 0) { - removeTag(key, config.values.length - 1); - } else if (e.key === 'ArrowDown') { - e.preventDefault(); - navigateAutocomplete(1); - } else if (e.key === 'ArrowUp') { - e.preventDefault(); - navigateAutocomplete(-1); - } - }); + config.input.addEventListener('keydown', handleTagInput.bind(null, key)); + config.input.addEventListener('input', handleAutocomplete.bind(null, key)); + config.input.addEventListener('blur', () => setTimeout(hideAutocomplete, 200)); + }); + + function handleTagInput(type, e) { + const config = tagInputs[type]; - config.input.addEventListener('input', function () { - if (this.value.length >= 2) { - activeTagInput = key; - showAutocomplete(this, this.value); - } else { - hideAutocomplete(); + if (e.key === 'Enter' || e.key === ',') { + e.preventDefault(); + const value = e.target.value.trim(); + if (value && !config.values.includes(value)) { + addTag(type, value); + e.target.value = ''; } - }); + } else if (e.key === 'Backspace' && e.target.value === '' && config.values.length > 0) { + removeTag(type, config.values.length - 1); + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + navigateAutocomplete(1); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + navigateAutocomplete(-1); + } + } - config.input.addEventListener('blur', function () { - setTimeout(() => hideAutocomplete(), 200); - }); - }); + function handleAutocomplete(type, e) { + if (e.target.value.length >= 2) { + activeTagInput = type; + showAutocomplete(e.target, e.target.value); + } else { + hideAutocomplete(); + } + } function addTag(type, value) { const config = tagInputs[type]; @@ -54,15 +71,21 @@ document.addEventListener('DOMContentLoaded', function () { const tag = document.createElement('div'); tag.className = 'tag'; - tag.innerHTML = ` - <span>${value}</span> - <button type="button" class="tag-remove" data-index="${config.values.length - 1}">×</button> - `; - tag.querySelector('.tag-remove').addEventListener('click', function () { + const span = document.createElement('span'); + span.textContent = value; + + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'tag-remove'; + button.dataset.index = config.values.length - 1; + button.textContent = '×'; + button.addEventListener('click', function () { removeTag(type, parseInt(this.dataset.index)); }); + tag.appendChild(span); + tag.appendChild(button); config.tags.appendChild(tag); config.hidden.value = config.values.join(','); } @@ -78,21 +101,14 @@ document.addEventListener('DOMContentLoaded', function () { const config = tagInputs[type]; config.tags.innerHTML = ''; config.values.forEach((value, index) => { - const tag = document.createElement('div'); - tag.className = 'tag'; - tag.innerHTML = ` - <span>${value}</span> - <button type="button" class="tag-remove" data-index="${index}">×</button> - `; - tag.querySelector('.tag-remove').addEventListener('click', function () { - removeTag(type, index); - }); - config.tags.appendChild(tag); + addTag(type, value); }); } function showAutocomplete(input, query) { - const suggestions = getSuggestions(query); + // TODO: Replace with actual API call to fetch contacts + const suggestions = []; + if (suggestions.length === 0) { hideAutocomplete(); return; @@ -105,15 +121,15 @@ document.addEventListener('DOMContentLoaded', function () { autocompleteDropdown.style.top = (rect.bottom + window.scrollY) + 'px'; autocompleteDropdown.style.left = rect.left + 'px'; autocompleteDropdown.style.width = rect.width + 'px'; - - autocompleteDropdown.innerHTML = suggestions.map((item, index) => - `<div class="autocomplete-item" data-index="${index}">${item}</div>` - ).join(''); - - autocompleteDropdown.querySelectorAll('.autocomplete-item').forEach(item => { - item.addEventListener('click', function () { - selectAutocomplete(parseInt(this.dataset.index)); - }); + autocompleteDropdown.innerHTML = ''; + + suggestions.forEach((item, index) => { + const div = document.createElement('div'); + div.className = 'autocomplete-item'; + div.dataset.index = index; + div.textContent = item; + div.addEventListener('click', () => selectAutocomplete(index)); + autocompleteDropdown.appendChild(div); }); autocompleteDropdown.style.display = 'block'; @@ -152,47 +168,11 @@ document.addEventListener('DOMContentLoaded', function () { } } - function getSuggestions(query) { - const mockSuggestions = [ - '[email protected]', - '[email protected]', - '[email protected]', - '[email protected]', - ]; - return mockSuggestions.filter(s => s.toLowerCase().includes(query.toLowerCase())); - } - - document.querySelectorAll('.options-subitem > a').forEach(function (item) { - item.addEventListener('click', function (e) { - e.preventDefault(); - const parent = this.parentElement; - - if (parent.classList.contains('disabled')) { - return; - } - - document.querySelectorAll('.options-subitem.open').forEach(function (other) { - if (other !== parent) { - other.classList.remove('open'); - } - }); - - parent.classList.toggle('open'); - }); - }); - - document.addEventListener('click', function (e) { - if (!e.target.closest('.options-subitem')) { - document.querySelectorAll('.options-subitem.open').forEach(function (item) { - item.classList.remove('open'); - }); - } - }); - + // Filter controls const toggleBtn = document.getElementById('toggle-filters'); const filters = document.getElementById('filters'); const closeBtn = document.getElementById('close-filters'); + const clearBtn = document.getElementById('clear-filters'); if (toggleBtn && filters) { toggleBtn.addEventListener('click', function (e) { @@ -202,12 +182,9 @@ document.addEventListener('DOMContentLoaded', function () { } if (closeBtn && filters) { - closeBtn.addEventListener('click', function () { - filters.style.display = 'none'; - }); + closeBtn.addEventListener('click', () => filters.style.display = 'none'); } - const clearBtn = document.getElementById('clear-filters'); if (clearBtn) { clearBtn.addEventListener('click', function () { Object.keys(tagInputs).forEach(key => { @@ -221,25 +198,17 @@ document.addEventListener('DOMContentLoaded', function () { }); } + // Date preset handling const datePreset = document.getElementById('date-preset'); const customDateRange = document.getElementById('custom-date-range'); + if (datePreset && customDateRange) { datePreset.addEventListener('change', function () { customDateRange.style.display = this.value === 'custom' ? 'block' : 'none'; }); } - document.addEventListener('click', function (e) { - const filters = document.getElementById('filters'); - const toggleBtn = document.getElementById('toggle-filters'); - - if (filters && toggleBtn) { - if (!filters.contains(e.target) && !toggleBtn.contains(e.target)) { - filters.style.display = 'none'; - } - } - }); - + // Scope handling const scopeSelect = document.querySelector('select[name="scope"]'); const customFoldersInput = document.getElementById('custom-folders-input'); @@ -248,4 +217,13 @@ document.addEventListener('DOMContentLoaded', function () { customFoldersInput.style.display = this.value === 'custom' ? 'block' : 'none'; }); } -}); + + // Close filters when clicking outside + document.addEventListener('click', function (e) { + if (filters && toggleBtn) { + if (!filters.contains(e.target) && !toggleBtn.contains(e.target)) { + filters.style.display = 'none'; + } + } + }); +});
\ No newline at end of file diff --git a/static/js/mail.js b/static/js/mail.js new file mode 100644 index 0000000..1622b37 --- /dev/null +++ b/static/js/mail.js @@ -0,0 +1,323 @@ +document.addEventListener('DOMContentLoaded', function () { + const emailRows = document.querySelectorAll('.email-row'); + const preview = document.querySelector('.preview'); + const prefsData = document.getElementById('mail-preferences'); + + let currentEmailId = null; + let markAsReadTimer = null; + + // Parse preferences from data attributes + const prefs = { + MarkMessagesAsRead: prefsData ? prefsData.dataset.markAsRead : 'Immediately', + ShowEmailAddressWithDisplayName: prefsData ? prefsData.dataset.showAddress === 'true' : true, + DisplayHTML: prefsData ? prefsData.dataset.displayHtml === 'true' : true, + LoadRemoteContent: prefsData ? prefsData.dataset.loadRemote : 'Never' + }; + + emailRows.forEach(row => { + row.addEventListener('click', async function (e) { + if (e.target.closest('.email-flag')) { + return; + } + + const emailId = this.dataset.emailId; + if (emailId === currentEmailId) { + return; + } + + emailRows.forEach(r => r.classList.remove('active')); + this.classList.add('active'); + + currentEmailId = emailId; + + // Clear previous timer + if (markAsReadTimer) { + clearTimeout(markAsReadTimer); + } + + try { + const response = await fetch(`/api/mail/email/${emailId}`); + if (!response.ok) throw new Error('Failed to fetch email'); + + const email = await response.json(); + renderEmail(email); + + // Handle mark as read based on preference + if (!email.IsRead) { + handleMarkAsRead(emailId, this); + } + } catch (error) { + console.error('Error fetching email:', error); + showError('Error loading email'); + } + }); + }); + + // Handle flag clicks + document.querySelectorAll('.email-flag').forEach(flag => { + flag.addEventListener('click', async function (e) { + e.stopPropagation(); + + const row = this.closest('.email-row'); + const emailId = row.dataset.emailId; + + try { + const response = await fetch(`/api/mail/email/${emailId}/flag`, { + method: 'POST' + }); + + if (!response.ok) throw new Error('Failed to toggle flag'); + + const data = await response.json(); + + if (data.flagged) { + this.classList.add('flagged'); + this.title = 'Unflag'; + } else { + this.classList.remove('flagged'); + this.title = 'Flag'; + } + } catch (error) { + console.error('Error toggling flag:', error); + } + }); + }); + + function handleMarkAsRead(emailId, row) { + const markOption = prefs.MarkMessagesAsRead || 'Immediately'; + + const delays = { + 'Never': null, + 'Immediately': 0, + 'After 5 Seconds': 5000, + 'After 10 Seconds': 10000, + 'After 30 Seconds': 30000, + 'After 1 Minute': 60000 + }; + + const delay = delays[markOption]; + + if (delay === null) { + return; // Never mark as read + } + + markAsReadTimer = setTimeout(async () => { + try { + const response = await fetch(`/api/mail/email/${emailId}/read`, { + method: 'POST' + }); + + if (response.ok) { + row.classList.remove('unread'); + } + } catch (error) { + console.error('Error marking as read:', error); + } + }, delay); + } + + function renderEmail(email) { + preview.innerHTML = ''; + + // Header + const header = createHeader(email); + preview.appendChild(header); + + // Sender info + const sender = createSenderInfo(email); + preview.appendChild(sender); + + // Recipients + const recipients = createRecipients(email); + preview.appendChild(recipients); + + // Attachments + if (email.Attachments && email.Attachments.length > 0) { + const attachments = createAttachments(email.Attachments); + preview.appendChild(attachments); + } + + // Body + const body = createBody(email); + preview.appendChild(body); + } + + function createHeader(email) { + const header = document.createElement('div'); + header.className = 'email-header'; + + const subject = document.createElement('h2'); + subject.className = 'email-subject'; + + if (email.Subject) { + subject.textContent = email.Subject; + } else { + const noSubject = document.createElement('span'); + noSubject.className = 'no-subject'; + noSubject.textContent = '[No Subject]'; + subject.appendChild(noSubject); + } + + const actions = document.createElement('div'); + actions.className = 'email-actions'; + + const actionButtons = [ + { title: 'Reply', symbol: '↶' }, + { title: 'Reply All', symbol: '⇄' }, + { title: 'Forward', symbol: '→' }, + { title: 'Archive', symbol: '▼' }, + { title: 'Delete', symbol: '×' } + ]; + + actionButtons.forEach(btn => { + const button = document.createElement('button'); + button.className = 'btn-icon'; + button.title = btn.title; + button.textContent = btn.symbol; + actions.appendChild(button); + }); + + header.appendChild(subject); + header.appendChild(actions); + + return header; + } + + function createSenderInfo(email) { + const sender = document.createElement('div'); + sender.className = 'email-sender'; + + const senderInfo = document.createElement('div'); + senderInfo.className = 'sender-info'; + + // Respect ShowEmailAddressWithDisplayName preference + const showAddress = prefs.ShowEmailAddressWithDisplayName; + + const strong = document.createElement('strong'); + strong.textContent = email.FromName || email.From; + senderInfo.appendChild(strong); + + if (showAddress && email.FromName) { + const address = document.createTextNode(` <${email.From}>`); + senderInfo.appendChild(address); + } + + const dateDiv = document.createElement('div'); + dateDiv.className = 'email-date'; + dateDiv.textContent = formatDate(email.Date); + + sender.appendChild(senderInfo); + sender.appendChild(dateDiv); + + return sender; + } + + function createRecipients(email) { + const recipients = document.createElement('div'); + recipients.className = 'email-recipients'; + + const toDiv = document.createElement('div'); + const toStrong = document.createElement('strong'); + toStrong.textContent = 'To: '; + toDiv.appendChild(toStrong); + toDiv.appendChild(document.createTextNode(email.To || '')); + recipients.appendChild(toDiv); + + if (email.CC) { + const ccDiv = document.createElement('div'); + const ccStrong = document.createElement('strong'); + ccStrong.textContent = 'Cc: '; + ccDiv.appendChild(ccStrong); + ccDiv.appendChild(document.createTextNode(email.CC)); + recipients.appendChild(ccDiv); + } + + return recipients; + } + + function createAttachments(attachments) { + const container = document.createElement('div'); + container.className = 'email-attachments'; + + const label = document.createElement('strong'); + label.textContent = 'Attachments: '; + container.appendChild(label); + + attachments.forEach(att => { + const link = document.createElement('a'); + link.href = `/api/mail/attachment/${att.ID}`; + link.className = 'attachment'; + link.download = att.Filename; + link.textContent = `${att.Filename} (${att.Size})`; + container.appendChild(link); + }); + + return container; + } + + function createBody(email) { + const body = document.createElement('div'); + body.className = 'email-body'; + + // Respect DisplayHTML preference + const displayHTML = prefs.DisplayHTML; + + if (displayHTML && email.Body) { + // Use ShadowRenderer library to encapsulate styles + const shadow = ShadowRenderer.render(body, email.Body); + + // Handle remote content based on LoadRemoteContent preference + handleRemoteContent(shadow); + } else { + const pre = document.createElement('pre'); + pre.textContent = email.Body || '[Empty Content]'; + body.appendChild(pre); + } + + return body; + } + + function handleRemoteContent(container) { + const loadOption = prefs.LoadRemoteContent || 'Never'; + + if (loadOption === 'Never') { + // Block all external images + const images = container.querySelectorAll('img'); + images.forEach(img => { + const src = img.getAttribute('src'); + if (src && src.startsWith('http')) { + img.removeAttribute('src'); + img.dataset.src = src; // Store original src + img.alt = '[Remote image blocked]'; + img.style.border = '1px dashed #ccc'; + img.style.padding = '5px'; + img.style.display = 'inline-block'; + } + }); + } + // TODO: Implement "From my contacts" check + // For "Always", images load normally + } + + function showError(message) { + preview.innerHTML = ''; + const error = document.createElement('div'); + error.className = 'no-email-selected'; + const p = document.createElement('p'); + p.textContent = message; + error.appendChild(p); + preview.appendChild(error); + } + + function formatDate(dateString) { + const date = new Date(dateString); + return date.toLocaleString('en-US', { + weekday: 'short', + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + } +});
\ No newline at end of file diff --git a/static/js/shadow.js b/static/js/shadow.js new file mode 100644 index 0000000..8439639 --- /dev/null +++ b/static/js/shadow.js @@ -0,0 +1,63 @@ +/* ShadowRenderer.js - A tiny library to render safely encapsulated HTML */ +const ShadowRenderer = { + render: function (hostElement, htmlContent, options = {}) { + // 1. Attach Shadow Root (if not exists) + let shadow = hostElement.shadowRoot; + if (!shadow) { + shadow = hostElement.attachShadow({ mode: 'open' }); + } + + // 2. Parse HTML + const parser = new DOMParser(); + const doc = parser.parseFromString(htmlContent, 'text/html'); + + // 3. Rewrite Styles (Body -> Wrapper) + const styles = doc.querySelectorAll('style'); + styles.forEach(style => { + // Replace 'body' selector with '.mail-body-content' + style.textContent = style.textContent.replace(/(^|[\}\s,;])body(?=[\s,\.\{])/gi, '$1.mail-body-content'); + }); + + // 4. Create Wrapper + const wrapper = document.createElement('div'); + wrapper.className = 'mail-body-content'; + + // Copy attributes & move children + if (doc.body) { + Array.from(doc.body.attributes).forEach(attr => { + if (attr.name === 'class') { + if (attr.value) wrapper.classList.add(...attr.value.split(' ')); + } else { + wrapper.setAttribute(attr.name, attr.value); + } + }); + // Handle legacy bgcolor if present + if (doc.body.bgColor) wrapper.style.backgroundColor = doc.body.bgColor; + + while (doc.body.firstChild) wrapper.appendChild(doc.body.firstChild); + } + + // 5. Build Shadow Content + shadow.innerHTML = ''; // Clear previous + + // Append Head Content (Styles) + if (doc.head) { + while (doc.head.firstChild) shadow.appendChild(doc.head.firstChild); + } + + // Append Body Wrapper + shadow.appendChild(wrapper); + + // 6. Apply Default Styles + const defaultStyle = document.createElement('style'); + defaultStyle.textContent = ` + :host { display: block; overflow: auto; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; } + .mail-body-content { margin: 0; padding: 16px; min-height: 100%; } + img { max-width: 100%; height: auto; } + a { color: #1a73e8; } + `; + shadow.prepend(defaultStyle); + + return shadow; + } +}; |
