From d5ea2aa824eee4b7e2d169d21da0107d057e7bc6 Mon Sep 17 00:00:00 2001
From: Bobby <30593201+luciferreeves@users.noreply.github.com>
Date: Wed, 24 Dec 2025 17:17:15 +0530
Subject: feat: Implement API endpoints for email details and actions, and
refactor email preview for client-side rendering with Shadow DOM.
---
static/js/dropdown.js | 30 +++++
static/js/filters.js | 202 ++++++++++++++-----------------
static/js/mail.js | 323 ++++++++++++++++++++++++++++++++++++++++++++++++++
static/js/shadow.js | 63 ++++++++++
4 files changed, 506 insertions(+), 112 deletions(-)
create mode 100644 static/js/dropdown.js
create mode 100644 static/js/mail.js
create mode 100644 static/js/shadow.js
(limited to 'static')
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 = `
- ${value}
-
- `;
- 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 = `
- ${value}
-
- `;
- 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) =>
- `
${item}
`
- ).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 = [
- 'john@example.com',
- 'jane@example.com',
- 'support@example.com',
- 'admin@example.com',
- 'info@example.com'
- ];
- 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;
+ }
+};
--
cgit v1.2.3