diff options
| -rw-r--r-- | controllers/api.go | 66 | ||||
| -rw-r--r-- | router/api.go | 16 | ||||
| -rw-r--r-- | services/emails.go | 110 | ||||
| -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 | ||||
| -rw-r--r-- | templates/layouts/mailbox.django | 12 | ||||
| -rw-r--r-- | templates/layouts/main.django | 55 | ||||
| -rw-r--r-- | templates/mail/folder.django | 11 | ||||
| -rw-r--r-- | templates/partials/preview.django | 52 | ||||
| -rw-r--r-- | utils/format/date.go | 5 | ||||
| -rw-r--r-- | utils/format/html.go | 59 | ||||
| -rw-r--r-- | utils/format/size.go | 16 |
14 files changed, 804 insertions, 216 deletions
diff --git a/controllers/api.go b/controllers/api.go new file mode 100644 index 0000000..d952f88 --- /dev/null +++ b/controllers/api.go @@ -0,0 +1,66 @@ +package controllers + +import ( + "lain/services" + "lain/session" + "strconv" + + "github.com/gofiber/fiber/v2" +) + +func GetEmailAPI(context *fiber.Ctx) error { + emailID, err := strconv.ParseUint(context.Params("id"), 10, 32) + if err != nil { + return context.Status(400).JSON(fiber.Map{"error": "Invalid email ID"}) + } + + userEmail, err := session.GetSessionEmail(context) + if err != nil { + return context.Status(401).JSON(fiber.Map{"error": "Unauthorized"}) + } + + email, err := services.GetEmailDetails(userEmail, uint(emailID)) + if err != nil { + return context.Status(404).JSON(fiber.Map{"error": "Email not found"}) + } + + return context.JSON(email) +} + +func ToggleFlagAPI(context *fiber.Ctx) error { + emailID, err := strconv.ParseUint(context.Params("id"), 10, 32) + if err != nil { + return context.Status(400).JSON(fiber.Map{"error": "Invalid email ID"}) + } + + userEmail, err := session.GetSessionEmail(context) + if err != nil { + return context.Status(401).JSON(fiber.Map{"error": "Unauthorized"}) + } + + isFlagged, err := services.ToggleEmailFlag(userEmail, uint(emailID)) + if err != nil { + return context.Status(500).JSON(fiber.Map{"error": "Failed to toggle flag"}) + } + + return context.JSON(fiber.Map{"flagged": isFlagged}) +} + +func MarkEmailAsReadAPI(context *fiber.Ctx) error { + emailID, err := strconv.ParseUint(context.Params("id"), 10, 32) + if err != nil { + return context.Status(400).JSON(fiber.Map{"error": "Invalid email ID"}) + } + + userEmail, err := session.GetSessionEmail(context) + if err != nil { + return context.Status(401).JSON(fiber.Map{"error": "Unauthorized"}) + } + + err = services.MarkEmailAsRead(userEmail, uint(emailID)) + if err != nil { + return context.Status(500).JSON(fiber.Map{"error": "Failed to mark as read"}) + } + + return context.JSON(fiber.Map{"success": true}) +} diff --git a/router/api.go b/router/api.go new file mode 100644 index 0000000..ce7b5bf --- /dev/null +++ b/router/api.go @@ -0,0 +1,16 @@ +package router + +import ( + "lain/controllers" + "lain/types" + "lain/utils/auth" + "lain/utils/urls" +) + +func init() { + urls.SetNamespace("api") + + urls.Path(types.GET, "/mail/email/:id", auth.RequireAuthentication(controllers.GetEmailAPI), "get_email") + urls.Path(types.POST, "/mail/email/:id/flag", auth.RequireAuthentication(controllers.ToggleFlagAPI), "toggle_flag") + urls.Path(types.POST, "/mail/email/:id/read", auth.RequireAuthentication(controllers.MarkEmailAsReadAPI), "mark_read") +} diff --git a/services/emails.go b/services/emails.go index e007f87..4b440f4 100644 --- a/services/emails.go +++ b/services/emails.go @@ -4,6 +4,8 @@ import ( "lain/jobs" "lain/models" "lain/repository" + "lain/utils/crypto" + "lain/utils/email" "lain/utils/format" "net/url" "strings" @@ -56,3 +58,111 @@ func GetEmails(userEmail, folderPath string, prefs *models.Preferences, page int return emails, nil } + +func GetEmailDetails(userEmail string, emailID uint) (fiber.Map, error) { + message, err := repository.GetEmailByID(userEmail, emailID) + if err != nil { + return nil, err + } + + // Get attachments + attachments, _ := repository.GetAttachmentsByEmailID(emailID) + + var attachmentMaps []fiber.Map + for _, att := range attachments { + attachmentMaps = append(attachmentMaps, fiber.Map{ + "ID": att.ID, + "Filename": att.Filename, + "ContentType": att.ContentType, + "Size": format.FormatFileSize(att.Size), + }) + } + + // Sanitize HTML body + body := message.BodyHTML + if body == "" { + body = "<pre>" + format.DecodeHTML(message.BodyText) + "</pre>" + } else { + body = format.SanitizeHTML(body) + } + + return fiber.Map{ + "ID": message.ID, + "Subject": format.DecodeHTML(message.Subject), + "From": format.DecodeHTML(message.From), + "FromName": format.DecodeHTML(message.FromName), + "To": format.DecodeHTML(message.To), + "CC": format.DecodeHTML(message.CC), + "Date": message.Date, + "Body": body, + "IsRead": message.IsRead, + "IsFlagged": message.IsFlagged, + "Attachments": attachmentMaps, + }, nil +} + +func ToggleEmailFlag(userEmail string, emailID uint) (bool, error) { + message, err := repository.GetEmailByID(userEmail, emailID) + if err != nil { + return false, err + } + + prefs, err := repository.GetPreferencesByEmail(userEmail) + if err != nil { + return false, err + } + + password, err := crypto.Decrypt(prefs.Authorization) + if err != nil { + return false, err + } + + client, err := email.ConnectIMAP(userEmail, password) + if err != nil { + return false, err + } + defer email.DisconnectIMAP(client) + + if err := email.ToggleFlag(client, message.Folder.IMAPName, message.UID, message.IsFlagged); err != nil { + return false, err + } + + message.IsFlagged = !message.IsFlagged + repository.UpdateEmail(message) + + return message.IsFlagged, nil +} + +func MarkEmailAsRead(userEmail string, emailID uint) error { + message, err := repository.GetEmailByID(userEmail, emailID) + if err != nil { + return err + } + + if message.IsRead { + return nil + } + + prefs, err := repository.GetPreferencesByEmail(userEmail) + if err != nil { + return err + } + + password, err := crypto.Decrypt(prefs.Authorization) + if err != nil { + return err + } + + client, err := email.ConnectIMAP(userEmail, password) + if err != nil { + return err + } + defer email.DisconnectIMAP(client) + + if err := email.MarkAsRead(client, message.Folder.IMAPName, message.UID); err != nil { + return err + } + + message.IsRead = true + return repository.UpdateEmail(message) +} 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; + } +}; diff --git a/templates/layouts/mailbox.django b/templates/layouts/mailbox.django index 32a7c8f..0cfaf5c 100644 --- a/templates/layouts/mailbox.django +++ b/templates/layouts/mailbox.django @@ -1,4 +1,6 @@ -{% extends 'layouts/main.django' %} {% block content %} +{% extends 'layouts/main.django' %} + +{% block content %} <div class="mailbox"> <aside class="sidebar"> {% include 'partials/sidebar.django' %} @@ -12,4 +14,12 @@ {% include 'partials/preview.django' %} </section> </div> + {% block extra_content %} + + {% endblock %} +{% endblock %} + +{% block scripts %} + {{ block.super }} + <script src="{% static 'js/search.js' %}"></script> {% endblock %} diff --git a/templates/layouts/main.django b/templates/layouts/main.django index 2ca45fd..af46e27 100644 --- a/templates/layouts/main.django +++ b/templates/layouts/main.django @@ -1,55 +1,14 @@ -{% extends 'layouts/generic.django' %} {% block body %} +{% extends 'layouts/generic.django' %} + +{% block body %} {% include 'partials/navbar.django' %} <main> {% block content %} {% endblock %} </main> -{% endblock %} {% block scripts %} - <script> - 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') - }) - }) - - document.addEventListener('click', function (e) { - if (!e.target.closest('.options-subitem')) { - document.querySelectorAll('.options-subitem.open').forEach(function (item) { - item.classList.remove('open') - }) - } - }) - - // Toggle search filters - const toggleBtn = document.getElementById('toggle-filters') - const filters = document.getElementById('search-filters') - - if (toggleBtn && filters) { - toggleBtn.addEventListener('click', function (e) { - e.preventDefault() - if (filters.style.display === 'none') { - filters.style.display = 'block' - } else { - filters.style.display = 'none' - } - }) - } - }) - </script> +{% endblock %} + +{% block scripts %} + <script src="{% static 'js/dropdown.js' %}"></script> {% endblock %} diff --git a/templates/mail/folder.django b/templates/mail/folder.django index 91aa6f8..26fd20c 100644 --- a/templates/mail/folder.django +++ b/templates/mail/folder.django @@ -1,3 +1,12 @@ -{% extends 'layouts/mailbox.django' %} {% block scripts %} +{% extends 'layouts/mailbox.django' %} + +{% block extra_content %} + <div id="mail-preferences" data-mark-as-read="{{ Preferences.MarkMessagesAsRead }}" data-show-address="{{ Preferences.ShowEmailAddressWithDisplayName|lower }}" data-display-html="{{ Preferences.DisplayHTML|lower }}" data-load-remote="{{ Preferences.LoadRemoteContent }}" style="display: none;"></div> +{% endblock %} + +{% block scripts %} + {{ block.super }} + <script src="{% static 'js/shadow.js' %}"></script> + <script src="{% static 'js/mail.js' %}"></script> <script src="{% static 'js/filters.js' %}"></script> {% endblock %} diff --git a/templates/partials/preview.django b/templates/partials/preview.django index d7aa00a..44d3b03 100644 --- a/templates/partials/preview.django +++ b/templates/partials/preview.django @@ -1,49 +1,3 @@ -{% if Email %} - <div class="email-header"> - <h2 class="email-subject">{{ Email.Subject }}</h2> - - <div class="email-actions"> - <button class="btn-icon" title="Reply">↶</button> - <button class="btn-icon" title="Reply All">⇄</button> - <button class="btn-icon" title="Forward">→</button> - <button class="btn-icon" title="Archive">▼</button> - <button class="btn-icon" title="Delete">×</button> - </div> - </div> - - <div class="email-sender"> - <div class="sender-info"> - <strong>{{ Email.FromName }}</strong> - <{{ Email.FromAddress }}> - </div> - <div class="email-date">{{ Email.Date }}</div> - </div> - - <div class="email-recipients"> - <div> - <strong>To:</strong> - {{ Email.To }} - </div> - {% if Email.Cc %} - <div> - <strong>Cc:</strong> - {{ Email.Cc }} - </div> - {% endif %} - </div> - - {% if Email.Attachments %} - <div class="email-attachments"> - <strong>Attachments:</strong> - {% for attachment in Email.Attachments %} - <a href="#" class="attachment">{{ attachment.Filename }} ({{ attachment.Size }})</a> - {% endfor %} - </div> - {% endif %} - - <div class="email-body">{{ Email.Body|safe }}</div> -{% else %} - <div class="no-email-selected"> - <p>Select an email to view</p> - </div> -{% endif %} +<div class="no-email-selected"> + <p>Select an email to view</p> +</div> diff --git a/utils/format/date.go b/utils/format/date.go index 8ef95cb..a326fa0 100644 --- a/utils/format/date.go +++ b/utils/format/date.go @@ -1,7 +1,6 @@ package format import ( - "html" "lain/types" "time" ) @@ -93,7 +92,3 @@ func formatTime(date time.Time, timeFormat types.TimeFormat) string { return date.Format("15:04") } } - -func DecodeHTML(text string) string { - return html.UnescapeString(text) -} diff --git a/utils/format/html.go b/utils/format/html.go index 36e2425..d976cb8 100644 --- a/utils/format/html.go +++ b/utils/format/html.go @@ -1,10 +1,65 @@ package format import ( + "html" "regexp" "strings" ) +func SanitizeHTML(htmlContent string) string { + // Remove dangerous tags + htmlContent = removeDangerousTags(htmlContent) + + // Remove inline event handlers + htmlContent = removeEventHandlers(htmlContent) + + // Remove javascript: protocol + htmlContent = removeJavascriptProtocol(htmlContent) + + // Sanitize styles + htmlContent = sanitizeStyles(htmlContent) + + return htmlContent +} + +func removeDangerousTags(html string) string { + dangerousTags := []string{ + "script", "iframe", "object", "embed", "applet", + "meta", "link", "base", "form", "input", "button", + } + + for _, tag := range dangerousTags { + regex := regexp.MustCompile(`(?i)<` + tag + `[^>]*>[\s\S]*?</` + tag + `>`) + html = regex.ReplaceAllString(html, "") + regex = regexp.MustCompile(`(?i)<` + tag + `[^>]*>`) + html = regex.ReplaceAllString(html, "") + } + + return html +} + +func removeEventHandlers(html string) string { + eventHandlers := regexp.MustCompile(`(?i)\s*on\w+\s*=\s*["'][^"']*["']`) + return eventHandlers.ReplaceAllString(html, "") +} + +func removeJavascriptProtocol(html string) string { + jsProtocol := regexp.MustCompile(`(?i)javascript:`) + return jsProtocol.ReplaceAllString(html, "") +} + +func sanitizeStyles(html string) string { + // Remove dangerous CSS properties + dangerousStyles := []string{"behavior", "expression", "binding", "import", "moz-binding"} + + for _, style := range dangerousStyles { + regex := regexp.MustCompile(`(?i)` + style + `\s*:\s*[^;]+;?`) + html = regex.ReplaceAllString(html, "") + } + + return html +} + func GenerateSnippet(bodyText, bodyHTML string) string { text := bodyText if text == "" && bodyHTML != "" { @@ -71,3 +126,7 @@ func StripHTML(html string) string { return strings.TrimSpace(strings.Join(cleanLines, " ")) } + +func DecodeHTML(text string) string { + return html.UnescapeString(text) +} diff --git a/utils/format/size.go b/utils/format/size.go new file mode 100644 index 0000000..ce7c4fa --- /dev/null +++ b/utils/format/size.go @@ -0,0 +1,16 @@ +package format + +import "fmt" + +func FormatFileSize(bytes int64) string { + const unit = 1024 + if bytes < unit { + return fmt.Sprintf("%d B", bytes) + } + div, exp := int64(unit), 0 + for n := bytes / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) +} |
