summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorBobby <[email protected]>2025-12-24 17:17:15 +0530
committerBobby <[email protected]>2025-12-24 17:17:15 +0530
commitd5ea2aa824eee4b7e2d169d21da0107d057e7bc6 (patch)
treee608fea8cf91d6915b7b6ce5eb46896dbdc2ad79
parentb77d75f05fb2059389c05f6c01484e0cd12e796e (diff)
downloadlain-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.
-rw-r--r--controllers/api.go66
-rw-r--r--router/api.go16
-rw-r--r--services/emails.go110
-rw-r--r--static/js/dropdown.js30
-rw-r--r--static/js/filters.js202
-rw-r--r--static/js/mail.js323
-rw-r--r--static/js/shadow.js63
-rw-r--r--templates/layouts/mailbox.django12
-rw-r--r--templates/layouts/main.django55
-rw-r--r--templates/mail/folder.django11
-rw-r--r--templates/partials/preview.django52
-rw-r--r--utils/format/date.go5
-rw-r--r--utils/format/html.go59
-rw-r--r--utils/format/size.go16
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 = [
- ];
- 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>
- &lt;{{ Email.FromAddress }}&gt;
- </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])
+}