1227 lines
74 KiB
HTML
1227 lines
74 KiB
HTML
{{define "webmail_folder.html"}}
|
|
<!DOCTYPE html>
|
|
<html lang="en" data-bs-theme="dark">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>{{if .search_query}}Search: {{.search_query}}{{else}}{{.active_folder}}{{end}} - Webmail</title>
|
|
<link href="/webmail/static/vendor/bootstrap/css/bootstrap.min.css" rel="stylesheet">
|
|
<link href="/webmail/static/vendor/bootstrap-icons/font/bootstrap-icons.css" rel="stylesheet">
|
|
<link rel="icon" type="image/x-icon" href="/webmail/static/img/favicon-webmail.ico">
|
|
<style>
|
|
html, body { background-color: #1a1a1a; color: #e0e0e0; height: 100%; }
|
|
.card { background-color: #2d2d2d; border: 1px solid #404040; }
|
|
.folder-link.active { background-color: #0d6efd; color: #fff !important; }
|
|
.msg-row { cursor: grab; }
|
|
.msg-row.dragging { opacity: 0.4; }
|
|
.folder-link.drop-hover { background-color: #0d6efd; color: #fff !important; outline: 2px dashed #6ea8fe; outline-offset: -2px; }
|
|
.folder-unread-badge { font-size: .7rem; }
|
|
.msg-row-older { display: none; }
|
|
.msg-group-toggle { cursor: pointer; }
|
|
/* A search result can span multiple folders, and the bulk-action endpoint is
|
|
scoped to one folder path — rather than a bulk action silently no-oping on
|
|
every row from a different folder, selection/bulk actions are just not
|
|
offered while searching (per-row actions in the reading pane still work). */
|
|
.search-mode .msg-check, .search-mode #selectAllCheck, .search-mode .bulk-btn { display: none; }
|
|
|
|
/* Outlook-style three-pane shell: fixed-width sidebar + fixed-width list +
|
|
flexible reading pane, instead of a responsive 12-column grid — this is
|
|
deliberately a fixed desktop layout to match the reference, not a
|
|
mobile-first one. */
|
|
.mail-shell { display: flex; align-items: stretch; height: calc(100vh - 56px); overflow: hidden; }
|
|
.mail-sidebar { width: 230px; flex: 0 0 auto; overflow-y: auto; border-right: 1px solid #404040; padding: .75rem; }
|
|
.mail-content { display: flex; flex: 1 1 auto; min-width: 0; overflow: hidden; }
|
|
.mail-list-pane { width: 380px; flex: 0 0 auto; overflow-y: auto; border-right: 1px solid #404040; display: flex; flex-direction: column; }
|
|
.mail-reading-pane { flex: 1 1 auto; overflow-y: auto; padding: 1.5rem; min-width: 0; }
|
|
.mail-toolbar { flex: 0 0 auto; padding: .5rem .75rem; border-bottom: 1px solid #404040; display: flex; align-items: center; gap: .35rem; flex-wrap: wrap; }
|
|
.mail-list-scroll { flex: 1 1 auto; overflow-y: auto; }
|
|
/* "Reading pane below the list" alternate layout (Outlook's View > Reading
|
|
Pane > Bottom) — a client-side-only preference (localStorage, see the JS
|
|
below), same pattern as the sidebar-hide/folder-collapse toggles already on
|
|
this page. Default (nothing stored yet) is the side-by-side layout. */
|
|
.mail-content.layout-below { flex-direction: column; }
|
|
.mail-content.layout-below .mail-list-pane { width: auto; height: 42%; flex: 0 0 auto; border-right: none; border-bottom: 1px solid #404040; }
|
|
.mail-content.layout-below .mail-reading-pane { flex: 1 1 auto; }
|
|
.new-mail-banner { display: none; padding: .4rem .75rem; background-color: #0d3860; color: #fff; font-size: .85rem; cursor: pointer; text-align: center; }
|
|
.new-mail-banner.show { display: block; }
|
|
.mail-list-header { padding: .35rem .75rem; font-size: .75rem; text-transform: uppercase; color: #8a8a8a; display: flex; justify-content: space-between; }
|
|
|
|
.msg-item { display: flex; align-items: flex-start; gap: .6rem; padding: .55rem .75rem; border-bottom: 1px solid #333; cursor: pointer; }
|
|
.msg-item:hover { background-color: #262626; }
|
|
.msg-item.active { background-color: #0d3860; }
|
|
.msg-item:has(.msg-check:checked) { background-color: #2a2f36; }
|
|
.msg-item.unread .msg-subject { font-weight: 700; color: #fff; }
|
|
.msg-item.unread .msg-from { font-weight: 700; color: #fff; }
|
|
.msg-check { margin-top: .35rem; flex: 0 0 auto; }
|
|
.msg-avatar { width: 34px; height: 34px; border-radius: 50%; background: #495057; color: #fff; display: flex; align-items: center; justify-content: center; font-size: .85rem; font-weight: 600; flex: 0 0 auto; }
|
|
.msg-item.unread .msg-avatar { background: #0d6efd; }
|
|
.msg-main { min-width: 0; flex: 1 1 auto; }
|
|
.msg-row1 { display: flex; align-items: center; gap: .4rem; }
|
|
.msg-from { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
.msg-star { flex: 0 0 auto; cursor: pointer; color: #6a6a6a; }
|
|
.msg-star.bi-star-fill { color: #f4c150; }
|
|
.msg-star:hover { color: #f4c150; }
|
|
.msg-date { flex: 0 0 auto; font-size: .75rem; color: #8a8a8a; margin-left: auto; }
|
|
.msg-row2 { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: .85rem; color: #adb5bd; }
|
|
.msg-subject { color: #e0e0e0; }
|
|
|
|
#folderContextMenu, #messageContextMenu { z-index: 1085; }
|
|
.dropdown-submenu { position: relative; }
|
|
.dropdown-submenu > .dropdown-menu { top: 0; left: 100%; margin-left: 2px; display: none; }
|
|
.dropdown-submenu:hover > .dropdown-menu { display: block; }
|
|
.folder-row { display: flex; align-items: center; }
|
|
.folder-drag-handle { cursor: grab; color: #6a6a6a; padding: 0 .25rem 0 0; flex: 0 0 auto; }
|
|
.folder-drag-handle:hover { color: #adb5bd; }
|
|
.folder-row.folder-row-dragging { opacity: 0.4; }
|
|
.folder-expand-toggle { flex: 0 0 auto; width: 16px; text-align: center; cursor: pointer; color: #8a8a8a; font-size: .8rem; transition: transform .1s; }
|
|
.folder-expand-toggle:hover { color: #fff; }
|
|
.folder-expand-toggle.collapsed { transform: rotate(-90deg); }
|
|
.folder-expand-spacer { flex: 0 0 auto; width: 16px; }
|
|
.folder-children { margin-left: 16px; }
|
|
.folder-children.collapsed { display: none; }
|
|
|
|
.msg-body-html { background-color: #fff; color: #000; border-radius: 6px; padding: 1rem; overflow-wrap: break-word; word-break: break-word; overflow-x: auto; max-width: 100%; }
|
|
.msg-body-html img { max-width: 100%; height: auto; }
|
|
.msg-body-html table { max-width: 100%; }
|
|
.msg-body-text { white-space: pre-wrap; word-break: break-word; overflow-wrap: break-word; }
|
|
#readingPaneBody .pane-toolbar { border-bottom: 1px solid #404040; padding-bottom: .75rem; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
{{template "csrf_script" .}}
|
|
<nav class="navbar navbar-expand-lg navbar-dark bg-dark px-3" style="height: 56px;">
|
|
<span class="navbar-brand mb-0 h1"><i class="bi bi-envelope-fill me-2"></i>Webmail <small class="text-muted">{{.mailbox.Email}}</small></span>
|
|
<form method="get" action="/webmail/mail/search" class="mx-auto" style="width: 360px;">
|
|
<div class="input-group input-group-sm">
|
|
<span class="input-group-text bg-body-secondary border-0"><i class="bi bi-search"></i></span>
|
|
<input type="search" name="q" id="mailSearchInput" class="form-control" placeholder="Search all mail" value="{{.search_query}}">
|
|
</div>
|
|
</form>
|
|
<div class="navbar-nav flex-row gap-2 ms-auto">
|
|
<button type="button" onclick="openCompose('/webmail/mail/compose')" class="btn btn-primary btn-sm"><i class="bi bi-pencil-square me-1"></i>Compose</button>
|
|
<a href="/webmail/calendar" class="btn btn-outline-light btn-sm"><i class="bi bi-calendar3 me-1"></i>Calendar</a>
|
|
<a href="/webmail/account" class="btn btn-outline-light btn-sm" title="Settings"><i class="bi bi-gear"></i></a>
|
|
{{template "webmail_account_menu" .}}
|
|
</div>
|
|
</nav>
|
|
|
|
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 1090;">
|
|
{{range .flashes}}
|
|
<div class="toast align-items-center text-bg-{{if eq .Category "error"}}danger{{else}}{{.Category}}{{end}} border-0" role="alert" aria-live="assertive" aria-atomic="true">
|
|
<div class="d-flex">
|
|
<div class="toast-body">
|
|
<i class="bi bi-{{if eq .Category "error"}}exclamation-triangle{{else if eq .Category "success"}}check-circle{{else}}info-circle{{end}} me-2"></i>
|
|
{{.Message}}
|
|
</div>
|
|
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
|
|
</div>
|
|
</div>
|
|
{{end}}
|
|
</div>
|
|
|
|
<div class="mail-shell">
|
|
<div class="mail-sidebar" id="folderSidebarCol">
|
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
|
<span class="text-muted small text-uppercase">Folders</span>
|
|
<div>
|
|
<button type="button" class="btn btn-sm btn-outline-secondary border-0 py-0 px-1" id="expandAllBtn" title="Expand all"><i class="bi bi-arrows-expand"></i></button>
|
|
<button type="button" class="btn btn-sm btn-outline-secondary border-0 py-0 px-1" id="collapseAllBtn" title="Collapse all"><i class="bi bi-arrows-collapse"></i></button>
|
|
<button type="button" class="btn btn-sm btn-outline-secondary border-0 py-0" id="sidebarCollapseBtn" title="Hide folder list"><i class="bi bi-chevron-bar-left"></i></button>
|
|
</div>
|
|
</div>
|
|
<div class="list-group list-group-flush" id="folderList">
|
|
{{range .folder_tree}}{{template "folderTreeNode" .}}{{end}}
|
|
</div>
|
|
<div class="text-muted small mt-2" style="font-size: .72rem;">Right-click a folder for more actions</div>
|
|
</div>
|
|
|
|
<div id="folderContextMenu" class="dropdown-menu" style="display: none; position: fixed;">
|
|
<button type="button" class="dropdown-item" data-menu-action="new"><i class="bi bi-folder-plus me-2"></i>New folder</button>
|
|
<button type="button" class="dropdown-item" data-menu-action="mark-all-read"><i class="bi bi-envelope-open me-2"></i>Mark all as read</button>
|
|
<button type="button" class="dropdown-item" data-menu-action="rename"><i class="bi bi-pencil me-2"></i>Rename</button>
|
|
<button type="button" class="dropdown-item text-danger" data-menu-action="empty-trash"><i class="bi bi-trash3 me-2"></i>Empty Trash</button>
|
|
<button type="button" class="dropdown-item text-danger" data-menu-action="clean-spam"><i class="bi bi-shield-x me-2"></i>Clean up Junk</button>
|
|
<button type="button" class="dropdown-item" data-menu-action="restore-folder"><i class="bi bi-arrow-counterclockwise me-2"></i>Restore</button>
|
|
<button type="button" class="dropdown-item text-danger" data-menu-action="delete-folder-permanently"><i class="bi bi-trash3-fill me-2"></i>Delete permanently</button>
|
|
</div>
|
|
|
|
<form method="post" id="folderActionForm" class="d-none"></form>
|
|
|
|
<div class="mail-content" id="mailContent">
|
|
<div class="mail-list-pane{{if .search_query}} search-mode{{end}}" id="messageListCol">
|
|
<div class="mail-toolbar">
|
|
<button type="button" class="btn btn-sm btn-outline-secondary border-0 py-0 d-none" id="sidebarShowBtn" title="Show folder list"><i class="bi bi-chevron-bar-right"></i></button>
|
|
<input type="checkbox" class="form-check-input" id="selectAllCheck" title="Select all">
|
|
<div class="btn-group btn-group-sm">
|
|
<button type="button" class="btn btn-outline-secondary bulk-btn" data-action="delete" disabled title="Delete"><i class="bi bi-trash"></i></button>
|
|
<button type="button" class="btn btn-outline-secondary bulk-btn" data-action="read" disabled title="Mark as read"><i class="bi bi-envelope-open"></i></button>
|
|
<button type="button" class="btn btn-outline-secondary bulk-btn" data-action="unread" disabled title="Mark as unread"><i class="bi bi-envelope"></i></button>
|
|
</div>
|
|
{{if not .search_query}}
|
|
<select class="form-select form-select-sm bulk-move-select" id="bulkMoveSelect" disabled style="width: auto;" title="Move selected to…">
|
|
<option value="">Move to…</option>
|
|
{{$folder := .active_folder}}
|
|
{{range .folders}}{{if ne . $folder}}<option value="{{.}}">{{.}}</option>{{end}}{{end}}
|
|
</select>
|
|
{{end}}
|
|
<button type="button" class="btn btn-sm btn-outline-secondary border-0" onclick="location.reload()" title="Refresh"><i class="bi bi-arrow-clockwise"></i></button>
|
|
<button type="button" class="btn btn-sm btn-outline-secondary border-0 py-0" id="layoutToggleBtn" title="Move reading pane below the list"><i class="bi bi-layout-split"></i></button>
|
|
<button type="button" class="btn btn-sm btn-outline-secondary border-0 py-0 d-none" id="notifyPermBtn" title="Enable new-mail notifications"><i class="bi bi-bell"></i></button>
|
|
<div class="ms-auto d-flex align-items-center gap-1">
|
|
{{if not .search_query}}
|
|
<a href="{{.sort_from_href}}" class="btn btn-sm btn-outline-secondary border-0 py-0" title="Sort by sender"><i class="bi bi-person{{if eq .sort_by "from"}}-fill{{end}}"></i>{{if eq .sort_by "from"}} <i class="bi bi-caret-{{if eq .sort_dir "asc"}}up{{else}}down{{end}}-fill"></i>{{end}}</a>
|
|
<a href="{{.sort_date_href}}" class="btn btn-sm btn-outline-secondary border-0 py-0" title="Sort by date"><i class="bi bi-calendar3{{if ne .sort_by "from"}}-fill{{end}}"></i>{{if ne .sort_by "from"}} <i class="bi bi-caret-{{if eq .sort_dir "asc"}}up{{else}}down{{end}}-fill"></i>{{end}}</a>
|
|
<a href="{{.unread_only_href}}" class="btn btn-sm border-0 py-0 {{if .unread_only}}btn-primary{{else}}btn-outline-secondary{{end}}" title="Unread only"><i class="bi bi-envelope-fill"></i></a>
|
|
<a href="{{.starred_only_href}}" class="btn btn-sm border-0 py-0 {{if .starred_only}}btn-warning{{else}}btn-outline-secondary{{end}}" title="Favourites only"><i class="bi bi-star-fill"></i></a>
|
|
{{end}}
|
|
</div>
|
|
</div>
|
|
<div class="new-mail-banner" id="newMailBanner"></div>
|
|
<div class="mail-list-header">
|
|
<span>{{if .search_query}}Search: “{{.search_query}}”{{else}}{{.active_folder}}{{end}}</span>
|
|
<span id="mailListTotal">{{.total}} message{{if ne .total 1}}s{{end}}</span>
|
|
</div>
|
|
<div class="mail-list-scroll" id="mailListScroll">
|
|
{{if .messages}}
|
|
{{range .messages}}{{template "msgRow" .}}{{end}}
|
|
{{else}}
|
|
<div class="text-center py-5">
|
|
<i class="bi bi-inbox text-muted" style="font-size: 3rem;"></i>
|
|
<h6 class="text-muted mt-3">No messages</h6>
|
|
</div>
|
|
{{end}}
|
|
</div>
|
|
{{if or .has_prev .has_next}}
|
|
<div class="d-flex justify-content-between p-2 border-top" style="border-color: #404040 !important;">
|
|
{{if .has_prev}}<a href="{{.prev_href}}" class="btn btn-outline-secondary btn-sm">« Newer</a>{{else}}<span></span>{{end}}
|
|
{{if .has_next}}<a href="{{.next_href}}" class="btn btn-outline-secondary btn-sm">Older »</a>{{end}}
|
|
</div>
|
|
{{end}}
|
|
</div>
|
|
|
|
<div class="mail-reading-pane" id="readingPaneBody">
|
|
<div class="text-center text-muted py-5">
|
|
<i class="bi bi-envelope-open" style="font-size: 3rem;"></i>
|
|
<p class="mt-3">Select a message to read</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<form method="post" id="bulkActionForm" class="d-none">
|
|
<input type="hidden" name="action" id="bulkActionField">
|
|
<input type="hidden" name="target_folder" id="bulkTargetFolderField">
|
|
</form>
|
|
|
|
{{/* Always rendered (independent of search-mode, unlike the bulk toolbar's own
|
|
Move-to dropdown) so the message context menu's Move-to submenu always has a
|
|
folder list to build from, search results included. */}}
|
|
<select id="allFoldersList" class="d-none">
|
|
{{range .folders}}<option value="{{.}}">{{.}}</option>{{end}}
|
|
</select>
|
|
|
|
<div id="messageContextMenu" class="dropdown-menu" style="display: none; position: fixed;">
|
|
<button type="button" class="dropdown-item" data-msg-action="reply"><i class="bi bi-reply me-2"></i>Reply</button>
|
|
<button type="button" class="dropdown-item" data-msg-action="forward"><i class="bi bi-arrow-right me-2"></i>Forward</button>
|
|
<button type="button" class="dropdown-item" data-msg-action="forward-attach"><i class="bi bi-paperclip me-2"></i>Forward as attachment</button>
|
|
<div class="dropdown-divider"></div>
|
|
<button type="button" class="dropdown-item" data-msg-action="toggle-read"><i class="bi bi-envelope-open me-2"></i><span data-toggle-read-label>Mark as read</span></button>
|
|
<button type="button" class="dropdown-item" data-msg-action="junk"><i class="bi bi-shield-exclamation me-2"></i>Mark as Junk</button>
|
|
<div class="dropdown-submenu">
|
|
<button type="button" class="dropdown-item dropdown-toggle" data-msg-action="move-toggle">Move to…</button>
|
|
<div class="dropdown-menu" id="messageMoveSubmenu"></div>
|
|
</div>
|
|
<button type="button" class="dropdown-item" data-msg-action="open-tab"><i class="bi bi-box-arrow-up-right me-2"></i>Open in new tab</button>
|
|
<button type="button" class="dropdown-item" data-msg-action="download"><i class="bi bi-download me-2"></i>Download email</button>
|
|
<div class="dropdown-divider"></div>
|
|
<button type="button" class="dropdown-item text-danger" data-msg-action="delete"><i class="bi bi-trash me-2"></i><span data-delete-label>Delete</span></button>
|
|
</div>
|
|
<form method="post" id="messageActionForm" class="d-none"></form>
|
|
|
|
<div class="modal fade" id="messageModal" tabindex="-1" aria-hidden="true">
|
|
<div class="modal-dialog modal-xl modal-dialog-scrollable">
|
|
<div class="modal-content" style="background-color: #2d2d2d; border-color: #404040;">
|
|
<div class="modal-header" style="border-color: #404040;">
|
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
</div>
|
|
<div class="modal-body" id="messageModalBody"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="modal fade" id="confirmationModal" tabindex="-1" aria-hidden="true">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title"><i class="bi bi-question-circle me-2"></i>Confirm Action</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
</div>
|
|
<div class="modal-body" id="confirmationModalBody">Are you sure you want to proceed?</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
<button type="button" class="btn btn-danger" id="confirmationModalConfirm">Confirm</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{{/* Delete choice — Trash / Delete Permanently / Cancel — offered whenever the
|
|
message(s) being deleted aren't already under Trash (once they are, deleting
|
|
only has one real outcome, so that path still just uses the plain
|
|
confirmationModal above). */}}
|
|
{{/* z-index bumped above the compose popup's own 1080 (webmail_compose_widget.html)
|
|
so this still renders on top if it's open — same reasoning as
|
|
composeCloseDraftModal there. */}}
|
|
<div class="modal fade" id="deleteChoiceModal" tabindex="-1" aria-hidden="true" style="z-index: 1100;">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title"><i class="bi bi-trash me-2"></i>Delete</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
</div>
|
|
<div class="modal-body" id="deleteChoiceModalBody">Move to Trash, or delete permanently?</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
<button type="button" class="btn btn-outline-danger" id="deleteChoicePermanent">Delete Permanently</button>
|
|
<button type="button" class="btn btn-primary" id="deleteChoiceTrash">Move to Trash</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="modal fade" id="inputModal" tabindex="-1" aria-hidden="true">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<form id="inputModalForm">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title" id="inputModalTitle"><i class="bi bi-pencil me-2"></i>Folder name</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<input type="text" class="form-control" id="inputModalField" maxlength="60" required>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
<button type="submit" class="btn btn-primary" id="inputModalConfirm">OK</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{{template "compose_widget" .}}
|
|
{{template "webmail_shortcuts" .}}
|
|
|
|
<script src="/webmail/static/vendor/bootstrap/js/bootstrap.bundle.min.js"></script>
|
|
<script>
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
document.querySelectorAll('.toast').forEach(function(el) { new bootstrap.Toast(el, {delay: 5000}).show(); });
|
|
});
|
|
function showConfirmation(message) {
|
|
return new Promise((resolve) => {
|
|
const modal = document.getElementById('confirmationModal');
|
|
document.getElementById('confirmationModalBody').textContent = message;
|
|
const confirmButton = document.getElementById('confirmationModalConfirm');
|
|
const handleConfirm = () => { resolve(true); bootstrap.Modal.getInstance(modal).hide(); cleanup(); };
|
|
const handleCancel = () => { resolve(false); cleanup(); };
|
|
const cleanup = () => {
|
|
confirmButton.removeEventListener('click', handleConfirm);
|
|
modal.removeEventListener('hidden.bs.modal', handleCancel);
|
|
};
|
|
confirmButton.addEventListener('click', handleConfirm);
|
|
modal.addEventListener('hidden.bs.modal', handleCancel, { once: true });
|
|
new bootstrap.Modal(modal).show();
|
|
});
|
|
}
|
|
// showDeleteChoice resolves 'trash' | 'permanent' | 'cancel' (Escape/backdrop/
|
|
// its own Cancel button, matching showConfirmation's cancel-on-dismiss
|
|
// convention above) — see deleteChoiceModal for when this is used instead of
|
|
// the plain yes/no showConfirmation.
|
|
function showDeleteChoice(message) {
|
|
return new Promise((resolve) => {
|
|
const modal = document.getElementById('deleteChoiceModal');
|
|
document.getElementById('deleteChoiceModalBody').textContent = message;
|
|
const trashBtn = document.getElementById('deleteChoiceTrash');
|
|
const permBtn = document.getElementById('deleteChoicePermanent');
|
|
let choice = 'cancel';
|
|
const onTrash = () => { choice = 'trash'; bootstrap.Modal.getInstance(modal).hide(); };
|
|
const onPermanent = () => { choice = 'permanent'; bootstrap.Modal.getInstance(modal).hide(); };
|
|
const onHidden = () => { cleanup(); resolve(choice); };
|
|
const cleanup = () => {
|
|
trashBtn.removeEventListener('click', onTrash);
|
|
permBtn.removeEventListener('click', onPermanent);
|
|
modal.removeEventListener('hidden.bs.modal', onHidden);
|
|
};
|
|
trashBtn.addEventListener('click', onTrash);
|
|
permBtn.addEventListener('click', onPermanent);
|
|
modal.addEventListener('hidden.bs.modal', onHidden, { once: true });
|
|
new bootstrap.Modal(modal).show();
|
|
});
|
|
}
|
|
// showInputPrompt replaces the browser's native prompt() for folder
|
|
// name/rename input — a native prompt() blocks the whole tab (including any
|
|
// in-flight automation/extension driving it) until dismissed, and looks/feels
|
|
// inconsistent with the confirm dialog above. Resolves the entered text, or
|
|
// null if cancelled — same null-on-cancel contract prompt() itself has.
|
|
function showInputPrompt(title, defaultValue) {
|
|
return new Promise((resolve) => {
|
|
const modal = document.getElementById('inputModal');
|
|
const form = document.getElementById('inputModalForm');
|
|
const field = document.getElementById('inputModalField');
|
|
document.getElementById('inputModalTitle').textContent = title;
|
|
field.value = defaultValue || '';
|
|
let resolved = false;
|
|
const handleSubmit = (e) => {
|
|
e.preventDefault();
|
|
resolved = true;
|
|
resolve(field.value.trim());
|
|
bootstrap.Modal.getInstance(modal).hide();
|
|
cleanup();
|
|
};
|
|
const handleCancel = () => {
|
|
if (!resolved) resolve(null);
|
|
cleanup();
|
|
};
|
|
const cleanup = () => {
|
|
form.removeEventListener('submit', handleSubmit);
|
|
modal.removeEventListener('hidden.bs.modal', handleCancel);
|
|
};
|
|
form.addEventListener('submit', handleSubmit);
|
|
modal.addEventListener('hidden.bs.modal', handleCancel, { once: true });
|
|
new bootstrap.Modal(modal).show();
|
|
modal.addEventListener('shown.bs.modal', function() { field.focus(); field.select(); }, { once: true });
|
|
});
|
|
}
|
|
// Delegated (not per-element) so this also covers [data-confirm] buttons
|
|
// injected later via fetch — the reading pane's own delete button, loaded
|
|
// into #readingPaneBody by openInPane, wouldn't exist yet at DOMContentLoaded
|
|
// time for a per-element listener to attach to.
|
|
document.addEventListener('click', async function(e) {
|
|
const button = e.target.closest('[data-confirm]');
|
|
if (!button) return;
|
|
e.preventDefault();
|
|
if (await showConfirmation(button.getAttribute('data-confirm'))) {
|
|
const form = button.closest('form');
|
|
if (form) form.submit();
|
|
}
|
|
});
|
|
// Same idea as [data-confirm] above, but for a message delete button not
|
|
// already under Trash — see deleteChoiceModal for the Trash/Permanent/Cancel
|
|
// choice, and messageDelete's own permanent=1 form field this injects when
|
|
// "Delete Permanently" is picked. Delegated for the same reason: the reading
|
|
// pane's own delete button (webmail_message_pane.html) is fetched in later.
|
|
document.addEventListener('click', async function(e) {
|
|
const button = e.target.closest('[data-delete-confirm]');
|
|
if (!button) return;
|
|
e.preventDefault();
|
|
const choice = await showDeleteChoice('Move this message to Trash, or delete it permanently?');
|
|
if (choice === 'cancel') return;
|
|
const form = button.closest('form');
|
|
if (!form) return;
|
|
if (choice === 'permanent') {
|
|
const perm = document.createElement('input');
|
|
perm.type = 'hidden'; perm.name = 'permanent'; perm.value = '1';
|
|
form.appendChild(perm);
|
|
}
|
|
form.submit();
|
|
});
|
|
|
|
// Drag a message row onto a folder in the sidebar to move it there — a
|
|
// shortcut for the toolbar's "Move to..." control. Each row carries its OWN
|
|
// folder (data-folder) rather than assuming the page's active folder, since a
|
|
// search result can span multiple folders.
|
|
(function() {
|
|
let draggedUID = null;
|
|
let draggedFolder = null;
|
|
const listScroll = document.getElementById('mailListScroll');
|
|
|
|
// Delegated (dragstart/dragend both bubble) rather than bound per-row, so
|
|
// a message row the new-mail poll inserts later (see the poll IIFE below)
|
|
// is draggable immediately with no separate rebind step.
|
|
listScroll.addEventListener('dragstart', function(e) {
|
|
const row = e.target.closest('.msg-row');
|
|
if (!row) return;
|
|
draggedUID = row.dataset.uid;
|
|
draggedFolder = row.dataset.folder;
|
|
row.classList.add('dragging');
|
|
// Lets the compose popup's own drop handler (a same-origin iframe —
|
|
// native drag-and-drop works into it same as any other element) attach
|
|
// this message as a .eml, matching Outlook's drag-message-onto-a-reply
|
|
// behavior. The sidebar-folder drop target above doesn't read this —
|
|
// only the compose iframe does.
|
|
const subjectEl = row.querySelector('.msg-subject');
|
|
e.dataTransfer.setData('application/x-mailgoserver-message', JSON.stringify({
|
|
uid: draggedUID, folder: draggedFolder,
|
|
subject: subjectEl ? subjectEl.textContent : '',
|
|
}));
|
|
e.dataTransfer.effectAllowed = 'copy';
|
|
});
|
|
listScroll.addEventListener('dragend', function(e) {
|
|
const row = e.target.closest('.msg-row');
|
|
if (row) row.classList.remove('dragging');
|
|
});
|
|
|
|
document.querySelectorAll('.folder-link').forEach(function(link) {
|
|
link.addEventListener('dragover', function(e) {
|
|
if (!draggedUID || link.dataset.folder === draggedFolder) return;
|
|
e.preventDefault();
|
|
link.classList.add('drop-hover');
|
|
});
|
|
link.addEventListener('dragleave', function() {
|
|
link.classList.remove('drop-hover');
|
|
});
|
|
link.addEventListener('drop', async function(e) {
|
|
e.preventDefault();
|
|
link.classList.remove('drop-hover');
|
|
const targetFolder = link.dataset.folder;
|
|
if (!draggedUID || !targetFolder || targetFolder === draggedFolder) return;
|
|
const body = new URLSearchParams();
|
|
body.set('target_folder', targetFolder);
|
|
body.set('csrf_token', window.__csrfToken || '');
|
|
await fetch(`/webmail/mail/${draggedFolder}/${draggedUID}/move`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body: body.toString(),
|
|
});
|
|
window.location.reload();
|
|
});
|
|
});
|
|
})();
|
|
|
|
// Drag-and-drop folder reordering — root folders (INBOX/Junk/Sent/Drafts/
|
|
// Trash) are static and never draggable; only a custom folder still under
|
|
// INBOX (server-computed .Renameable — see buildFolderView) can be dragged,
|
|
// and only among its OWN siblings (rows sharing the same immediate .folder-
|
|
// children parent — dragging never re-parents a folder, only reorders it
|
|
// where it already is). A dragged row's own .folder-children (if it has
|
|
// descendants) moves right along with it, or its whole subtree would get
|
|
// visually orphaned mid-drag. Reorders the DOM live as you drag (move the
|
|
// dragged row to whichever side of the row under the cursor it's closer to —
|
|
// a common no-library technique), persisting just that sibling group's final
|
|
// order once on dragend. draggingRow being set is what keeps this from firing
|
|
// during the unrelated "drag a message onto a folder" drag above (that one's
|
|
// own dragover guard checks draggedUID instead, so the two never interfere).
|
|
(function() {
|
|
const list = document.getElementById('folderList');
|
|
if (!list) return;
|
|
let draggingRow = null;
|
|
let draggingChildren = null;
|
|
|
|
function persistOrder(parentEl) {
|
|
const order = Array.from(parentEl.children)
|
|
.filter(function(el) { return el.classList.contains('folder-row'); })
|
|
.map(function(el) { return el.dataset.folder; });
|
|
const body = new URLSearchParams();
|
|
order.forEach(function(name) { body.append('order', name); });
|
|
body.set('csrf_token', window.__csrfToken || '');
|
|
fetch('/webmail/mail/folders/order', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body: body.toString(),
|
|
});
|
|
}
|
|
|
|
list.querySelectorAll('.folder-row[draggable="true"]').forEach(function(row) {
|
|
row.addEventListener('dragstart', function(e) {
|
|
e.stopPropagation();
|
|
draggingRow = row;
|
|
draggingChildren = row.nextElementSibling && row.nextElementSibling.classList.contains('folder-children')
|
|
? row.nextElementSibling : null;
|
|
row.classList.add('folder-row-dragging');
|
|
});
|
|
row.addEventListener('dragend', function() {
|
|
row.classList.remove('folder-row-dragging');
|
|
if (draggingRow) persistOrder(draggingRow.parentElement);
|
|
draggingRow = null;
|
|
draggingChildren = null;
|
|
});
|
|
row.addEventListener('dragover', function(e) {
|
|
if (!draggingRow || draggingRow === row) return;
|
|
if (draggingRow.parentElement !== row.parentElement) return; // reorder only, never re-parent
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const rect = row.getBoundingClientRect();
|
|
const before = (e.clientY - rect.top) < rect.height / 2;
|
|
row.parentNode.insertBefore(draggingRow, before ? row : row.nextSibling);
|
|
if (draggingChildren) row.parentNode.insertBefore(draggingChildren, draggingRow.nextSibling);
|
|
});
|
|
});
|
|
})();
|
|
|
|
// Folder tree expand/collapse — a display preference remembered per-browser
|
|
// (localStorage, a Set of collapsed folder names), same pattern as the
|
|
// sidebar-hide toggle below. Default is fully expanded (nothing collapsed).
|
|
(function() {
|
|
const KEY = 'webmail_collapsed_folders';
|
|
function loadCollapsed() {
|
|
try { return new Set(JSON.parse(localStorage.getItem(KEY)) || []); } catch (e) { return new Set(); }
|
|
}
|
|
function saveCollapsed(set) { localStorage.setItem(KEY, JSON.stringify(Array.from(set))); }
|
|
|
|
function setCollapsed(name, collapsed) {
|
|
const toggle = document.querySelector('[data-folder-toggle="' + CSS.escape(name) + '"]');
|
|
const kids = document.querySelector('[data-children-of="' + CSS.escape(name) + '"]');
|
|
if (toggle) { toggle.classList.toggle('collapsed', collapsed); toggle.title = collapsed ? 'Expand' : 'Collapse'; }
|
|
if (kids) kids.classList.toggle('collapsed', collapsed);
|
|
}
|
|
|
|
const collapsed = loadCollapsed();
|
|
collapsed.forEach(function(name) { setCollapsed(name, true); });
|
|
|
|
document.querySelectorAll('.folder-expand-toggle').forEach(function(toggle) {
|
|
toggle.addEventListener('click', function(e) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const name = toggle.dataset.folderToggle;
|
|
const nowCollapsed = !toggle.classList.contains('collapsed');
|
|
setCollapsed(name, nowCollapsed);
|
|
if (nowCollapsed) collapsed.add(name); else collapsed.delete(name);
|
|
saveCollapsed(collapsed);
|
|
});
|
|
});
|
|
|
|
const expandAllBtn = document.getElementById('expandAllBtn');
|
|
const collapseAllBtn = document.getElementById('collapseAllBtn');
|
|
if (expandAllBtn) expandAllBtn.addEventListener('click', function() {
|
|
document.querySelectorAll('[data-folder-toggle]').forEach(function(t) { setCollapsed(t.dataset.folderToggle, false); });
|
|
collapsed.clear();
|
|
saveCollapsed(collapsed);
|
|
});
|
|
if (collapseAllBtn) collapseAllBtn.addEventListener('click', function() {
|
|
document.querySelectorAll('[data-folder-toggle]').forEach(function(t) {
|
|
setCollapsed(t.dataset.folderToggle, true);
|
|
collapsed.add(t.dataset.folderToggle);
|
|
});
|
|
saveCollapsed(collapsed);
|
|
});
|
|
})();
|
|
|
|
// "+N more" toggle for a collapsed same-subject run — reveals every
|
|
// immediately-following row marked as part of that group, self-terminating
|
|
// at the first row that isn't (no need to track the count client-side).
|
|
document.querySelectorAll('[data-group-toggle]').forEach(function(badge) {
|
|
badge.addEventListener('click', function(e) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
let sib = badge.closest('.msg-item').nextElementSibling;
|
|
while (sib && sib.classList.contains('msg-row-older')) {
|
|
sib.style.display = '';
|
|
sib = sib.nextElementSibling;
|
|
}
|
|
badge.style.display = 'none';
|
|
});
|
|
});
|
|
|
|
// Folder sidebar collapse — a display preference remembered per-browser
|
|
// (localStorage), not server state; default is pinned open (nothing stored
|
|
// yet == not collapsed).
|
|
(function() {
|
|
const KEY = 'webmail_sidebar_collapsed';
|
|
const sidebarCol = document.getElementById('folderSidebarCol');
|
|
const showBtn = document.getElementById('sidebarShowBtn');
|
|
function apply(collapsed) {
|
|
sidebarCol.style.display = collapsed ? 'none' : '';
|
|
showBtn.classList.toggle('d-none', !collapsed);
|
|
}
|
|
apply(localStorage.getItem(KEY) === '1');
|
|
document.getElementById('sidebarCollapseBtn').addEventListener('click', function() {
|
|
localStorage.setItem(KEY, '1');
|
|
apply(true);
|
|
});
|
|
showBtn.addEventListener('click', function() {
|
|
localStorage.setItem(KEY, '0');
|
|
apply(false);
|
|
});
|
|
})();
|
|
|
|
// "Reading pane below the list" layout toggle — same client-side-only
|
|
// (localStorage) pattern as the sidebar collapse above. Default (nothing
|
|
// stored yet) is the side-by-side layout matching the original reference.
|
|
(function() {
|
|
const KEY = 'webmail_layout_below';
|
|
const content = document.getElementById('mailContent');
|
|
const btn = document.getElementById('layoutToggleBtn');
|
|
const icon = btn.querySelector('i');
|
|
function apply(below) {
|
|
content.classList.toggle('layout-below', below);
|
|
icon.className = below ? 'bi bi-layout-text-window' : 'bi bi-layout-split';
|
|
btn.title = below ? 'Move reading pane back to the side' : 'Move reading pane below the list';
|
|
}
|
|
apply(localStorage.getItem(KEY) === '1');
|
|
btn.addEventListener('click', function() {
|
|
const below = !content.classList.contains('layout-below');
|
|
localStorage.setItem(KEY, below ? '1' : '0');
|
|
apply(below);
|
|
});
|
|
})();
|
|
|
|
// Reading pane, selection, and the star toggle share one delegated listener on
|
|
// the list container (not one per row/checkbox/star) for two reasons: (1)
|
|
// Shift/Ctrl-click needs to work when clicking anywhere on a row, not just its
|
|
// small checkbox, which needs the full row list + index either way; (2)
|
|
// delegation means a message row the new-mail poll inserts later (see the poll
|
|
// IIFE below) works immediately — star, select, open — with no separate rebind
|
|
// step, since there's nothing per-row left to bind.
|
|
(function() {
|
|
const paneBody = document.getElementById('readingPaneBody');
|
|
const listScroll = document.getElementById('mailListScroll');
|
|
const selectAll = document.getElementById('selectAllCheck');
|
|
const bulkBtns = document.querySelectorAll('.bulk-btn');
|
|
const moveSelect = document.getElementById('bulkMoveSelect');
|
|
let lastRow = null;
|
|
|
|
function getRows() { return Array.from(listScroll.querySelectorAll('.msg-item')); }
|
|
function getChecks() { return getRows().map(function(row) { return row.querySelector('.msg-check'); }); }
|
|
|
|
function updateToolbar() {
|
|
const checks = getChecks();
|
|
const any = checks.some(function(c) { return c.checked; });
|
|
bulkBtns.forEach(function(b) { b.disabled = !any; });
|
|
if (moveSelect) moveSelect.disabled = !any;
|
|
selectAll.checked = checks.length > 0 && checks.every(function(c) { return c.checked; });
|
|
}
|
|
|
|
function openInPane(row) {
|
|
if (row.dataset.isDraft === 'true') { window.location.href = row.dataset.href; return; }
|
|
getRows().forEach(function(r) { r.classList.remove('active'); });
|
|
row.classList.add('active');
|
|
row.classList.remove('unread');
|
|
paneBody.innerHTML = '<div class="text-center text-muted py-5"><div class="spinner-border" role="status"></div></div>';
|
|
fetch(row.dataset.paneHref)
|
|
.then(function(r) { if (!r.ok) throw new Error(r.status); return r.text(); })
|
|
.then(function(html) { paneBody.innerHTML = html; })
|
|
.catch(function() { paneBody.innerHTML = '<p class="text-danger">Failed to load the message.</p>'; });
|
|
}
|
|
|
|
async function toggleStar(star) {
|
|
const uid = star.dataset.uid, folder = star.dataset.folder;
|
|
const body = new URLSearchParams();
|
|
body.set('csrf_token', window.__csrfToken || '');
|
|
const resp = await fetch(`/webmail/mail/${folder}/${uid}/star`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body: body.toString(),
|
|
});
|
|
if (!resp.ok) return;
|
|
const nowStarred = star.classList.toggle('bi-star-fill');
|
|
star.classList.toggle('bi-star', !nowStarred);
|
|
star.title = nowStarred ? 'Remove from favourites' : 'Add to favourites';
|
|
}
|
|
|
|
listScroll.addEventListener('click', function(e) {
|
|
const star = e.target.closest('.msg-star');
|
|
if (star) { toggleStar(star); return; }
|
|
if (e.target.closest('.msg-group-toggle')) return;
|
|
|
|
const row = e.target.closest('.msg-item');
|
|
if (!row) return;
|
|
const rows = getRows();
|
|
const checks = getChecks();
|
|
const i = rows.indexOf(row);
|
|
const isCheck = e.target.classList.contains('msg-check');
|
|
|
|
if (e.shiftKey && lastRow && rows.includes(lastRow)) {
|
|
e.preventDefault();
|
|
const [from, to] = [rows.indexOf(lastRow), i].sort(function(a, b) { return a - b; });
|
|
const state = isCheck ? e.target.checked : true;
|
|
for (let j = from; j <= to; j++) checks[j].checked = state;
|
|
lastRow = row;
|
|
updateToolbar();
|
|
return;
|
|
}
|
|
if (isCheck) { lastRow = row; updateToolbar(); return; }
|
|
if (e.ctrlKey || e.metaKey) {
|
|
e.preventDefault();
|
|
checks[i].checked = !checks[i].checked;
|
|
lastRow = row;
|
|
updateToolbar();
|
|
return;
|
|
}
|
|
lastRow = row;
|
|
openInPane(row);
|
|
});
|
|
|
|
selectAll.addEventListener('change', function() {
|
|
getChecks().forEach(function(c) { c.checked = selectAll.checked; });
|
|
updateToolbar();
|
|
});
|
|
document.addEventListener('keydown', function(e) {
|
|
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'a' && document.activeElement.tagName !== 'INPUT') {
|
|
e.preventDefault();
|
|
selectAll.checked = true;
|
|
getChecks().forEach(function(c) { c.checked = true; });
|
|
updateToolbar();
|
|
}
|
|
});
|
|
|
|
function selectedUIDs() { return getChecks().filter(function(c) { return c.checked; }).map(function(c) { return c.value; }); }
|
|
function submitBulk(action, targetFolder) {
|
|
const uids = selectedUIDs();
|
|
if (uids.length === 0) return;
|
|
const form = document.getElementById('bulkActionForm');
|
|
form.action = '/webmail/mail/{{.active_folder}}/bulk';
|
|
document.getElementById('bulkActionField').value = action;
|
|
document.getElementById('bulkTargetFolderField').value = targetFolder || '';
|
|
form.querySelectorAll('input[name="uid"]').forEach(function(el) { el.remove(); });
|
|
uids.forEach(function(uid) {
|
|
const input = document.createElement('input');
|
|
input.type = 'hidden'; input.name = 'uid'; input.value = uid;
|
|
form.appendChild(input);
|
|
});
|
|
const csrf = document.createElement('input');
|
|
csrf.type = 'hidden'; csrf.name = 'csrf_token'; csrf.value = window.__csrfToken || '';
|
|
form.appendChild(csrf);
|
|
form.submit();
|
|
}
|
|
document.querySelectorAll('.bulk-btn').forEach(function(btn) {
|
|
btn.addEventListener('click', async function() {
|
|
const action = btn.dataset.action;
|
|
if (action === 'delete') {
|
|
const activeFolderUnderTrash = document.querySelector('.folder-row[data-folder="' + CSS.escape({{.active_folder}}) + '"][data-under-trash="true"]') !== null;
|
|
if (activeFolderUnderTrash) {
|
|
if (!(await showConfirmation('Permanently delete the selected message(s)? This cannot be undone.'))) return;
|
|
submitBulk('delete-permanent');
|
|
return;
|
|
}
|
|
const choice = await showDeleteChoice('Move the selected message(s) to Trash, or delete them permanently?');
|
|
if (choice === 'cancel') return;
|
|
submitBulk(choice === 'permanent' ? 'delete-permanent' : 'delete');
|
|
return;
|
|
}
|
|
submitBulk(action);
|
|
});
|
|
});
|
|
if (moveSelect) {
|
|
moveSelect.addEventListener('change', function() {
|
|
if (moveSelect.value) submitBulk('move', moveSelect.value);
|
|
});
|
|
}
|
|
|
|
window.__mailUpdateToolbar = updateToolbar; // used by the new-mail poll IIFE below
|
|
})();
|
|
|
|
// Folder sidebar context menu — right-click any folder for New folder / Mark
|
|
// all as read / Rename / Empty Trash, replacing the old always-visible "+ New
|
|
// folder" form. Every action posts through #folderActionForm (shared, its
|
|
// action/fields set right before submit) rather than one form per action, same
|
|
// reasoning as the message list's #bulkActionForm.
|
|
(function() {
|
|
const menu = document.getElementById('folderContextMenu');
|
|
const form = document.getElementById('folderActionForm');
|
|
let menuFolder = null;
|
|
|
|
// fields is a plain {name: value} object — each key becomes a hidden
|
|
// input, replacing whatever the previous action left behind.
|
|
function postAction(url, fields) {
|
|
form.action = url;
|
|
form.querySelectorAll('input[data-dynamic]').forEach(function(el) { el.remove(); });
|
|
const all = Object.assign({ csrf_token: window.__csrfToken || '' }, fields || {});
|
|
Object.keys(all).forEach(function(name) {
|
|
const input = document.createElement('input');
|
|
input.type = 'hidden'; input.name = name; input.value = all[name];
|
|
input.dataset.dynamic = '1';
|
|
form.appendChild(input);
|
|
});
|
|
form.submit();
|
|
}
|
|
|
|
function hideMenu() { menu.style.display = 'none'; menuFolder = null; }
|
|
|
|
document.querySelectorAll('.folder-row').forEach(function(row) {
|
|
row.addEventListener('contextmenu', function(e) {
|
|
e.preventDefault();
|
|
menuFolder = row.dataset.folder;
|
|
const renameable = row.dataset.renameable === 'true';
|
|
const isTrash = row.dataset.trash === 'true';
|
|
const isSpam = row.dataset.spam === 'true';
|
|
const underTrash = row.dataset.underTrash === 'true';
|
|
const canAddSubfolder = row.dataset.canAddSubfolder === 'true';
|
|
menu.querySelector('[data-menu-action="new"]').style.display = canAddSubfolder ? '' : 'none';
|
|
menu.querySelector('[data-menu-action="rename"]').style.display = renameable ? '' : 'none';
|
|
menu.querySelector('[data-menu-action="empty-trash"]').style.display = isTrash ? '' : 'none';
|
|
menu.querySelector('[data-menu-action="clean-spam"]').style.display = isSpam ? '' : 'none';
|
|
menu.querySelector('[data-menu-action="restore-folder"]').style.display = underTrash ? '' : 'none';
|
|
menu.querySelector('[data-menu-action="delete-folder-permanently"]').style.display = underTrash ? '' : 'none';
|
|
menu.style.left = e.clientX + 'px';
|
|
menu.style.top = e.clientY + 'px';
|
|
menu.style.display = 'block';
|
|
});
|
|
});
|
|
|
|
menu.querySelectorAll('[data-menu-action]').forEach(function(item) {
|
|
item.addEventListener('click', async function() {
|
|
const folder = menuFolder;
|
|
const action = item.dataset.menuAction;
|
|
hideMenu();
|
|
if (!folder) return;
|
|
switch (action) {
|
|
case 'new': {
|
|
const name = await showInputPrompt('New folder name (inside "' + folder + '")');
|
|
if (name) postAction('/webmail/mail/folders/add', { name: name, parent: folder });
|
|
break;
|
|
}
|
|
case 'mark-all-read':
|
|
postAction(`/webmail/mail/${folder}/mark-all-read`);
|
|
break;
|
|
case 'rename': {
|
|
const name = await showInputPrompt('Rename "' + folder + '" to', folder);
|
|
if (name && name !== folder) {
|
|
postAction(`/webmail/mail/folders/${folder}/rename`, { new_name: name });
|
|
}
|
|
break;
|
|
}
|
|
case 'empty-trash':
|
|
if (await showConfirmation('Permanently delete every message in Trash (and any folder deleted into it)? This can\'t be undone.')) {
|
|
postAction(`/webmail/mail/${folder}/empty`);
|
|
}
|
|
break;
|
|
case 'clean-spam':
|
|
if (await showConfirmation('Permanently delete every message in Junk? This can\'t be undone — they will NOT be moved to Trash.')) {
|
|
postAction(`/webmail/mail/${folder}/clean-spam`);
|
|
}
|
|
break;
|
|
case 'restore-folder':
|
|
postAction(`/webmail/mail/folders/${folder}/restore`);
|
|
break;
|
|
case 'delete-folder-permanently':
|
|
if (await showConfirmation('Permanently delete folder "' + folder + '" (and anything nested inside it)? This can\'t be undone.')) {
|
|
postAction(`/webmail/mail/folders/${folder}/delete-permanently`);
|
|
}
|
|
break;
|
|
}
|
|
});
|
|
});
|
|
|
|
document.addEventListener('click', function(e) {
|
|
if (!menu.contains(e.target)) hideMenu();
|
|
});
|
|
document.addEventListener('keydown', function(e) {
|
|
if (e.key === 'Escape') hideMenu();
|
|
});
|
|
window.addEventListener('scroll', hideMenu, true);
|
|
})();
|
|
|
|
// Message row right-click menu (Reply/Forward/Mark read-unread/Mark as Junk/
|
|
// Move to.../Open in new tab/Delete) and double-click (open in a full-size
|
|
// modal, reusing the same pane fragment the reading pane already loads —
|
|
// useful for a long message without losing the list). Both are additive to
|
|
// the existing plain/Shift/Ctrl-click handling above (different event types
|
|
// entirely — contextmenu/dblclick never fire alongside a plain click).
|
|
(function() {
|
|
const menu = document.getElementById('messageContextMenu');
|
|
const submenu = document.getElementById('messageMoveSubmenu');
|
|
const form = document.getElementById('messageActionForm');
|
|
let menuRow = null;
|
|
|
|
function postMessageAction(url, fields) {
|
|
form.action = url;
|
|
form.querySelectorAll('input[data-dynamic]').forEach(function(el) { el.remove(); });
|
|
const all = Object.assign({ csrf_token: window.__csrfToken || '' }, fields || {});
|
|
Object.keys(all).forEach(function(name) {
|
|
const input = document.createElement('input');
|
|
input.type = 'hidden'; input.name = name; input.value = all[name];
|
|
input.dataset.dynamic = '1';
|
|
form.appendChild(input);
|
|
});
|
|
form.submit();
|
|
}
|
|
|
|
function hideMenu() { menu.style.display = 'none'; menuRow = null; }
|
|
|
|
function buildMoveSubmenu(row) {
|
|
submenu.innerHTML = '';
|
|
const rowFolder = row.dataset.folder;
|
|
Array.from(document.querySelectorAll('#allFoldersList option')).forEach(function(opt) {
|
|
if (opt.value === rowFolder) return;
|
|
const item = document.createElement('button');
|
|
item.type = 'button';
|
|
item.className = 'dropdown-item';
|
|
item.textContent = opt.value;
|
|
item.addEventListener('click', function() {
|
|
hideMenu();
|
|
postMessageAction(`/webmail/mail/${rowFolder}/${row.dataset.uid}/move`, { target_folder: opt.value });
|
|
});
|
|
submenu.appendChild(item);
|
|
});
|
|
const newItem = document.createElement('button');
|
|
newItem.type = 'button';
|
|
newItem.className = 'dropdown-item';
|
|
newItem.innerHTML = '<i class="bi bi-folder-plus me-2"></i>New folder…';
|
|
newItem.addEventListener('click', async function() {
|
|
hideMenu();
|
|
const name = await showInputPrompt('New folder name (inside "INBOX")');
|
|
if (!name) return;
|
|
const body = new URLSearchParams({ name: name, parent: 'INBOX', csrf_token: window.__csrfToken || '' });
|
|
const resp = await fetch('/webmail/mail/folders/add', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString() });
|
|
if (resp.ok) {
|
|
postMessageAction(`/webmail/mail/${rowFolder}/${row.dataset.uid}/move`, { target_folder: name });
|
|
}
|
|
});
|
|
submenu.appendChild(newItem);
|
|
}
|
|
|
|
// Delegated on the list container (contextmenu/dblclick both bubble) so a
|
|
// message row the new-mail poll inserts later (see the poll IIFE below)
|
|
// gets the right-click menu and double-click modal immediately too.
|
|
const listScrollForMenu = document.getElementById('mailListScroll');
|
|
listScrollForMenu.addEventListener('contextmenu', function(e) {
|
|
const row = e.target.closest('.msg-row');
|
|
if (!row) return;
|
|
e.preventDefault();
|
|
if (row.dataset.isDraft === 'true') return; // drafts only open in compose
|
|
menuRow = row;
|
|
const isUnread = row.classList.contains('unread');
|
|
menu.querySelector('[data-toggle-read-label]').textContent = isUnread ? 'Mark as read' : 'Mark as unread';
|
|
const underTrash = row.dataset.folder && document.querySelector('.folder-row[data-folder="' + CSS.escape(row.dataset.folder) + '"][data-under-trash="true"]') !== null;
|
|
menu.querySelector('[data-delete-label]').textContent = underTrash ? 'Delete Permanently' : 'Delete';
|
|
buildMoveSubmenu(row);
|
|
menu.style.left = e.clientX + 'px';
|
|
menu.style.top = e.clientY + 'px';
|
|
menu.style.display = 'block';
|
|
});
|
|
listScrollForMenu.addEventListener('dblclick', function(e) {
|
|
const row = e.target.closest('.msg-row');
|
|
if (!row) return;
|
|
if (e.target.closest('.msg-check, .msg-star, .msg-group-toggle')) return;
|
|
if (row.dataset.isDraft === 'true') { window.location.href = row.dataset.href; return; }
|
|
const modalBody = document.getElementById('messageModalBody');
|
|
modalBody.innerHTML = '<div class="text-center text-muted py-5"><div class="spinner-border" role="status"></div></div>';
|
|
new bootstrap.Modal(document.getElementById('messageModal')).show();
|
|
fetch(row.dataset.paneHref)
|
|
.then(function(r) { if (!r.ok) throw new Error(r.status); return r.text(); })
|
|
.then(function(html) { modalBody.innerHTML = html; })
|
|
.catch(function() { modalBody.innerHTML = '<p class="text-danger p-3">Failed to load the message.</p>'; });
|
|
});
|
|
|
|
menu.querySelectorAll('[data-msg-action]').forEach(function(item) {
|
|
item.addEventListener('click', async function() {
|
|
const row = menuRow;
|
|
const action = item.dataset.msgAction;
|
|
if (action === 'move-toggle') return; // hover-only, handled by the submenu itself
|
|
hideMenu();
|
|
if (!row) return;
|
|
const folder = row.dataset.folder, uid = row.dataset.uid;
|
|
switch (action) {
|
|
case 'reply':
|
|
openCompose(`/webmail/mail/compose?reply=${uid}&folder=${folder}`);
|
|
break;
|
|
case 'forward':
|
|
openCompose(`/webmail/mail/compose?forward=${uid}&folder=${folder}`);
|
|
break;
|
|
case 'forward-attach': {
|
|
// Right-clicking one of several checked rows attaches all
|
|
// of them at once; otherwise just the row that was
|
|
// right-clicked. Either way, goes through
|
|
// window.forwardAsAttachment (webmail_compose_widget.html)
|
|
// so repeating this — on another message, or with a fresh
|
|
// selection — adds to an already-open compose instead of
|
|
// replacing what's already attached.
|
|
const checkedRows = Array.from(document.querySelectorAll('.msg-item')).filter(function(r) {
|
|
const cb = r.querySelector('.msg-check');
|
|
return cb && cb.checked;
|
|
});
|
|
const rowsToForward = (checkedRows.length > 1 && checkedRows.includes(row)) ? checkedRows : [row];
|
|
const items = rowsToForward.map(function(r) {
|
|
const subjectEl = r.querySelector('.msg-subject');
|
|
return { uid: r.dataset.uid, folder: r.dataset.folder, label: subjectEl ? subjectEl.textContent : '' };
|
|
});
|
|
window.forwardAsAttachment(items);
|
|
break;
|
|
}
|
|
case 'download':
|
|
window.open(`/webmail/mail/${folder}/${uid}/download`, '_blank', 'noopener');
|
|
break;
|
|
case 'toggle-read': {
|
|
const isUnread = row.classList.contains('unread');
|
|
postMessageAction(`/webmail/mail/${folder}/bulk`, { action: isUnread ? 'read' : 'unread', uid: uid });
|
|
break;
|
|
}
|
|
case 'junk':
|
|
postMessageAction(`/webmail/mail/${folder}/${uid}/mark-junk`);
|
|
break;
|
|
case 'open-tab':
|
|
window.open(row.dataset.href, '_blank', 'noopener');
|
|
break;
|
|
case 'delete': {
|
|
const underTrash = menu.querySelector('[data-delete-label]').textContent === 'Delete Permanently';
|
|
if (underTrash) {
|
|
if (await showConfirmation('Permanently delete this message? This cannot be undone.')) {
|
|
postMessageAction(`/webmail/mail/${folder}/${uid}/delete`);
|
|
}
|
|
break;
|
|
}
|
|
const choice = await showDeleteChoice('Move this message to Trash, or delete it permanently?');
|
|
if (choice === 'cancel') break;
|
|
postMessageAction(`/webmail/mail/${folder}/${uid}/delete`, choice === 'permanent' ? { permanent: '1' } : {});
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
document.addEventListener('click', function(e) {
|
|
if (!menu.contains(e.target)) hideMenu();
|
|
});
|
|
document.addEventListener('keydown', function(e) {
|
|
if (e.key === 'Escape') hideMenu();
|
|
});
|
|
window.addEventListener('scroll', hideMenu, true);
|
|
})();
|
|
|
|
// New-mail polling — periodically checks this folder for messages newer than
|
|
// the highest one currently shown and patches them straight into the list
|
|
// (see msgRow/webmailMailPoll on the Go side — every listener above is
|
|
// delegated on #mailListScroll specifically so a row inserted here works
|
|
// immediately, with no separate rebind step) instead of reloading the page,
|
|
// so reading or composing isn't interrupted. Only runs on the plain,
|
|
// unpaginated, default-sorted view — a custom sort or a later page would make
|
|
// "insert new rows at the top" the wrong place to put them, and it's not worth
|
|
// solving for a background poll (see tests/todo.md's scope note).
|
|
(function() {
|
|
{{if or .search_query (gt .page 1)}}return;{{end}}
|
|
{{if eq .sort_by "from"}}return;{{end}}
|
|
const folder = {{.active_folder}};
|
|
const unreadOnly = {{.unread_only}};
|
|
const starredOnly = {{.starred_only}};
|
|
const listScroll = document.getElementById('mailListScroll');
|
|
const totalEl = document.getElementById('mailListTotal');
|
|
const banner = document.getElementById('newMailBanner');
|
|
const notifyBtn = document.getElementById('notifyPermBtn');
|
|
|
|
banner.addEventListener('click', function() { banner.classList.remove('show'); });
|
|
|
|
if (notifyBtn && 'Notification' in window && Notification.permission === 'default') {
|
|
notifyBtn.classList.remove('d-none');
|
|
notifyBtn.addEventListener('click', function() {
|
|
Notification.requestPermission().then(function() { notifyBtn.classList.add('d-none'); });
|
|
});
|
|
}
|
|
|
|
function notify(count) {
|
|
if (!('Notification' in window) || Notification.permission !== 'granted') return;
|
|
const title = count === 1 ? 'New message' : count + ' new messages';
|
|
try { new Notification(title, { body: 'In ' + folder }); } catch (e) { /* ignore — some browsers need a service worker for this */ }
|
|
}
|
|
|
|
function highestUID() {
|
|
let max = 0;
|
|
listScroll.querySelectorAll('.msg-item').forEach(function(el) {
|
|
const id = parseInt(el.dataset.uid, 10);
|
|
if (id > max) max = id;
|
|
});
|
|
return max;
|
|
}
|
|
|
|
async function poll() {
|
|
const params = new URLSearchParams({ since_id: highestUID(), unread: unreadOnly ? '1' : '0', starred: starredOnly ? '1' : '0' });
|
|
let html;
|
|
try {
|
|
const resp = await fetch(`/webmail/mail/${folder}/poll?` + params.toString());
|
|
if (!resp.ok) return;
|
|
html = await resp.text();
|
|
} catch (e) { return; }
|
|
if (!html.trim()) return;
|
|
|
|
const frag = document.createElement('div');
|
|
frag.innerHTML = html;
|
|
const newRows = Array.from(frag.querySelectorAll('.msg-item'));
|
|
if (newRows.length === 0) return;
|
|
|
|
const emptyState = listScroll.querySelector('.text-center.py-5');
|
|
if (emptyState) emptyState.remove();
|
|
// Server returns oldest-of-the-new-batch first — inserting each in that
|
|
// order at the very top leaves the newest of the batch on top, matching
|
|
// the folder's own default newest-first order.
|
|
newRows.forEach(function(row) { listScroll.insertBefore(row, listScroll.firstChild); });
|
|
|
|
if (totalEl) {
|
|
const n = (parseInt(totalEl.textContent, 10) || 0) + newRows.length;
|
|
totalEl.textContent = n + ' message' + (n === 1 ? '' : 's');
|
|
}
|
|
if (window.__mailUpdateToolbar) window.__mailUpdateToolbar();
|
|
|
|
banner.textContent = newRows.length + (newRows.length === 1 ? ' new message' : ' new messages');
|
|
banner.classList.add('show');
|
|
clearTimeout(poll._bannerTimer);
|
|
poll._bannerTimer = setTimeout(function() { banner.classList.remove('show'); }, 6000);
|
|
|
|
notify(newRows.length);
|
|
}
|
|
|
|
// The 25s interval is the backstop; a live SSE connection (real push, via
|
|
// internal/notify — the same signal IMAP IDLE uses) triggers an immediate
|
|
// poll() the moment mail actually lands instead of waiting for the next
|
|
// tick. 'EventSource' in window guards browsers/environments without SSE
|
|
// support, which just fall back to interval-only polling.
|
|
setInterval(poll, 25000);
|
|
if ('EventSource' in window) {
|
|
const stream = new EventSource(`/webmail/mail/${folder}/stream`);
|
|
stream.onmessage = function() { poll(); };
|
|
}
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|
|
{{end}}
|
|
|
|
{{/* msgRow renders one message-list row — . is a folderRow (webmail_mail.go). Shared
|
|
by the main list render above and webmailMailPoll's fragment response (the
|
|
periodic new-mail check, see the poll IIFE below) so a message polled in later
|
|
looks byte-identical to one rendered on initial page load. */}}
|
|
{{define "msgRow"}}
|
|
{{$rowHref := printf "/webmail/mail/%s/%d" .Folder .ID}}
|
|
{{$paneHref := printf "/webmail/mail/%s/%d/pane" .Folder .ID}}
|
|
{{$isDraft := eq .Folder "Drafts"}}
|
|
{{if $isDraft}}{{$rowHref = printf "/webmail/mail/compose?draft=%d&folder=Drafts" .ID}}{{end}}
|
|
{{$displayName := senderName .CachedFrom}}
|
|
{{if eq .Folder "Sent"}}{{if .CachedTo}}{{$displayName = senderName .CachedTo}}{{else}}{{$displayName = "(no recipient)"}}{{end}}{{end}}
|
|
<div class="msg-item {{if .Unread}}unread{{end}}{{if .Collapsed}} msg-row-older{{end}} msg-row" draggable="true" data-uid="{{.ID}}" data-folder="{{.Folder}}" data-href="{{$rowHref}}" data-pane-href="{{$paneHref}}" data-is-draft="{{$isDraft}}">
|
|
<input type="checkbox" class="form-check-input msg-check" value="{{.ID}}">
|
|
<div class="msg-avatar">{{initial $displayName}}</div>
|
|
<div class="msg-main">
|
|
<div class="msg-row1">
|
|
<span class="msg-from" title="{{if eq .Folder "Sent"}}{{.CachedTo}}{{else}}{{.CachedFrom}}{{end}}">{{$displayName}}</span>
|
|
<i class="bi msg-star {{if .Starred}}bi-star-fill{{else}}bi-star{{end}}" data-uid="{{.ID}}" data-folder="{{.Folder}}" title="{{if .Starred}}Remove from favourites{{else}}Add to favourites{{end}}"></i>
|
|
<span class="msg-date">{{strftime "%Y-%m-%d %H:%M" .InternalDate}}</span>
|
|
</div>
|
|
<div class="msg-row2">
|
|
<span class="msg-subject">{{if .CachedSubject}}{{.CachedSubject}}{{else}}(no subject){{end}}</span>
|
|
{{if gt .GroupExtra 0}}<span class="badge bg-secondary msg-group-toggle" data-group-toggle="{{.ID}}">+{{.GroupExtra}} more</span>{{end}}
|
|
{{if .CachedPreview}} – {{.CachedPreview}}{{end}}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{{end}}
|
|
|
|
{{/* folderTreeNode renders one sidebar folder row plus (if any) its children,
|
|
recursing into itself for each child — . is a *folderViewNode (webmail_mail.go's
|
|
buildFolderView), which carries everything needed (icon, counts, active state,
|
|
renameable/can-add-child/is-spam/is-trash) precomputed, so no shared state needs
|
|
threading through the recursion beyond the node itself. */}}
|
|
{{define "folderTreeNode"}}
|
|
<div class="folder-row {{if .Children}}folder-has-children{{end}}" data-folder="{{.Name}}" data-renameable="{{.Renameable}}" data-can-add-subfolder="{{.CanAddKid}}" data-trash="{{.IsTrash}}" data-spam="{{.IsSpam}}" data-under-trash="{{.UnderTrash}}"{{if .Renameable}} draggable="true"{{end}}>
|
|
{{if .Children}}
|
|
<i class="bi bi-chevron-down folder-expand-toggle" data-folder-toggle="{{.Name}}" title="Collapse"></i>
|
|
{{else}}
|
|
<span class="folder-expand-spacer"></span>
|
|
{{end}}
|
|
{{if .Renameable}}<i class="bi bi-grip-vertical folder-drag-handle" title="Drag to reorder"></i>{{end}}
|
|
<a href="/webmail/mail/{{.Name}}" data-folder="{{.Name}}" draggable="false" class="list-group-item list-group-item-action bg-transparent text-white folder-link flex-grow-1 d-flex justify-content-between align-items-center {{if .Active}}active{{end}}">
|
|
<span>{{if isStandardFolder .Name}}<i class="bi {{.Icon}} me-1"></i>{{end}}{{.Name}}</span>
|
|
{{if .Total}}
|
|
<span class="badge {{if .Unread}}bg-primary{{else}}bg-secondary{{end}} rounded-pill folder-unread-badge" title="{{.Total}} total{{if .Unread}}, {{.Unread}} unread{{end}}">{{.Total}}{{if .Unread}} / <strong>{{.Unread}}</strong>{{end}}</span>
|
|
{{end}}
|
|
</a>
|
|
{{if .Renameable}}
|
|
<form method="post" action="/webmail/mail/folders/{{.Name}}/remove" class="d-inline">
|
|
<button type="submit" class="btn btn-sm btn-outline-danger border-0" title="Delete folder" data-confirm="Delete folder "{{.Name}}"? Any mail in it moves to Trash, keeping this folder as a subfolder there so you can find it."><i class="bi bi-x-lg"></i></button>
|
|
</form>
|
|
{{end}}
|
|
</div>
|
|
{{if .Children}}
|
|
<div class="folder-children" data-children-of="{{.Name}}">
|
|
{{range .Children}}{{template "folderTreeNode" .}}{{end}}
|
|
</div>
|
|
{{end}}
|
|
{{end}}
|