let currentPage = 1;
let currentFilter = 'all';
let currentSort = 'date_desc';
let currentMessageId = null;
let currentMessageIndex = -1;
let currentMessages = [];
let currentPagination = null;
let modalClosedByBackButton = false;
let threadedView = false;
let userSettings = {};
let currentSearchTerms = [];
let selectMode = false;
let selectedMessages = new Set();
let keyboardHelpVisible = false;
let currentMessageData = null;
let currentParsedMessage = null;
let currentRenderMode = 'auto';
let requestedMessageId = null;
let currentConversationMessageId = null;
let currentConversationSubject = '';
let currentContextMenuMessageId = null;
let currentContextMenuMessageSaved = false;
/**
* Render a FREQ status badge appropriate to the given status value.
* @param {string|null} status 'pending', 'fulfilled', 'denied', or null
* @returns {string} HTML badge string
*/
function renderFreqBadge(status) {
const icon = '<i class="fas fa-file-download"></i>';
if (status === 'fulfilled') {
return `<span class="badge bg-success ms-1">${icon} ${uiT('ui.freq.status_fulfilled', 'FREQ Fulfilled')}</span>`;
}
if (status === 'denied') {
return `<span class="badge bg-danger ms-1">${icon} ${uiT('ui.freq.status_denied', 'FREQ Denied')}</span>`;
}
if (status === 'pending') {
return `<span class="badge bg-warning text-dark ms-1">${icon} ${uiT('ui.freq.status_pending', 'FREQ Pending')}</span>`;
}
return `<span class="badge bg-info ms-1">${icon} ${uiT('ui.compose.freq.badge', 'FREQ')}</span>`;
}
function apiError(payload, fallback) {
if (window.getApiErrorMessage) {
return window.getApiErrorMessage(payload, fallback);
}
if (payload && payload.error) {
return String(payload.error);
}
return fallback;
}
function uiT(key, fallback, params = {}) {
if (window.t) {
return window.t(key, params, fallback);
}
return fallback;
}
$(document).ready(function() {
loadNetmailSettings().then(function() {
const urlParams = new URLSearchParams(window.location.search);
const messageParam = urlParams.get('message');
requestedMessageId = messageParam && /^\d+$/.test(messageParam) ? parseInt(messageParam, 10) : null;
loadMessages(function() {
openRequestedMessage();
});
});
loadStats();
loadAddressBook();
// Initialize history state
if (!history.state) {
history.replaceState({}, '', '');
}
// Handle browser back button for modal
window.addEventListener('popstate', function(event) {
if ($('#messageModal').hasClass('show')) {
modalClosedByBackButton = true;
$('#messageModal').modal('hide');
}
});
// Add keyboard navigation for message modal
$(document).on('keydown', function(e) {
if ($('#messageModal').hasClass('show')) {
switch(e.key) {
case 'ArrowLeft':
e.preventDefault();
navigateMessage(-1);
break;
case 'ArrowRight':
e.preventDefault();
navigateMessage(1);
break;
case 'f':
case 'F':
if (e.ctrlKey || e.metaKey) break;
e.preventDefault();
toggleModalFullscreen();
break;
case 'd':
case 'D':
e.preventDefault();
downloadCurrentMessage();
break;
case 'a':
case 'A':
e.preventDefault();
cycleRenderMode();
break;
case '?':
case 'h':
case 'H':
e.preventDefault();
toggleKeyboardHelp();
break;
}
}
});
// Handle modal close events
$('#messageModal').on('hidden.bs.modal', function() {
// If modal wasn't closed by back button and we're in a modal state, go back in history
if (!modalClosedByBackButton && history.state && history.state.modal === 'message') {
history.back();
}
modalClosedByBackButton = false;
hideKeyboardHelp();
});
// Filter change handler
$('input[name="filter"]').change(function() {
currentConversationMessageId = null;
currentConversationSubject = '';
currentFilter = $(this).val();
currentPage = 1;
loadMessages();
});
// Search on enter key
$('#searchInput').on('keypress', function(e) {
if (e.which === 13) {
searchMessages();
}
});
document.addEventListener('click', hideMessageContextMenu);
document.addEventListener('scroll', hideMessageContextMenu, true);
window.addEventListener('resize', hideMessageContextMenu);
bindMessageListTouchContextMenu();
// Address book search
$('#addressBookSearch').on('keyup', debounce(function() {
loadAddressBook($(this).val());
}, 300));
// Polling disabled ? BinkStream delivers dashboard_stats on new netmail.
// Re-enable by uncommenting if BinkStream is unavailable.
// startAutoRefresh(function() { loadMessages(); loadStats(); }, 120000);
// Refresh when the page becomes visible again (PWA restore, tab focus, browser un-minimize).
// BinkStream events are throttled while the page is hidden, so a manual refresh is needed.
let _hiddenAt = null;
document.addEventListener('visibilitychange', function() {
if (document.visibilityState === 'hidden') {
_hiddenAt = Date.now();
} else if (document.visibilityState === 'visible' && _hiddenAt !== null) {
// Only refresh if the page was hidden for more than 30 seconds.
if (Date.now() - _hiddenAt >= 30000) {
if (!currentSearchTerms.length) {
loadMessages(null, true);
}
loadStats();
}
_hiddenAt = null;
}
});
if (window.BinkStream) {
// Reload the message list when new netmail arrives.
// Debounced to absorb bursts from concurrent imports.
let _dashboardRefreshTimer = null;
window.BinkStream.on('dashboard_stats', function() {
clearTimeout(_dashboardRefreshTimer);
_dashboardRefreshTimer = setTimeout(function() {
if (!currentSearchTerms.length) {
loadMessages(null, true);
}
loadStats();
}, 2000);
});
// Cross-tab read sync ? apply read styling immediately when another tab
// marks a message as read without requiring a full list reload.
window.BinkStream.on('message_read', function(data) {
if (data.message_type !== 'netmail') return;
(data.message_ids || []).forEach(function(id) {
applyNetmailReadStyle(id);
});
});
}
});
function loadMessages(callback, silent = false) {
if (!silent) showLoading('#messagesContainer');
// Clear search terms when loading regular messages (not from search)
currentSearchTerms = [];
if (currentFilter === 'drafts') {
// Load drafts instead of regular messages
loadDrafts();
return;
}
if (currentConversationMessageId) {
$.get(`/api/messages/netmail/${currentConversationMessageId}/conversation`)
.done(function(data) {
displayMessages(data.messages, true);
updatePagination(data.pagination);
if (typeof callback === 'function') callback(data);
})
.fail(function() {
$('#messagesContainer').html(`<div class="text-center text-danger py-4">${uiT('errors.failed_load_messages', 'Failed to load messages')}</div>`);
});
return;
}
let url = `/api/messages/netmail?page=${currentPage}&sort=${currentSort}`;
if (currentFilter !== 'all') {
url += `&filter=${currentFilter}`;
}
if (threadedView) {
url += '&threaded=true';
}
$.get(url)
.done(function(data) {
// If the saved page is beyond the last page, reset to page 1 and reload
if (currentPage > 1 && data.messages && data.messages.length === 0 && data.pagination && data.pagination.pages < currentPage) {
currentPage = 1;
loadMessages(callback);
return;
}
displayMessages(data.messages, data.threaded || false);
updatePagination(data.pagination);
// Remember the current netmail page
saveNetmailPage();
if (typeof callback === 'function') callback(data);
})
.fail(function() {
$('#messagesContainer').html(`<div class="text-center text-danger py-4">${uiT('errors.failed_load_messages', 'Failed to load messages')}</div>`);
});
}
function loadDrafts() {
$.get('/api/messages/drafts?type=netmail')
.done(function(data) {
if (data.success) {
displayDrafts(data.drafts);
// Clear pagination for drafts
$('#pagination').empty();
} else {
$('#messagesContainer').html(`<div class="text-center text-danger py-4">${uiT('errors.messages.drafts.list_failed', 'Failed to load drafts')}</div>`);
}
})
.fail(function() {
$('#messagesContainer').html(`<div class="text-center text-danger py-4">${uiT('errors.messages.drafts.list_failed', 'Failed to load drafts')}</div>`);
});
}
function displayDrafts(drafts) {
const container = $('#messagesContainer');
let html = '';
if (drafts.length === 0) {
html = `<div class="text-center text-muted py-4">${uiT('ui.netmail.no_drafts_found', 'No drafts found')}</div>`;
} else {
// Create table structure
html = `
<div class="table-responsive">
<table class="table table-hover message-table mb-0">
<thead>
<tr>
<th style="width: 30%">${uiT('ui.netmail.to', 'To')}</th>
<th style="width: 45%">${uiT('ui.common.subject_label_short', 'Subject')}</th>
<th colspan="2" style="width: 25%">${uiT('ui.netmail.last_updated', 'Last Updated')}</th>
</tr>
</thead>
<tbody>
`;
drafts.forEach(function(draft) {
const displayTo = draft.to_name || uiT('ui.common.unknown', 'Unknown');
const displayAddress = draft.to_address || '';
html += `
<tr class="message-row" style="cursor: pointer;" onclick="continueDraft(${draft.id})">
<td>
<div><strong>${escapeHtml(displayTo)}</strong></div>
${displayAddress ? `<div class="text-muted small">${escapeHtml(displayAddress)}</div>` : ''}
</td>
<td>
<strong>${escapeHtml(draft.subject || uiT('messages.no_subject', '(No Subject)'))}</strong>
${draft.message_text ? `<br><small class="text-muted">${escapeHtml(draft.message_text.substring(0, 100))}${draft.message_text.length > 100 ? '...' : ''}</small>` : ''}
</td>
<td>
<div>${formatFullDate(draft.updated_at)}</div>
<div class="text-muted small">
<button class="btn btn-sm btn-outline-danger" onclick="event.stopPropagation(); deleteDraftConfirm(${draft.id})" title="${uiT('ui.common.delete_draft', 'Delete draft')}">
<i class="fas fa-trash"></i>
</button>
</div>
</td>
</tr>
`;
});
html += `
</tbody>
</table>
</div>
`;
}
container.html(html);
}
function displayMessages(messages, isThreaded = false) {
// Store messages for navigation
currentMessages = messages;
const container = $('#messagesContainer');
let html = '';
if (messages.length === 0) {
html = `<div class="text-center text-muted py-4">${uiT('messages.none_found', 'No messages found')}</div>`;
} else {
if (currentConversationMessageId) {
html += `
<div class="alert alert-info d-flex justify-content-between align-items-center mb-3">
<div>
<strong>${uiT('ui.common.showing_entire_conversation', 'Showing Entire Conversation')}</strong>
${currentConversationSubject ? `<div class="small mt-1">${escapeHtml(currentConversationSubject)}</div>` : ''}
</div>
<button type="button" class="btn btn-sm btn-outline-primary" onclick="clearConversationView()">${uiT('ui.common.back_to_message_list', 'Back to Message List')}</button>
</div>
`;
}
html += `
<table class="table table-hover message-table mb-0">
<thead>
<tr>
<th style="width: 3%" id="selectAllColumn" class="d-none">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="selectAllMessages" onchange="toggleSelectAll()">
</div>
</th>
<th width="27%">${uiT('ui.netmail.from_to', 'From/To')}</th>
<th width="40%">${uiT('ui.common.subject_label_short', 'Subject')}</th>
<th width="15%">${uiT('ui.common.address', 'Address')}</th>
<th width="10%">${uiT('ui.netmail.received', 'Received')}</th>
<th width="5%"></th>
</tr>
</thead>
<tbody>
`;
messages.forEach(function(msg) {
const isUnread = !msg.is_read;
const isSent = !!msg.is_sent;
const rowClass = ''; //isUnread ? 'table-light' : '';
// Threading support
const threadLevel = msg.thread_level || 0;
const replyCount = msg.reply_count || 0;
const isThreadRoot = msg.is_thread_root || false;
// Indent up to 2 levels (0.75rem each); deeper nesting shown via border-left color
const indentRem = Math.min(threadLevel, 2) * 0.75;
const threadIndent = threadLevel > 0 ? `style="padding-left: ${indentRem}rem;"` : '';
const threadIcon = (threadLevel > 0 || replyCount > 0)
? `<i class="fas fa-reply me-1 text-muted" title="${uiT('ui.common.show_entire_conversation', 'Show Entire Conversation')}" style="cursor: pointer;" onclick="event.stopPropagation(); showConversation(${msg.id})"></i>`
: '';
const replyCountBadge = isThreadRoot && replyCount > 0 ? ` <span class="badge bg-secondary ms-1" title="${uiT('ui.common.replies_with_count', '{count} replies', { count: replyCount })}">${replyCount}</span>` : '';
const threadLevelClass = threadLevel > 0 ? ` thread-reply thread-level-${Math.min(threadLevel, 9)}` : '';
html += `
<tr class="${rowClass} message-row${threadLevelClass}" data-message-id="${msg.id}" onclick="viewMessage(${msg.id})" oncontextmenu="openMessageContextMenu(event, ${msg.id})" style="cursor: pointer;">
<td class="message-checkbox d-none" onclick="event.stopPropagation()">
<div class="form-check">
<input class="form-check-input message-select" type="checkbox" value="${msg.id}" onchange="updateSelection()">
</div>
</td>
<td ${threadIndent}>
${isUnread ? `<i class="fas fa-envelope text-primary me-1" title="${uiT('ui.common.unread', 'Unread')}"></i>` : `<i class="far fa-envelope-open text-muted me-1" title="${uiT('ui.common.read', 'Read')}"></i>`}<i class="fas fa-bookmark ${Number(msg.is_saved) === 1 ? 'text-warning' : 'text-muted'} me-1 save-btn" data-message-id="${msg.id}" data-message-type="netmail" data-saved="${Number(msg.is_saved) === 1 ? 'true' : 'false'}" title="${Number(msg.is_saved) === 1 ? uiT('ui.common.remove_from_saved', 'Remove from saved') : uiT('ui.common.save_for_later', 'Save for later')}" style="cursor: pointer;" onclick="toggleSaveMessage(${msg.id}, 'netmail', ${Number(msg.is_saved) === 1})"></i>${msg.art_format === 'petscii' ? `<span class="badge me-1" style="background-color:#4040a0;color:#fff;font-size:0.6em;padding:1px 3px;vertical-align:middle;" title="PETSCII / C64 Art">C64</span>` : ''}${threadIcon}<strong>${escapeHtml(isSent ? `${uiT('ui.common.to_label', 'To:')} ` + msg.to_name : msg.from_name)}</strong>
<br>
</td>
<td>
${isUnread ? '<strong class="subject-unread">' : ''}<span>${escapeHtml(msg.subject || uiT('messages.no_subject', '(No Subject)'))}</span>${isUnread ? '</strong>' : ''}${replyCountBadge}
${msg.has_attachment ? ` <i class="fas fa-paperclip text-muted" title="${uiT('ui.common.has_attachment', 'Has attachment')}"></i>` : ''}
<br>
<small class="text-muted">
<span class="badge bg-secondary">${uiT('ui.netmail.badge_netmail', 'NETMAIL')}</span>
${isUnread ? `<span class="badge bg-primary ms-1">${uiT('ui.netmail.badge_new', 'NEW')}</span>` : ''}
${msg.received_insecure ? `<span class="badge bg-warning text-dark ms-1" title="${uiT('ui.netmail.received_insecure_session_title', 'Received via insecure session')}"><i class="fas fa-exclamation-triangle"></i></span>` : ''}
${msg.is_freq ? renderFreqBadge(msg.freq_status) : ''}
</small>
</td>
<td>
<small class="font-monospace node-addr-popover text-primary" style="cursor:pointer; text-decoration:underline;" data-address="${escapeHtml(isSent ? msg.to_address : msg.from_address)}" onclick="event.stopPropagation(); handleNodeAddrClick(this)">${escapeHtml(isSent ? msg.to_address : msg.from_address)}</small>
${(isSent ? msg.to_domain : msg.from_domain) ? `<br><span class="badge bg-secondary" style="font-size: 0.7em;">${isSent ? msg.to_domain : msg.from_domain}</span>` : ''}
</td>
<td title="${formatFullDate(msg.date_written)}">
<small>${formatDate(msg.date_received)}</small>
</td>
<td class="text-center">
<button class="btn btn-outline-danger btn-sm" onclick="event.stopPropagation(); deleteMessage(${msg.id})" title="${uiT('ui.common.delete_message', 'Delete message')}">
<i class="fas fa-trash"></i>
</button>
</td>
</tr>
`;
});
html += `
</tbody>
</table>
`;
}
container.html(html);
// If select mode was active before the re-render, restore it
if (selectMode) {
$('#selectAllColumn').removeClass('d-none');
$('.message-checkbox').removeClass('d-none');
// Re-check any previously selected messages that are still in the new list
if (selectedMessages.size > 0) {
$('.message-select').each(function() {
if (selectedMessages.has(parseInt($(this).val()))) {
$(this).prop('checked', true);
}
});
updateSelection();
}
}
}
// ?? Node address popover ??????????????????????????????????????????????????????
const _nodePopoverCache = {};
function _renderNodePopoverContent(node, address) {
if (!node) {
return `<span class="text-muted small">${uiT('ui.nodelist.not_found', 'Not in nodelist')}</span>`;
}
return `<div class="fw-semibold">${escapeHtml(node.system_name || address)}</div>` +
(node.location ? `<div class="text-muted small">${escapeHtml(node.location)}</div>` : '') +
`<div class="mt-2"><a href="/nodelist/view?address=${encodeURIComponent(node.address)}" class="btn btn-sm btn-outline-primary w-100">${uiT('ui.nodelist.view_full_details', 'View full node details')}</a></div>`;
}
function handleNodeAddrClick(el) {
const address = el.dataset.address;
if (!address) return;
// Close other open node popovers
$('.node-addr-popover').not(el).each(function() {
const p = bootstrap.Popover.getInstance(this);
if (p) p.hide();
});
let pop = bootstrap.Popover.getInstance(el);
if (pop) {
pop.toggle();
return;
}
pop = new bootstrap.Popover(el, {
html: true,
trigger: 'manual',
placement: 'left',
sanitize: false,
content: `<i class="fas fa-spinner fa-spin"></i>`,
});
pop.show();
function updateBody(html) {
const popId = el.getAttribute('aria-describedby');
const popEl = popId ? document.getElementById(popId) : null;
if (popEl) popEl.querySelector('.popover-body').innerHTML = html;
}
if (_nodePopoverCache[address] !== undefined) {
updateBody(_renderNodePopoverContent(_nodePopoverCache[address], address));
} else {
fetch(`/api/nodelist/node?address=${encodeURIComponent(address)}`)
.then(r => r.json())
.then(data => {
_nodePopoverCache[address] = data.node || null;
updateBody(_renderNodePopoverContent(_nodePopoverCache[address], address));
})
.catch(() => {
_nodePopoverCache[address] = null;
updateBody(_renderNodePopoverContent(null, address));
});
}
}
$(document).on('click', function(e) {
if (!$(e.target).closest('.node-addr-popover, .popover').length) {
$('.node-addr-popover').each(function() {
const p = bootstrap.Popover.getInstance(this);
if (p) p.hide();
});
}
});
// ?????????????????????????????????????????????????????????????????????????????
function updatePagination(pagination) {
currentPagination = pagination;
const container = $('#pagination');
let html = '';
if (currentConversationMessageId) {
container.empty();
return;
}
if (pagination.pages > 1) {
html = '<ul class="pagination pagination-sm mb-0">';
// Previous button
if (pagination.page > 1) {
html += `<li class="page-item"><a class="page-link" href="#" onclick="changePage(${pagination.page - 1})">${uiT('ui.common.previous', 'Previous')}</a></li>`;
}
// Page numbers: first, ellipsis, window around current, ellipsis, last
const cur = pagination.page, total = pagination.pages;
const pageBtn = (n) => {
const active = n === cur ? 'active' : '';
return `<li class="page-item ${active}"><a class="page-link" href="#" onclick="changePage(${n})">${n}</a></li>`;
};
const ellipsis = `<li class="page-item disabled"><span class="page-link">…</span></li>`;
const pages = new Set([1, total]);
for (let i = Math.max(1, cur - 2); i <= Math.min(total, cur + 2); i++) pages.add(i);
let prev = 0;
[...pages].sort((a, b) => a - b).forEach(p => {
if (prev && p - prev > 1) html += ellipsis;
html += pageBtn(p);
prev = p;
});
// Next button
if (pagination.page < pagination.pages) {
html += `<li class="page-item"><a class="page-link" href="#" onclick="changePage(${pagination.page + 1})">${uiT('ui.common.next', 'Next')}</a></li>`;
}
html += '</ul>';
}
container.html(html);
}
function showConversation(messageId) {
currentConversationMessageId = messageId;
const selected = currentMessages.find(msg => Number(msg.id) === Number(messageId));
currentConversationSubject = selected && selected.subject ? selected.subject : '';
loadMessages();
}
function clearConversationView() {
currentConversationMessageId = null;
currentConversationSubject = '';
loadMessages();
}
function openMessageContextMenu(event, messageId) {
if (event.shiftKey) {
return;
}
event.preventDefault();
event.stopPropagation();
openMessageContextMenuAtPosition(event.pageX, event.pageY, messageId);
}
function openMessageContextMenuAtPosition(pageX, pageY, messageId) {
const menu = document.getElementById('messageContextMenu');
if (!menu) {
return;
}
currentContextMenuMessageId = messageId;
const selected = currentMessages.find(msg => Number(msg.id) === Number(messageId));
currentContextMenuMessageSaved = !!(selected && Number(selected.is_saved) === 1);
const saveLabel = document.getElementById('messageContextMenuSaveLabel');
if (saveLabel) {
saveLabel.textContent = currentContextMenuMessageSaved
? uiT('ui.common.remove_from_saved', 'Remove from saved')
: uiT('ui.common.save_for_later', 'Save for later');
}
menu.style.left = `${pageX}px`;
menu.style.top = `${pageY}px`;
menu.style.display = 'block';
}
function hideMessageContextMenu() {
const menu = document.getElementById('messageContextMenu');
if (!menu) {
return;
}
menu.style.display = 'none';
currentContextMenuMessageId = null;
}
function bindMessageListTouchContextMenu() {
if (!window.MessageListContextMenu) {
return;
}
window.MessageListContextMenu.bindLongPress({
containerId: 'messagesContainer',
rowSelector: '.message-row[data-message-id]',
isInteractiveTarget: isMessageRowInteractiveTarget,
onLongPress: function(details) {
openMessageContextMenuAtPosition(details.pageX, details.pageY, details.messageId);
}
});
}
function isMessageRowInteractiveTarget(target) {
return !!target.closest(
'button, a, input, select, textarea, label, .save-btn, .echo-from-popover, .node-addr-popover, .message-select'
);
}
function replyFromContextMenu() {
if (!currentContextMenuMessageId) {
return;
}
const messageId = currentContextMenuMessageId;
hideMessageContextMenu();
composeMessage('netmail', messageId);
}
function repostFromContextMenu() {
if (!currentContextMenuMessageId) {
return;
}
const messageId = currentContextMenuMessageId;
hideMessageContextMenu();
repostMessage(messageId);
}
function viewConversationFromContextMenu() {
if (!currentContextMenuMessageId) {
return;
}
const messageId = currentContextMenuMessageId;
hideMessageContextMenu();
showConversation(messageId);
}
function downloadMessageFromContextMenu() {
if (!currentContextMenuMessageId) {
return;
}
const messageId = currentContextMenuMessageId;
hideMessageContextMenu();
window.location.href = `/api/messages/netmail/${messageId}/download`;
}
function forwardMessageToEmailFromContextMenu() {
if (!currentContextMenuMessageId) {
return;
}
const messageId = currentContextMenuMessageId;
hideMessageContextMenu();
$.post(`/api/messages/netmail/${messageId}/forward-email`)
.done(function(response) {
if (response && response.success) {
showForwardEmailFeedback(
true,
(window.getApiMessage
? window.getApiMessage(response, uiT('ui.common.forwarded_to_email', 'Message forwarded to your email'))
: uiT('ui.common.forwarded_to_email', 'Message forwarded to your email'))
);
} else {
showForwardEmailFeedback(
false,
apiError(response, uiT('errors.messages.forward_email.failed', 'Failed to forward message by email'))
);
}
})
.fail(function(xhr) {
showForwardEmailFeedback(
false,
apiError(xhr.responseJSON || {}, uiT('errors.messages.forward_email.failed', 'Failed to forward message by email'))
);
});
}
function showForwardEmailFeedback(success, message) {
if (window.MessageListContextMenu) {
window.MessageListContextMenu.showActionNotice(success, message);
return;
}
window.alert(message);
}
function changePage(page) {
currentPage = page;
loadMessages();
}
function toggleModalFullscreen() {
const modal = document.getElementById('messageModal');
const dialog = modal.querySelector('.modal-dialog');
const icon = document.querySelector('#fullscreenToggle i');
if (dialog.classList.contains('modal-fullscreen')) {
dialog.classList.remove('modal-fullscreen');
dialog.classList.add('modal-lg');
icon.classList.remove('fa-compress');
icon.classList.add('fa-expand');
UserStorage.setItem('modalFullscreen', 'false');
} else {
dialog.classList.remove('modal-lg');
dialog.classList.add('modal-fullscreen');
icon.classList.remove('fa-expand');
icon.classList.add('fa-compress');
UserStorage.setItem('modalFullscreen', 'true');
}
}
function applyModalFullscreenPreference() {
const isFullscreen = UserStorage.getItem('modalFullscreen') === 'true';
const modal = document.getElementById('messageModal');
const dialog = modal.querySelector('.modal-dialog');
const icon = document.querySelector('#fullscreenToggle i');
if (isFullscreen) {
dialog.classList.remove('modal-lg');
dialog.classList.add('modal-fullscreen');
icon.classList.remove('fa-expand');
icon.classList.add('fa-compress');
}
}
function viewMessage(messageId) {
currentMessageId = messageId;
// Find and store current message index for navigation
currentMessageIndex = currentMessages.findIndex(msg => msg.id === messageId);
// Update navigation buttons
updateNavigationButtons();
// Mark as read immediately
markMessageAsRead(messageId);
// Add history entry for mobile back button support
if (!history.state || history.state.modal !== 'message') {
history.pushState({modal: 'message', messageId: messageId}, '', '');
}
$('#messageContent').html(`
<div class="loading-spinner">
<i class="fas fa-spinner fa-spin me-2"></i>
${uiT('ui.common.loading_message', 'Loading message...')}
</div>
`);
applyModalFullscreenPreference();
$('#messageModal').modal('show');
$.get(`/api/messages/netmail/${messageId}`)
.done(function(data) {
displayMessageContent(data);
})
.fail(function() {
$('#messageContent').html(`<div class="text-danger">${uiT('errors.messages.netmail.get_failed', 'Failed to load message')}</div>`);
});
}
function printMessage() {
const content = document.getElementById('messageContent');
if (!content) return;
const win = window.open('', '_blank', 'width=800,height=600');
win.document.write(
'<!DOCTYPE html><html><head><meta charset="utf-8"><title>Print</title>'
+ '<style>'
+ 'body{font-family:sans-serif;font-size:11pt;padding:1.5cm;color:#000;background:#fff}'
+ '.message-header-full{border-bottom:1px solid #ccc;margin-bottom:1em;padding-bottom:.5em}'
+ '.message-header-full strong{color:#333}'
+ 'pre{white-space:pre-wrap;word-break:break-word;font-size:10pt;background:#f8f9fa;border:1px solid #dee2e6;padding:.75em;border-radius:4px}'
+ '.message-origin{border-top:1px solid #ccc;margin-top:1em;padding-top:.5em;font-size:9pt;color:#666}'
+ 'a{color:#000;text-decoration:none}'
+ 'button,i.fas,i.far,.badge,.btn,#ansiRenderBadge,.modal-header-save-icon{display:none!important}'
+ '</style>'
+ '</head><body>'
+ content.innerHTML
+ '</body></html>'
);
win.document.close();
win.focus();
win.onafterprint = function() { win.close(); };
win.print();
}
function displayMessageContent(message) {
const isSent = !!message.is_sent;
$('#messageSubject').text(message.subject || uiT('messages.no_subject', '(No Subject)'));
// Parse message to separate kludge lines from body (use stored kludge_lines if available)
const parsedMessage = parseNetmailMessage(message.message_text || '', message.kludge_lines || null, message.bottom_kludges || null);
currentMessageData = message;
currentParsedMessage = parsedMessage;
currentRenderMode = 'auto';
// Check if sender is already in address book before rendering
checkAndDisplayMessage(message, parsedMessage, isSent);
}
function getNextRenderMode(mode) {
const modes = ['auto', 'ansi', 'amiga_ansi', 'plain', 'raw'];
const currentIndex = modes.indexOf(mode);
return modes[(currentIndex + 1 + modes.length) % modes.length];
}
function showRenderModeToast() {
const modalBody = document.querySelector('#messageModal .modal-body');
if (!modalBody) {
return;
}
const existing = document.getElementById('renderModeToast');
if (existing) {
existing.remove();
}
const modeLabel = window.getViewerModeToastLabel
? window.getViewerModeToastLabel(currentRenderMode, currentMessageData)
: currentRenderMode;
const toast = document.createElement('div');
toast.id = 'renderModeToast';
toast.className = 'badge bg-secondary';
toast.style.position = 'absolute';
toast.style.top = '0.75rem';
toast.style.right = '0.75rem';
toast.style.zIndex = '25';
toast.textContent = `${uiT('ui.echomail.viewer_mode_prefix', 'Viewer mode:')} ${modeLabel}`;
modalBody.appendChild(toast);
window.setTimeout(() => {
const currentToast = document.getElementById('renderModeToast');
if (currentToast) {
currentToast.remove();
}
}, 1200);
}
function updateRenderModeBadge() {
const badge = document.getElementById('ansiRenderBadge');
const badgeText = document.getElementById('ansiRenderBadgeText');
if (!badge || !badgeText) {
return;
}
if (currentRenderMode === 'auto') {
badge.style.display = 'none';
return;
}
const modeLabel = window.getViewerModeToastLabel
? window.getViewerModeToastLabel(currentRenderMode, currentMessageData)
: currentRenderMode;
const prefix = uiT('ui.echomail.viewer_mode_prefix', 'Viewer mode:');
const suffix = uiT('ui.echomail.press_a_to_cycle', 'press A to cycle');
badgeText.textContent = `${prefix} ${modeLabel} - ${suffix}`;
badge.style.display = '';
}
function renderCurrentMessageBody() {
if (!currentMessageData || !currentParsedMessage) {
return;
}
const body = currentParsedMessage.messageBody;
const container = document.getElementById('messageBodyContainer');
if (!container) return;
if (currentRenderMode !== 'plain'
&& currentRenderMode !== 'raw'
&& typeof looksLikeSixel === 'function'
&& looksLikeSixel(body)) {
renderSixelChunks(container, body, function (textChunk) {
return formatMessageBodyForDisplay(currentMessageData, textChunk, currentSearchTerms, {
formatOverride: currentRenderMode === 'plain' ? null : currentRenderMode
});
});
updateRenderModeBadge();
return;
}
const bodyHtml = currentMessageData.markup_html && currentRenderMode === 'auto'
? currentMessageData.markup_html
: formatMessageBodyForDisplay(currentMessageData, body, currentSearchTerms, {
forcePlain: currentRenderMode === 'plain',
formatOverride: currentRenderMode === 'plain' ? null : currentRenderMode
});
container.innerHTML = bodyHtml;
if (window.BinkMediaPlayer) BinkMediaPlayer.scan(container);
updateRenderModeBadge();
}
function cycleRenderMode() {
if (!$('#messageModal').hasClass('show')) {
return;
}
currentRenderMode = getNextRenderMode(currentRenderMode);
renderCurrentMessageBody();
showRenderModeToast();
}
function checkAndDisplayMessage(message, parsedMessage, isSent) {
// Check if this contact already exists in address book
const replyToAddress = message.replyto_address || message.reply_address || message.original_author_address || message.from_address;
const replyToName = message.replyto_name || message.from_name;
$.get('/api/address-book', { search: replyToName })
.done(function(response) {
let isInAddressBook = false;
if (response.success && response.entries) {
const existingEntry = response.entries.find(entry =>
entry.messaging_user_id && replyToName &&
entry.messaging_user_id.toLowerCase() === replyToName.toLowerCase() &&
entry.node_address === replyToAddress
);
isInAddressBook = !!existingEntry;
}
renderMessageContent(message, parsedMessage, isSent, isInAddressBook);
})
.fail(function() {
// On error, assume not in address book
renderMessageContent(message, parsedMessage, isSent, false);
});
}
function renderMessageContent(message, parsedMessage, isSent, isInAddressBook) {
let addressBookButton;
if (isInAddressBook) {
addressBookButton = `
<button class="btn btn-sm btn-outline-secondary ms-2" id="saveAddressBookBtn" disabled title="${uiT('ui.common.already_in_address_book', 'Already in address book')}">
<i class="fas fa-check"></i> <i class="fas fa-address-book"></i>
</button>
`;
} else {
const replyToAddress = message.replyto_address || message.reply_address || message.original_author_address || message.from_address;
const replyToName = message.replyto_name || message.from_name;
addressBookButton = `
<button class="btn btn-sm btn-outline-success ms-2" id="saveAddressBookBtn" onclick="saveToAddressBook('${escapeHtml(replyToName)}', '${escapeHtml(replyToAddress)}', '${escapeHtml(message.from_name)}', '${escapeHtml(message.from_address)}')" title="${uiT('ui.common.save_to_address_book', 'Save to address book')}">
<i class="fas fa-address-book"></i>
</button>
`;
}
const bodyHtml = message.markup_html
? message.markup_html
: formatMessageBodyForDisplay(message, parsedMessage.messageBody);
const html = `
<div class="message-header-full mb-3">
<div class="row align-items-center">
<div class="col-md-6 d-flex align-items-center gap-1 flex-wrap">
<strong>${uiT('ui.common.from_label', 'From:')}</strong> <span id="senderNamePopoverTrigger" style="cursor:pointer;">${escapeHtml(message.from_name)}</span>
${addressBookButton}
</div>
<div class="col-md-6 text-end">
<strong>${uiT('ui.common.to_label', 'To:')}</strong> ${escapeHtml(message.to_name)}
<small class="text-muted ms-2">${formatFidonetAddress(message.to_address, message.to_system_name)}</small>
</div>
</div>
<div class="row mt-2">
<div class="col-md-6">
<strong>${uiT('ui.common.date_label', 'Date:')}</strong> <span title="${uiT('ui.common.sent_prefix', 'Sent:')} ${formatFullDate(message.date_written)}">${formatFullDate(message.date_received)}</span>
</div>
<div class="col-md-6">
<strong>${uiT('ui.common.subject_label', 'Subject:')}</strong> ${escapeHtml(message.subject || uiT('messages.no_subject', '(No Subject)'))}
</div>
</div>
<div class="row mt-2 align-items-center">
<div class="col-12 text-end">
<i class="fas fa-bookmark modal-header-save-icon ${Number(message.is_saved) === 1 ? 'text-warning' : 'text-muted'}"
id="modalHeaderSaveIcon"
style="cursor: pointer;"
title="${Number(message.is_saved) === 1 ? uiT('ui.common.remove_from_saved', 'Remove from saved') : uiT('ui.common.save_for_later', 'Save for later')}"></i>
</div>
</div>
${message.received_insecure ? `
<div class="row mt-2">
<div class="col-12">
<span class="badge bg-warning text-dark" title="${uiT('ui.netmail.received_insecure_badge_title', 'This message was received via an insecure/unauthenticated binkp session')}">
<i class="fas fa-exclamation-triangle"></i> ${uiT('ui.netmail.received_insecurely', 'Received Insecurely')}
</span>
<small class="text-muted ms-2">${uiT('ui.netmail.not_authenticated', 'This message was not authenticated')}</small>
</div>
</div>
` : ''}
${message.is_freq ? `
<div class="row mt-2">
<div class="col-12">
${renderFreqBadge(message.freq_status)}
<small class="text-muted ms-2">${uiT('ui.compose.freq.help', 'File request ? filename in subject line')}</small>
</div>
</div>
` : ''}
</div>
<div id="kludgeContainer" class="kludge-lines mb-3" style="display: none;">
<pre class="bg-dark text-light p-3 rounded small">${formatKludgeLinesWithSeparator(parsedMessage.topKludges || parsedMessage.kludgeLines, parsedMessage.bottomKludges || [])}</pre>
</div>
<div class="message-text">
<div id="ansiRenderBadge" style="display:none;" class="mb-2">
<span class="badge bg-secondary" id="ansiRenderBadgeText"></span>
</div>
<div id="messageBodyContainer">
${bodyHtml}
</div>
</div>
${message.attachments && message.attachments.length > 0 ? `
<div class="message-attachments mt-3">
<h6 class="text-muted mb-2">
<i class="fas fa-paperclip"></i>
${uiT('ui.common.file_attachments_with_count', 'File Attachments ({count})', { count: message.attachments.length })}
</h6>
<div class="list-group">
${message.attachments.map(file => `
<a href="/api/files/${file.id}/download" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center" download="${escapeHtml(file.filename)}">
<div>
<i class="fas fa-file me-2"></i>
<strong>${escapeHtml(file.filename)}</strong>
<br>
<small class="text-muted">
${formatFileSize(file.filesize)}
${file.short_description ? ' - ' + escapeHtml(file.short_description) : ''}
</small>
</div>
<i class="fas fa-download"></i>
</a>
`).join('')}
</div>
</div>
` : ''}
`;
$('#messageContent').html(html);
initSenderPopover(message, message.from_address, message.from_name);
// Wire up save button and header icon
updateModalSaveButton(message);
// Set up reply button
if (!isSent) {
$('#replyButton').show().off('click').on('click', function() {
// Store the message ID for the reply
const messageId = currentMessageId;
// Hide modal and wait for it to be fully closed before navigating
$('#messageModal').one('hidden.bs.modal', function() {
// Small delay to ensure other modal handlers complete first
setTimeout(function() {
composeMessage('netmail', messageId);
}, 10);
});
$('#messageModal').modal('hide');
});
} else {
$('#replyButton').hide();
}
$('#repostButton').show().off('click').on('click', function() {
const messageId = currentMessageId;
$('#messageModal').one('hidden.bs.modal', function() {
setTimeout(function() {
repostMessage(messageId);
}, 10);
});
$('#messageModal').modal('hide');
});
// Set up delete button
$('#deleteButton').show().off('click').on('click', function() {
deleteMessage(currentMessageId);
});
// Edit button is always shown ? getMessage already enforces sender/receiver access
}
function openEditMessage() {
if (!currentMessageData) return;
const msg = currentMessageData;
$('#editMessageModalTitle').html(`<i class="fas fa-pencil-alt me-2"></i>${uiT('ui.echomail.edit_message', 'Edit Message')} #${currentMessageId}`);
$('#editMsgDbId').text(currentMessageId);
$('#editMsgId').text(msg.message_id || '');
$('#editMsgDate').text(formatFullDate(msg.date_written));
$('#editMsgFrom').text((msg.from_name || '') + (msg.from_address ? ' <' + msg.from_address + '>' : ''));
$('#editMsgSubject').text(msg.subject || '');
$('#editArtFormat').val(msg.art_format || '');
const editCharsetVal = msg.message_charset || 'UTF-8';
const $editCharsetSel = $('#editCharset');
$editCharsetSel.find('option.unknown-charset').remove();
if ($editCharsetSel.find('option[value="' + editCharsetVal + '"]').length === 0) {
$editCharsetSel.prepend('<option value="' + editCharsetVal + '" class="unknown-charset">' + editCharsetVal + ' (unknown)</option>');
}
$editCharsetSel.val(editCharsetVal);
$('#editMessageError').addClass('d-none');
$('#editMessageSuccess').addClass('d-none');
$('#saveEditMessageBtn').prop('disabled', false);
$('#editMessageModal').modal('show');
}
function saveEditMessage() {
if (!currentMessageData) return;
const artFormat = $('#editArtFormat').val();
const charset = $('#editCharset').val().trim();
$('#editMessageError').addClass('d-none');
$('#editMessageSuccess').addClass('d-none');
$('#saveEditMessageBtn').prop('disabled', true);
$.ajax({
url: `/api/messages/netmail/${currentMessageId}/edit`,
method: 'POST',
contentType: 'application/json',
data: JSON.stringify({ art_format: artFormat, message_charset: charset }),
}).done(function() {
currentMessageData.art_format = artFormat || null;
currentMessageData.message_charset = charset || null;
const listMsg = currentMessages.find(m => m.id == currentMessageId);
if (listMsg) {
listMsg.art_format = artFormat || null;
}
renderCurrentMessageBody();
$('#editMessageSuccess').removeClass('d-none');
$('#saveEditMessageBtn').prop('disabled', false);
}).fail(function(xhr) {
const payload = xhr.responseJSON || {};
$('#editMessageError').text(window.getApiErrorMessage ? window.getApiErrorMessage(payload, uiT('errors.messages.echomail.edit.save_failed', 'Failed to save changes')) : (payload.error || uiT('errors.messages.echomail.edit.save_failed', 'Failed to save changes'))).removeClass('d-none');
$('#saveEditMessageBtn').prop('disabled', false);
});
}
function composeMessage(type, replyToId = null) {
window.location.href = `/compose/netmail${replyToId ? '?reply=' + replyToId : ''}`;
}
function repostMessage(messageId) {
if (!messageId) {
return;
}
window.location.href = `/compose/netmail?repost=${encodeURIComponent(messageId)}`;
}
function composeMessageToUser(toName, toAddress, subject, alwaysCrashmail) {
// Build URL with parameters for composing to a specific user
const params = new URLSearchParams();
params.set('to_name', toName);
params.set('to', toAddress);
if (subject && subject.trim()) {
// If subject doesn't start with "Re:", add it
const replySubject = subject && subject.toLowerCase().startsWith('re:') ? subject : 'Re: ' + subject;
params.set('subject', replySubject);
}
if (alwaysCrashmail) {
params.set('crashmail', '1');
}
window.location.href = `/compose/netmail?${params.toString()}`;
}
function searchMessages() {
currentConversationMessageId = null;
currentConversationSubject = '';
const query = $('#searchInput').val().trim();
if (query.length < 2) {
showError(uiT('errors.messages.search.query_too_short', 'Please enter at least 2 characters to search'));
return;
}
showLoading('#messagesContainer');
// Store search terms for highlighting
currentSearchTerms = query.toLowerCase().split(/\s+/).filter(term => term.length > 1);
$.get(`/api/messages/search?q=${encodeURIComponent(query)}&type=netmail`)
.done(function(data) {
displayMessages(data.messages);
$('#pagination').empty();
})
.fail(function() {
$('#messagesContainer').html('<div class="p-3 text-danger"><i class="fas fa-exclamation-triangle me-2"></i>' + uiT('ui.netmail.search.failed', 'Search failed') + '</div>');
$('#pagination').empty();
});
}
function openAdvancedSearch() {
$('#advSearchFromName').val('');
$('#advSearchSubject').val('');
$('#advSearchBody').val('');
$('#advSearchDateFrom').val('');
$('#advSearchDateTo').val('');
$('#advSearchError').addClass('d-none').text('');
$('#advancedSearchModal').modal('show');
}
function runAdvancedSearch() {
const fromName = $('#advSearchFromName').val().trim();
const subject = $('#advSearchSubject').val().trim();
const body = $('#advSearchBody').val().trim();
const dateFrom = $('#advSearchDateFrom').val();
const dateTo = $('#advSearchDateTo').val();
const textFields = [fromName, subject, body].filter(v => v.length > 0);
const hasDate = dateFrom || dateTo;
// Validate: at least one field filled, and text fields must be 2+ chars each
if (textFields.length === 0 && !hasDate) {
$('#advSearchError')
.removeClass('d-none')
.text(window.t('ui.common.advanced_search.fill_one_field', {}, 'Please fill in at least one field (minimum 2 characters for text fields).'));
return;
}
if (textFields.some(v => v.length < 2)) {
$('#advSearchError')
.removeClass('d-none')
.text(window.t('ui.common.advanced_search.fill_one_field', {}, 'Please fill in at least one field (minimum 2 characters for text fields).'));
return;
}
$('#advSearchError').addClass('d-none');
$('#advancedSearchModal').modal('hide');
showLoading('#messagesContainer');
// Collect text search terms for highlighting
currentSearchTerms = [fromName, subject, body]
.filter(v => v.length > 0)
.join(' ')
.toLowerCase()
.split(/\s+/)
.filter(term => term.length > 1);
const params = new URLSearchParams({ type: 'netmail' });
if (fromName) params.set('from_name', fromName);
if (subject) params.set('subject', subject);
if (body) params.set('body', body);
if (dateFrom) params.set('date_from', dateFrom);
if (dateTo) params.set('date_to', dateTo);
$.get('/api/messages/search?' + params.toString())
.done(function(data) {
displayMessages(data.messages);
$('#pagination').empty();
})
.fail(function() {
$('#messagesContainer').html('<div class="p-3 text-danger"><i class="fas fa-exclamation-triangle me-2"></i>' + uiT('ui.netmail.search.failed', 'Search failed') + '</div>');
$('#pagination').empty();
});
}
function loadStats() {
console.log('Loading netmail statistics...');
$.get('/api/messages/netmail/stats')
.done(function(data) {
console.log('Netmail stats response:', data);
$('#totalCount').text(data.total || 0);
$('#unreadCount').text(data.unread || 0);
$('#sentCount').text(data.sent || 0);
$('#savedCount').text(data.saved || 0);
const saved = data.saved || 0;
const badge = document.getElementById('savedFilterBadge');
if (badge) {
if (saved > 0) {
badge.textContent = saved;
badge.style.display = '';
} else {
badge.style.display = 'none';
}
}
})
.fail(function(xhr, status, error) {
console.error('Netmail stats loading failed:', xhr.status, status, error);
console.error('Response text:', xhr.responseText);
$('#totalCount').text(uiT('ui.common.error', 'Error'));
$('#unreadCount').text(uiT('ui.common.error', 'Error'));
$('#sentCount').text(uiT('ui.common.error', 'Error'));
$('#savedCount').text(uiT('ui.common.error', 'Error'));
});
}
/**
* Toggle save status of a netmail message (from list row or context menu).
*/
function toggleSaveMessage(messageId, messageType, isSaved) {
if (typeof event !== 'undefined' && event && typeof event.stopPropagation === 'function') {
event.stopPropagation();
}
const method = isSaved ? 'DELETE' : 'POST';
$.ajax({
url: `/api/messages/${messageType}/${messageId}/save`,
method: method,
success: function() {
const icon = $(`.save-btn[data-message-id="${messageId}"]`);
if (isSaved) {
icon.removeClass('text-warning').addClass('text-muted')
.attr('title', uiT('ui.common.save_for_later', 'Save for later'))
.attr('data-saved', 'false')
.attr('onclick', `toggleSaveMessage(${messageId}, '${messageType}', false)`);
showSuccess(uiT('ui.netmail.saved_items.removed', 'Message removed from saved items'));
if (currentFilter === 'saved') {
loadMessages();
}
} else {
icon.removeClass('text-muted').addClass('text-warning')
.attr('title', uiT('ui.common.remove_from_saved', 'Remove from saved'))
.attr('data-saved', 'true')
.attr('onclick', `toggleSaveMessage(${messageId}, '${messageType}', true)`);
showSuccess(uiT('ui.netmail.saved_items.saved', 'Message saved for later'));
}
// Sync current message data
const msg = currentMessages.find(m => Number(m.id) === Number(messageId));
if (msg) {
msg.is_saved = isSaved ? 0 : 1;
}
loadStats();
},
error: function(xhr) {
showError(window.getApiErrorMessage ? window.getApiErrorMessage(xhr.responseJSON, uiT('errors.messages.save.failed', 'Failed to update saved status')) : uiT('errors.messages.save.failed', 'Failed to update saved status'));
}
});
}
/**
* Sync the modal footer save button and header icon to the current saved state.
*/
function updateModalSaveButton(message) {
const saveBtn = $('#modalSaveButton');
const saveText = $('#modalSaveText');
const headerIcon = $('#modalHeaderSaveIcon');
const isSaved = Number(message.is_saved) === 1;
if (isSaved) {
saveBtn.removeClass('btn-outline-warning').addClass('btn-warning');
saveText.text(uiT('ui.common.saved_short', 'Saved'));
saveBtn.attr('title', uiT('ui.common.remove_from_saved', 'Remove from saved'));
headerIcon.removeClass('text-muted').addClass('text-warning');
headerIcon.attr('title', uiT('ui.common.remove_from_saved', 'Remove from saved'));
saveBtn.off('click').on('click', function() {
toggleSaveMessageNetmailModal(message.id, true);
});
headerIcon.off('click').on('click', function() {
toggleSaveMessageNetmailModal(message.id, true);
});
} else {
saveBtn.removeClass('btn-warning').addClass('btn-outline-warning');
saveText.text(uiT('ui.common.save', 'Save'));
saveBtn.attr('title', uiT('ui.common.save_for_later', 'Save for later'));
headerIcon.removeClass('text-warning').addClass('text-muted');
headerIcon.attr('title', uiT('ui.common.save_for_later', 'Save for later'));
saveBtn.off('click').on('click', function() {
toggleSaveMessageNetmailModal(message.id, false);
});
headerIcon.off('click').on('click', function() {
toggleSaveMessageNetmailModal(message.id, false);
});
}
}
/**
* Toggle save state from the message modal (netmail).
*/
function toggleSaveMessageNetmailModal(messageId, isSaved) {
const method = isSaved ? 'DELETE' : 'POST';
$.ajax({
url: `/api/messages/netmail/${messageId}/save`,
method: method,
success: function() {
const newSavedState = isSaved ? 0 : 1;
// Update current message data and re-sync the modal controls
if (currentMessageData && currentMessageData.id == messageId) {
currentMessageData.is_saved = newSavedState;
updateModalSaveButton(currentMessageData);
}
// Sync list row icon
const listIcon = $(`.save-btn[data-message-id="${messageId}"]`);
if (newSavedState) {
listIcon.removeClass('text-muted').addClass('text-warning')
.attr('title', uiT('ui.common.remove_from_saved', 'Remove from saved'))
.attr('data-saved', 'true')
.attr('onclick', `toggleSaveMessage(${messageId}, 'netmail', true)`);
showSuccess(uiT('ui.netmail.saved_items.saved', 'Message saved for later'));
} else {
listIcon.removeClass('text-warning').addClass('text-muted')
.attr('title', uiT('ui.common.save_for_later', 'Save for later'))
.attr('data-saved', 'false')
.attr('onclick', `toggleSaveMessage(${messageId}, 'netmail', false)`);
showSuccess(uiT('ui.netmail.saved_items.removed', 'Message removed from saved items'));
if (currentFilter === 'saved') {
loadMessages();
}
}
// Sync currentMessages array
const msg = currentMessages.find(m => Number(m.id) === Number(messageId));
if (msg) {
msg.is_saved = newSavedState;
}
loadStats();
},
error: function(xhr) {
showError(window.getApiErrorMessage ? window.getApiErrorMessage(xhr.responseJSON, uiT('errors.messages.save.failed', 'Failed to update saved status')) : uiT('errors.messages.save.failed', 'Failed to update saved status'));
}
});
}
/**
* Handle save toggle from context menu.
*/
function saveFromContextMenu() {
if (!currentContextMenuMessageId) {
return;
}
const messageId = currentContextMenuMessageId;
const isSaved = currentContextMenuMessageSaved;
hideMessageContextMenu();
toggleSaveMessage(messageId, 'netmail', isSaved);
}
function deleteMessage(messageId) {
if (!confirm(uiT('ui.netmail.delete_message_confirm', 'Are you sure you want to delete this message? This action cannot be undone.'))) {
return;
}
const msg = currentMessages.find(m => Number(m.id) === Number(messageId)) || currentMessageData;
if (msg && Number(msg.is_saved) === 1) {
if (!confirm(uiT('ui.netmail.delete_saved_confirm', 'This message is saved. Are you sure you want to delete it?'))) {
return;
}
}
$.ajax({
url: `/api/messages/netmail/${messageId}`,
method: 'DELETE',
success: function(data) {
$('#messageModal').modal('hide');
const successMessage = window.getApiMessage
? window.getApiMessage(data, uiT('ui.netmail.message_deleted_success', 'Message deleted successfully'))
: uiT('ui.netmail.message_deleted_success', 'Message deleted successfully');
showSuccess(successMessage);
loadMessages();
loadStats();
},
error: function(xhr) {
const errorMsg = apiError(xhr.responseJSON, uiT('errors.messages.netmail.delete_failed', 'Failed to delete message'));
showError(errorMsg);
}
});
}
function applyNetmailReadStyle(messageId) {
const messageRow = $(`.message-row[data-message-id="${messageId}"]`);
if (!messageRow.length) return;
messageRow.removeClass('table-light');
messageRow.find('.fa-envelope').removeClass('fas fa-envelope text-primary').addClass('far fa-envelope-open text-muted');
messageRow.find('strong.subject-unread').contents().unwrap();
messageRow.find('.fa-envelope-open').attr('title', 'Read');
messageRow.css('opacity', '0.85');
}
// Mark message as read when viewed
function markMessageAsRead(messageId) {
$.post(`/api/messages/netmail/${messageId}/read`)
.done(function() {
applyNetmailReadStyle(messageId);
})
.fail(function() {
console.log('Failed to mark netmail as read');
});
}
function sortMessages(sortBy) {
currentSort = sortBy;
currentPage = 1;
updateSortIndicator();
// Save sort preference
window.saveUserSetting('default_sort', sortBy);
loadMessages();
}
function updateSortIndicator() {
$('.sort-option').each(function() {
const $el = $(this);
$el.find('.sort-active-indicator').remove();
if ($el.data('sort') === currentSort) {
$el.prepend('<i class="fas fa-arrow-right me-2 sort-active-indicator text-primary" style="font-size:0.75em;"></i>');
}
});
}
function toggleThreading() {
threadedView = !threadedView;
currentPage = 1; // Reset to first page when toggling
// Update toggle text
const toggleText = $('#threadingToggleText');
if (threadedView) {
toggleText.text(uiT('ui.common.threading.show_flat', 'Show Flat'));
} else {
toggleText.text(uiT('ui.common.threading.show_threaded', 'Show Threaded'));
}
// Save preference (netmail uses separate setting from echomail)
window.saveUserSetting('netmail_threaded_view', threadedView);
loadMessages();
}
// User settings functions - apply netmail-specific settings after loading
function loadNetmailSettings() {
const settingsPromise = typeof window.loadUserSettings === 'function'
? window.loadUserSettings().then(function() {
// Apply netmail-specific settings
userSettings = window.userSettings;
if (userSettings.netmail_threaded_view !== undefined) {
threadedView = userSettings.netmail_threaded_view;
const toggleText = $('#threadingToggleText');
if (threadedView) {
toggleText.text(uiT('ui.common.threading.show_flat', 'Show Flat'));
} else {
toggleText.text(uiT('ui.common.threading.show_threaded', 'Show Threaded'));
}
}
if (userSettings.default_sort) {
currentSort = userSettings.default_sort;
}
updateSortIndicator();
})
: Promise.resolve();
// Load last visited netmail page from DB (only if setting is enabled)
const pagePromise = $.get('/api/user/web-mail-state')
.then(function(data) {
if (!window.userSettings || !window.userSettings.remember_page_position) return;
if (data && data.settings && data.settings.web_netmail_page) {
const page = parseInt(data.settings.web_netmail_page, 10);
if (page >= 1) {
currentPage = page;
}
}
})
.catch(function() {});
return Promise.all([settingsPromise, pagePromise]);
}
/**
* Persist the current netmail page to the DB (fire-and-forget).
*/
function saveNetmailPage() {
if (!window.userSettings || !window.userSettings.remember_page_position) return;
$.ajax({
url: '/api/user/web-mail-state',
method: 'POST',
data: JSON.stringify({ web_netmail_page: currentPage }),
contentType: 'application/json'
});
}
// Use global settings functions directly - no local wrappers needed
// All calls to saveUserSetting and saveUserSettings will use window.* functions
// Address Book Functions
function loadAddressBook(search = '') {
$.get('/api/address-book', { search: search })
.done(function(response) {
if (response.success) {
renderAddressBook(response.entries);
$('#addressBookStats').text(`${response.entries.length} ${uiT('ui.address_book.entries', 'entries')}`);
} else {
$('#addressBookList').html(`<div class="text-danger py-2">${uiT('ui.address_book.load_failed', 'Failed to load address book')}</div>`);
}
})
.fail(function() {
$('#addressBookList').html(`<div class="text-danger py-2">${uiT('ui.address_book.load_failed', 'Failed to load address book')}</div>`);
});
}
function renderAddressBook(entries) {
const container = $('#addressBookList');
let html = '';
if (entries.length === 0) {
html = `<div class="text-center text-muted py-2">${uiT('ui.address_book.no_entries_found', 'No entries found')}</div>`;
} else {
entries.forEach(function(entry) {
html += `
<div class="d-flex justify-content-between align-items-start mb-2 p-2 border rounded address-book-entry"
style="cursor: pointer;" onclick="composeToAddressBookEntry('${escapeHtml(entry.messaging_user_id || '')}', '${escapeHtml(entry.node_address || '')}', ${entry.always_crashmail ? 'true' : 'false'})">
<div class="flex-grow-1">
<div class="fw-bold small">${escapeHtml(entry.name || uiT('ui.address_book.unnamed', 'Unnamed'))}</div>
<div class="text-primary small">@${escapeHtml(entry.messaging_user_id || uiT('ui.common.unknown', 'Unknown'))}</div>
<div class="text-muted small font-monospace">${escapeHtml(entry.node_address || uiT('ui.address_book.no_address', 'No address'))}</div>
${entry.description ? `<div class="text-muted smaller">${escapeHtml(entry.description.substring(0, 30) + (entry.description.length > 30 ? '...' : ''))}</div>` : ''}
</div>
<div class="dropdown">
<button class="btn btn-sm btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" onclick="event.stopPropagation();">
<i class="fas fa-ellipsis-v"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="#" onclick="event.stopPropagation(); editAddressBookEntry(${entry.id});">
<i class="fas fa-edit"></i> ${uiT('ui.common.edit', 'Edit')}
</a></li>
<li><a class="dropdown-item text-danger" href="#" onclick="event.stopPropagation(); deleteAddressBookEntry(${entry.id}, '${escapeHtml(entry.name || uiT('ui.address_book.unnamed', 'Unnamed'))}');">
<i class="fas fa-trash"></i> ${uiT('ui.common.delete', 'Delete')}
</a></li>
</ul>
</div>
</div>
`;
});
}
container.html(html);
}
function showAddAddressModal() {
$('#addressBookModalTitle').text(uiT('ui.address_book.add_entry', 'Add Address Book Entry'));
$('#addressBookEntryId').val('');
$('#addressBookForm')[0].reset();
clearAddressBookModalError();
$('#addressBookModal').modal('show');
}
function showAddressBookModalError(message) {
const fallback = uiT('errors.address_book.create_failed', 'Failed to save entry');
const text = (typeof message === 'string' && message.trim() !== '') ? message : fallback;
const errorEl = $('#addressBookModalError');
if (errorEl.length > 0) {
errorEl.text(text).removeClass('d-none');
}
// Keep global alert as secondary visibility outside modal context.
showError(text);
}
function clearAddressBookModalError() {
const errorEl = $('#addressBookModalError');
if (errorEl.length > 0) {
errorEl.addClass('d-none').text('');
}
}
function editAddressBookEntry(entryId) {
clearAddressBookModalError();
$.get(`/api/address-book/${entryId}`)
.done(function(response) {
if (response.success) {
const entry = response.entry;
$('#addressBookModalTitle').text(uiT('ui.address_book.edit_entry', 'Edit Address Book Entry'));
$('#addressBookEntryId').val(entry.id);
$('#addressBookName').val(entry.name);
$('#addressBookUserId').val(entry.messaging_user_id);
$('#addressBookNodeAddress').val(entry.node_address);
$('#addressBookEmail').val(entry.email || '');
$('#addressBookDescription').val(entry.description || '');
$('#addressBookAlwaysCrashmail').prop('checked', !!entry.always_crashmail);
$('#addressBookModal').modal('show');
} else {
showError(uiT('ui.netmail.address_book.load_entry_failed_prefix', 'Failed to load entry: ') + apiError(response, uiT('ui.common.unknown_error', 'Unknown error')));
}
})
.fail(function() {
showError(uiT('errors.address_book.get_failed', 'Failed to load entry'));
});
}
function saveAddressBookEntry() {
clearAddressBookModalError();
const entryId = $('#addressBookEntryId').val();
const data = {
name: $('#addressBookName').val().trim(),
messaging_user_id: $('#addressBookUserId').val().trim(),
node_address: $('#addressBookNodeAddress').val().trim(),
email: $('#addressBookEmail').val().trim(),
description: $('#addressBookDescription').val().trim(),
always_crashmail: $('#addressBookAlwaysCrashmail').is(':checked'),
};
// Basic validation
if (!data.name || !data.messaging_user_id || !data.node_address) {
showAddressBookModalError(uiT('errors.address_book.required_fields', 'Name, user ID, and node address are required'));
return;
}
const url = entryId ? `/api/address-book/${entryId}` : '/api/address-book/';
const method = entryId ? 'PUT' : 'POST';
$.ajax({
url: url,
method: method,
contentType: 'application/json',
data: JSON.stringify(data),
success: function(response) {
if (response.success) {
$('#addressBookModal').modal('hide');
loadAddressBook();
const defaultMessage = entryId
? uiT('ui.address_book.entry_updated', 'Entry updated successfully')
: uiT('ui.compose.address_book.entry_added', 'Entry added successfully');
const successMessage = window.getApiMessage
? window.getApiMessage(response, defaultMessage)
: defaultMessage;
showSuccess(successMessage);
} else {
showAddressBookModalError(apiError(response, uiT('errors.address_book.create_failed', 'Failed to save entry')));
}
},
error: function(xhr) {
const payload = (xhr && xhr.responseJSON) ? xhr.responseJSON : null;
showAddressBookModalError(apiError(payload, uiT('errors.address_book.create_failed', 'Failed to save entry')));
}
});
}
function deleteAddressBookEntry(entryId, entryName) {
if (!confirm(uiT('ui.address_book.delete_confirm', `Are you sure you want to delete "${entryName}" from your address book?`, { name: entryName }))) {
return;
}
$.ajax({
url: `/api/address-book/${entryId}`,
method: 'DELETE',
success: function(response) {
if (response.success) {
loadAddressBook();
const successMessage = window.getApiMessage
? window.getApiMessage(response, uiT('ui.address_book.entry_deleted', 'Entry deleted successfully'))
: uiT('ui.address_book.entry_deleted', 'Entry deleted successfully');
showSuccess(successMessage);
} else {
showError(apiError(response, uiT('errors.address_book.delete_failed', 'Failed to delete entry')));
}
},
error: function() {
showError(uiT('errors.address_book.delete_failed', 'Failed to delete entry'));
}
});
}
function composeToAddressBookEntry(messagingUserId, nodeAddress, alwaysCrashmail) {
composeMessageToUser(messagingUserId, nodeAddress, '', alwaysCrashmail);
}
// Save sender to address book from message modal
function saveToAddressBook(fromName, fromAddress, originalFromName, originalFromAddress) {
const button = $('#saveAddressBookBtn');
const originalHtml = button.html();
const originalTitle = button.attr('title');
// Show loading state
button.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i>').attr('title', 'Saving...');
// First check if this contact already exists
$.get('/api/address-book', { search: fromName })
.done(function(response) {
if (response.success) {
// Check if contact already exists
const existingEntry = response.entries.find(entry =>
entry.messaging_user_id && fromName &&
entry.messaging_user_id.toLowerCase() === fromName.toLowerCase() &&
entry.node_address === fromAddress
);
if (existingEntry) {
// Already exists - show "already saved" state
button.removeClass('btn-outline-success').addClass('btn-outline-secondary')
.html('<i class="fas fa-check"></i> <i class="fas fa-address-book"></i>')
.attr('title', uiT('ui.common.already_in_address_book', 'Already in address book'))
.prop('disabled', true);
showError(uiT('ui.address_book.already_exists', 'This contact is already in your address book'));
return;
}
// Contact doesn't exist, create new entry
// Build description with reference information
let description = uiT('ui.address_book.added_from_netmail', 'Added from netmail message');
if (originalFromName && originalFromAddress) {
if (fromName !== originalFromName || fromAddress !== originalFromAddress) {
// REPLYTO was used - show both original and reply-to info
description = uiT(
'ui.address_book.added_from_netmail_replyto_detail',
'Added from netmail message. Original sender: {original_name} ({original_address}), Reply-to: {replyto_name} ({replyto_address})',
{
original_name: originalFromName,
original_address: originalFromAddress,
replyto_name: fromName,
replyto_address: fromAddress
}
);
} else {
// No REPLYTO - just show sender info
description = uiT(
'ui.address_book.added_from_netmail_sender_detail',
'Added from netmail message. Sender: {sender_name} ({sender_address})',
{
sender_name: originalFromName,
sender_address: originalFromAddress
}
);
}
}
const data = {
name: originalFromName || fromName, // Use original name for description
messaging_user_id: fromName, // Use the reply-to name for messaging
node_address: fromAddress,
description: description
};
$.ajax({
url: '/api/address-book/',
method: 'POST',
contentType: 'application/json',
data: JSON.stringify(data),
success: function(response) {
if (response.success) {
// Show success state
button.removeClass('btn-outline-success').addClass('btn-success')
.html(`<i class="fas fa-check"></i> ${uiT('ui.common.saved_short', 'Saved')}`)
.attr('title', uiT('ui.address_book.saved_to_address_book', 'Saved to address book'))
.prop('disabled', true);
showSuccess(uiT('ui.address_book.sender_added', `${fromName} added to address book`, { name: fromName }));
// Refresh address book in sidebar if it exists
if (typeof loadAddressBook === 'function') {
loadAddressBook();
}
} else {
// Reset button on error
button.removeClass('btn-outline-success').addClass('btn-outline-danger')
.html(originalHtml)
.attr('title', uiT('ui.common.error_click_retry', 'Error - click to retry'))
.prop('disabled', false);
showError(apiError(response, uiT('errors.address_book.create_failed', 'Failed to save to address book')));
}
},
error: function(xhr) {
// Reset button on error
button.removeClass('btn-outline-success').addClass('btn-outline-danger')
.html(originalHtml)
.attr('title', uiT('ui.common.error_click_retry', 'Error - click to retry'))
.prop('disabled', false);
showError(apiError(xhr.responseJSON, uiT('errors.address_book.create_failed', 'Failed to save to address book')));
}
});
} else {
// Reset button on error
button.html(originalHtml).attr('title', originalTitle).prop('disabled', false);
showError(uiT('ui.address_book.check_existing_failed', 'Failed to check existing contacts'));
}
})
.fail(function() {
// Reset button on error
button.html(originalHtml).attr('title', originalTitle).prop('disabled', false);
showError(uiT('ui.address_book.check_existing_failed', 'Failed to check existing contacts'));
});
}
// A typical debounce implementation
function debounce(func, delay) {
let timeout;
return function(...args) {
const context = this; // Capture the correct 'this'
clearTimeout(timeout);
timeout = setTimeout(() => {
func.apply(context, args); // Apply the captured context
}, delay);
};
}
// Draft management functions
function continueDraft(draftId) {
// Navigate to compose page with draft data
window.location.href = `/compose/netmail?draft=${draftId}`;
}
function deleteDraftConfirm(draftId) {
if (confirm(uiT('ui.drafts.delete_confirm', 'Are you sure you want to delete this draft? This cannot be undone.'))) {
deleteDraft(draftId);
}
}
function deleteDraft(draftId) {
$.ajax({
url: `/api/messages/drafts/${draftId}`,
method: 'DELETE',
success: function(response) {
if (response.success) {
// Reload drafts to show updated list
loadDrafts();
const successMessage = window.getApiMessage
? window.getApiMessage(response, uiT('ui.drafts.deleted_success', 'Draft deleted successfully'))
: uiT('ui.drafts.deleted_success', 'Draft deleted successfully');
showSuccess(successMessage);
} else {
showError(uiT('errors.messages.drafts.delete_failed', 'Failed to delete draft'));
}
},
error: function() {
showError(uiT('errors.messages.drafts.delete_failed', 'Failed to delete draft'));
}
});
}
/**
* Format file size in human-readable format
* @param {number} bytes File size in bytes
* @returns {string} Formatted file size (e.g., "1.5 MB")
*/
function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
if (!bytes) return uiT('ui.common.unknown_size', 'Unknown size');
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
}
function navigateMessage(direction) {
if (currentMessages.length === 0) return;
const newIndex = currentMessageIndex + direction;
// Check bounds
if (newIndex < 0) return;
if (newIndex >= currentMessages.length) {
if (direction > 0 && currentPagination && currentPagination.page < currentPagination.pages) {
currentPage = currentPagination.page + 1;
loadMessages(function() {
if (currentMessages.length > 0) {
viewMessage(currentMessages[0].id);
}
});
}
return;
}
// Get the new message
const newMessage = currentMessages[newIndex];
if (!newMessage) return;
// Update current message info
currentMessageId = newMessage.id;
currentMessageIndex = newIndex;
// Update navigation buttons
updateNavigationButtons();
// Mark as read immediately
markMessageAsRead(newMessage.id);
// Show loading
$('#messageContent').html(`
<div class="loading-spinner">
<i class="fas fa-spinner fa-spin me-2"></i>
${uiT('ui.common.loading_message', 'Loading message...')}
</div>
`);
// Load the new message
$.get(`/api/messages/netmail/${newMessage.id}`)
.done(function(data) {
displayMessageContent(data);
// Auto-scroll to top of modal content
$('#messageModal .modal-body').scrollTop(0);
})
.fail(function() {
$('#messageContent').html(`<div class="text-danger">${uiT('errors.messages.netmail.get_failed', 'Failed to load message')}</div>`);
});
}
function updateNavigationButtons() {
const prevBtn = $('#prevMessageBtn');
const nextBtn = $('#nextMessageBtn');
if (currentMessageIndex < 0) {
prevBtn.prop('disabled', true);
nextBtn.prop('disabled', true);
prevBtn.attr('title', uiT('ui.common.previous_message', 'Previous message'));
nextBtn.attr('title', uiT('ui.common.next_message', 'Next message'));
return;
}
// Disable/enable previous button
if (currentMessageIndex <= 0) {
prevBtn.prop('disabled', true);
} else {
prevBtn.prop('disabled', false);
}
// Disable/enable next button
const atEnd = currentMessageIndex >= currentMessages.length - 1;
if (atEnd) {
const hasNextPage = currentPagination && currentPagination.page < currentPagination.pages;
nextBtn.prop('disabled', !hasNextPage);
nextBtn.attr('title', hasNextPage
? uiT('ui.netmail.next_page_title', 'Load next page')
: uiT('ui.common.next_message', 'Next message'));
} else {
nextBtn.prop('disabled', false);
nextBtn.attr('title', uiT('ui.common.next_message', 'Next message'));
}
}
function openRequestedMessage() {
if (!requestedMessageId) {
return;
}
const messageId = requestedMessageId;
requestedMessageId = null;
viewMessage(messageId);
}
function toggleSelectMode() {
selectMode = !selectMode;
const btn = $('#selectModeBtn');
const checkboxColumn = $('#selectAllColumn');
const checkboxCells = $('.message-checkbox');
const bulkActions = $('#bulkActions');
if (selectMode) {
// Enable select mode
btn.html(`<i class="fas fa-times"></i> ${uiT('ui.common.cancel', 'Cancel')}`);
btn.removeClass('btn-outline-secondary').addClass('btn-outline-warning');
checkboxColumn.removeClass('d-none');
checkboxCells.removeClass('d-none');
bulkActions.removeClass('d-none');
} else {
// Disable select mode
btn.html(`<i class="fas fa-check-square"></i> ${uiT('ui.common.select', 'Select')}`);
btn.removeClass('btn-outline-warning').addClass('btn-outline-secondary');
checkboxColumn.addClass('d-none');
checkboxCells.addClass('d-none');
bulkActions.addClass('d-none');
clearSelection();
}
}
function toggleSelectAll() {
const selectAllCheckbox = $('#selectAllMessages');
const messageCheckboxes = $('.message-select');
if (selectAllCheckbox.prop('checked')) {
messageCheckboxes.prop('checked', true);
messageCheckboxes.each(function() {
selectedMessages.add(parseInt($(this).val()));
});
} else {
messageCheckboxes.prop('checked', false);
selectedMessages.clear();
}
updateSelectionDisplay();
}
function updateSelection() {
const messageCheckboxes = $('.message-select');
const checkedBoxes = $('.message-select:checked');
// Update selected messages set
selectedMessages.clear();
checkedBoxes.each(function() {
selectedMessages.add(parseInt($(this).val()));
});
// Update select all checkbox
const selectAllCheckbox = $('#selectAllMessages');
if (checkedBoxes.length === 0) {
selectAllCheckbox.prop('indeterminate', false);
selectAllCheckbox.prop('checked', false);
} else if (checkedBoxes.length === messageCheckboxes.length) {
selectAllCheckbox.prop('indeterminate', false);
selectAllCheckbox.prop('checked', true);
} else {
selectAllCheckbox.prop('indeterminate', true);
selectAllCheckbox.prop('checked', false);
}
updateSelectionDisplay();
}
function updateSelectionDisplay() {
const count = selectedMessages.size;
$('#selectedCount').text(count);
if (count === 0) {
$('#bulkActions .btn-outline-danger').prop('disabled', true);
} else {
$('#bulkActions .btn-outline-danger').prop('disabled', false);
}
}
function clearSelection() {
selectedMessages.clear();
$('.message-select').prop('checked', false);
$('#selectAllMessages').prop('checked', false).prop('indeterminate', false);
updateSelectionDisplay();
if (selectMode) {
toggleSelectMode();
}
}
function deleteSelectedMessages() {
if (selectedMessages.size === 0) {
showError(uiT('ui.messages.none_selected', 'No messages selected'));
return;
}
if (!confirm(uiT('ui.netmail.bulk_delete.confirm', `Are you sure you want to delete ${selectedMessages.size} message(s)?`, { count: selectedMessages.size }))) {
return;
}
const messageIds = Array.from(selectedMessages);
const savedCount = messageIds.filter(id => {
const msg = currentMessages.find(m => Number(m.id) === Number(id));
return msg && Number(msg.is_saved) === 1;
}).length;
if (savedCount > 0) {
if (!confirm(uiT('ui.netmail.bulk_delete.saved_confirm', `{count} of the selected messages are saved. Are you sure you want to delete them?`, { count: savedCount }))) {
return;
}
}
$.ajax({
url: '/api/messages/netmail/bulk-delete',
method: 'POST',
contentType: 'application/json',
data: JSON.stringify({ message_ids: messageIds }),
success: function(data) {
const deletedCount = data.deleted || messageIds.length;
const successMessage = window.getApiMessage
? window.getApiMessage(data, uiT('ui.netmail.bulk_delete.success', `Deleted ${deletedCount} message(s)`, { count: deletedCount }))
: uiT('ui.netmail.bulk_delete.success', `Deleted ${deletedCount} message(s)`, { count: deletedCount });
showSuccess(successMessage);
clearSelection();
loadMessages();
},
error: function(xhr) {
const error = apiError(xhr.responseJSON, uiT('ui.netmail.bulk_delete.failed', 'Failed to delete messages'));
showError(error);
}
});
}
function downloadCurrentMessage() {
if (!currentMessageId) {
return;
}
window.location.href = `/api/messages/netmail/${encodeURIComponent(currentMessageId)}/download`;
}
function toggleKeyboardHelp() {
keyboardHelpVisible = !keyboardHelpVisible;
$('#keyboardHelpOverlay').toggle(keyboardHelpVisible);
}
function hideKeyboardHelp() {
keyboardHelpVisible = false;
$('#keyboardHelpOverlay').hide();
}
|