2026-08-14 13:04:55 +01:00
{{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" >
< style >
2026-08-15 12:35:44 +01:00
html , body { background-color : #1a1a1a ; color : #e0e0e0 ; height : 100 % ; }
2026-08-14 13:04:55 +01:00
. card { background-color : #2d2d2d ; border : 1 px 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 : 2 px dashed #6ea8fe ; outline-offset : -2 px ; }
. folder-unread-badge { font-size : .7 rem ; }
. msg-row-older { display : none ; }
. msg-group-toggle { cursor : pointer ; }
2026-08-15 12:35:44 +01:00
/* 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 ( 100 vh - 56 px ); overflow : hidden ; }
. mail-sidebar { width : 230 px ; flex : 0 0 auto ; overflow-y : auto ; border-right : 1 px solid #404040 ; padding : .75 rem ; }
. mail-list-pane { width : 380 px ; flex : 0 0 auto ; overflow-y : auto ; border-right : 1 px solid #404040 ; display : flex ; flex-direction : column ; }
. mail-reading-pane { flex : 1 1 auto ; overflow-y : auto ; padding : 1.5 rem ; min-width : 0 ; }
. mail-toolbar { flex : 0 0 auto ; padding : .5 rem .75 rem ; border-bottom : 1 px solid #404040 ; display : flex ; align-items : center ; gap : .35 rem ; flex-wrap : wrap ; }
. mail-list-scroll { flex : 1 1 auto ; overflow-y : auto ; }
. mail-list-header { padding : .35 rem .75 rem ; font-size : .75 rem ; text-transform : uppercase ; color : #8a8a8a ; display : flex ; justify-content : space-between ; }
. msg-item { display : flex ; align-items : flex-start ; gap : .6 rem ; padding : .55 rem .75 rem ; border-bottom : 1 px solid #333 ; cursor : pointer ; }
. msg-item : hover { background-color : #262626 ; }
. msg-item . active { background-color : #0d3860 ; }
2026-08-15 16:31:27 +01:00
. msg-item : has ( . msg-check : checked ) { background-color : #2a2f36 ; }
2026-08-15 12:35:44 +01:00
. msg-item . unread . msg-subject { font-weight : 700 ; color : #fff ; }
. msg-item . unread . msg-from { font-weight : 700 ; color : #fff ; }
. msg-check { margin-top : .35 rem ; flex : 0 0 auto ; }
. msg-avatar { width : 34 px ; height : 34 px ; border-radius : 50 % ; background : #495057 ; color : #fff ; display : flex ; align-items : center ; justify-content : center ; font-size : .85 rem ; font-weight : 600 ; flex : 0 0 auto ; }
. msg-item . unread . msg-avatar { background : #0d6efd ; }
. msg-main { min-width : 0 ; flex : 1 1 auto ; }
2026-08-15 16:31:27 +01:00
. msg-row1 { display : flex ; align-items : center ; gap : .4 rem ; }
2026-08-15 12:35:44 +01:00
. msg-from { overflow : hidden ; text-overflow : ellipsis ; white-space : nowrap ; }
2026-08-15 16:31:27 +01:00
. 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 : .75 rem ; color : #8a8a8a ; margin-left : auto ; }
2026-08-15 12:35:44 +01:00
. msg-row2 { overflow : hidden ; text-overflow : ellipsis ; white-space : nowrap ; font-size : .85 rem ; color : #adb5bd ; }
. msg-subject { color : #e0e0e0 ; }
2026-08-15 16:31:27 +01:00
# folderContextMenu { z-index : 1085 ; }
. folder-row { display : flex ; align-items : center ; }
. folder-drag-handle { cursor : grab ; color : #6a6a6a ; padding : 0 .25 rem 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 : 16 px ; text-align : center ; cursor : pointer ; color : #8a8a8a ; font-size : .8 rem ; transition : transform .1 s ; }
. folder-expand-toggle : hover { color : #fff ; }
. folder-expand-toggle . collapsed { transform : rotate ( -90 deg ); }
. folder-expand-spacer { flex : 0 0 auto ; width : 16 px ; }
. folder-children { margin-left : 16 px ; }
. folder-children . collapsed { display : none ; }
2026-08-15 12:35:44 +01:00
. msg-body-html { background-color : #fff ; color : #000 ; border-radius : 6 px ; padding : 1 rem ; overflow-x : auto ; }
. msg-body-text { white-space : pre-wrap ; word-break : break-word ; }
# readingPaneBody . pane-toolbar { border-bottom : 1 px solid #404040 ; padding-bottom : .75 rem ; }
2026-08-14 13:04:55 +01:00
</ style >
</ head >
< body >
{{template "csrf_script" .}}
2026-08-15 12:35:44 +01:00
< 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}}" >
2026-08-14 13:04:55 +01:00
</ div >
2026-08-15 12:35:44 +01:00
</ 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/rules" class = "btn btn-outline-light btn-sm" title = "Rules" >< i class = "bi bi-funnel" ></ i ></ a >
< a href = "/webmail/certs" class = "btn btn-outline-light btn-sm" title = "Certs" >< i class = "bi bi-shield-lock" ></ i ></ a >
2026-08-15 16:31:27 +01:00
< a href = "/webmail/signatures" class = "btn btn-outline-light btn-sm" title = "Signatures" >< i class = "bi bi-pen" ></ i ></ a >
2026-08-15 12:35:44 +01:00
< a href = "/webmail/account" class = "btn btn-outline-light btn-sm" title = "Account" >< i class = "bi bi-gear" ></ i ></ a >
< form method = "post" action = "/webmail/logout" class = "d-inline" >
< button type = "submit" class = "btn btn-outline-light btn-sm" title = "Sign out" >< i class = "bi bi-box-arrow-right" ></ i ></ button >
</ form >
2026-08-14 13:04:55 +01:00
</ div >
</ nav >
< div class = "toast-container position-fixed top-0 end-0 p-3" style = "z-index: 1090;" >
{{range .flashes}}
2026-08-15 16:31:27 +01:00
< 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" >
2026-08-14 13:04:55 +01:00
< 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 >
2026-08-15 12:35:44 +01:00
< 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 >
2026-08-15 16:31:27 +01:00
< 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 >
2026-08-15 12:35:44 +01:00
</ div >
</ div >
2026-08-15 16:31:27 +01:00
< 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 >
2026-08-15 12:35:44 +01:00
</ div >
2026-08-15 16:31:27 +01:00
< 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 >
2026-08-15 12:35:44 +01:00
< 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 >
< 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 >
2026-08-15 16:31:27 +01:00
< 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 >
2026-08-15 12:35:44 +01:00
{{end}}
2026-08-14 13:04:55 +01:00
</ div >
</ div >
2026-08-15 12:35:44 +01:00
< div class = "mail-list-header" >
< span > {{if .search_query}}Search: “ {{.search_query}}” {{else}}{{.active_folder}}{{end}}</ span >
< span > {{.total}} message{{if ne .total 1}}s{{end}}</ span >
</ div >
< div class = "mail-list-scroll" id = "mailListScroll" >
{{if .messages}}
{{range .messages}}
{{$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}}" onclick = "event.stopPropagation()" >
< 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 >
2026-08-15 16:31:27 +01:00
< 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 >
2026-08-15 12:35:44 +01:00
< 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 >
2026-08-14 13:04:55 +01:00
</ div >
</ div >
2026-08-15 12:35:44 +01:00
{{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 >
2026-08-14 13:04:55 +01:00
</ div >
2026-08-15 12:35:44 +01:00
{{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 >
2026-08-14 13:04:55 +01:00
</ div >
</ div >
</ div >
2026-08-15 12:35:44 +01:00
< form method = "post" id = "bulkActionForm" class = "d-none" >
< input type = "hidden" name = "action" id = "bulkActionField" >
< input type = "hidden" name = "target_folder" id = "bulkTargetFolderField" >
</ form >
2026-08-14 13:04:55 +01:00
< 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 >
2026-08-15 16:31:27 +01:00
< 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 >
2026-08-14 13:04:55 +01:00
{{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 ();
});
}
2026-08-15 16:31:27 +01:00
// 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 ) => {
2026-08-14 13:04:55 +01:00
e . preventDefault ();
2026-08-15 16:31:27 +01:00
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 });
2026-08-14 13:04:55 +01:00
});
2026-08-15 16:31:27 +01:00
}
// 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 ();
}
2026-08-14 13:04:55 +01:00
});
// Drag a message row onto a folder in the sidebar to move it there — a
2026-08-15 12:35:44 +01:00
// 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.
2026-08-14 13:04:55 +01:00
( function () {
let draggedUID = null ;
let draggedFolder = null ;
document . querySelectorAll ( '.msg-row' ). forEach ( function ( row ) {
row . addEventListener ( 'dragstart' , function () {
draggedUID = row . dataset . uid ;
draggedFolder = row . dataset . folder ;
row . classList . add ( 'dragging' );
});
row . addEventListener ( 'dragend' , function () {
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 );
2026-08-15 12:35:44 +01:00
body . set ( 'csrf_token' , window . __csrfToken || '' );
2026-08-14 13:04:55 +01:00
await fetch ( `/webmail/mail/ ${ draggedFolder } / ${ draggedUID } /move` , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/x-www-form-urlencoded' },
body : body . toString (),
});
window . location . reload ();
});
});
})();
2026-08-15 16:31:27 +01:00
// 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 );
});
})();
2026-08-14 13:04:55 +01:00
// "+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 ();
2026-08-15 12:35:44 +01:00
let sib = badge . closest ( '.msg-item' ). nextElementSibling ;
2026-08-14 13:04:55 +01:00
while ( sib && sib . classList . contains ( 'msg-row-older' )) {
sib . style . display = '' ;
sib = sib . nextElementSibling ;
}
badge . style . display = 'none' ;
});
});
2026-08-15 12:35:44 +01:00
// 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 );
});
})();
2026-08-15 16:31:27 +01:00
// Reading pane + selection share one block because Shift/Ctrl-click needs to
// work when clicking anywhere on a row, not just its small checkbox — a plain
// click opens the message in the reading pane; Shift-click range-selects from
// the last row you interacted with (open or select) to this one; Ctrl/Cmd-click
// toggles just this row. Any of those needs the full row list + index, not just
// the checkbox's own click event, so it can't be two independent IIFEs the way
// "load the pane" and "manage checkboxes" would otherwise naturally split.
2026-08-15 12:35:44 +01:00
( function () {
const paneBody = document . getElementById ( 'readingPaneBody' );
2026-08-15 16:31:27 +01:00
const rows = Array . from ( document . querySelectorAll ( '.msg-item' ));
const checks = rows . map ( function ( row ) { return row . querySelector ( '.msg-check' ); });
2026-08-15 12:35:44 +01:00
const selectAll = document . getElementById ( 'selectAllCheck' );
const bulkBtns = document . querySelectorAll ( '.bulk-btn' );
const moveSelect = document . getElementById ( 'bulkMoveSelect' );
2026-08-15 16:31:27 +01:00
let lastIndex = null ;
2026-08-15 12:35:44 +01:00
function updateToolbar () {
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 ; });
}
2026-08-15 16:31:27 +01:00
function openInPane ( row ) {
if ( row . dataset . isDraft === 'true' ) { window . location . href = row . dataset . href ; return ; }
rows . 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>' ; });
}
rows . forEach ( function ( row , i ) {
row . addEventListener ( 'click' , function ( e ) {
if ( e . target . closest ( '.msg-group-toggle' ) || e . target . classList . contains ( 'msg-check' )) return ;
if ( e . shiftKey && lastIndex !== null ) {
e . preventDefault ();
const [ from , to ] = [ lastIndex , i ]. sort ( function ( a , b ) { return a - b ; });
for ( let j = from ; j <= to ; j ++ ) checks [ j ]. checked = true ;
updateToolbar ();
return ;
}
if ( e . ctrlKey || e . metaKey ) {
e . preventDefault ();
checks [ i ]. checked = ! checks [ i ]. checked ;
lastIndex = i ;
updateToolbar ();
return ;
}
lastIndex = i ;
openInPane ( row );
});
});
2026-08-15 12:35:44 +01:00
checks . forEach ( function ( cb , i ) {
cb . addEventListener ( 'click' , function ( e ) {
2026-08-15 16:31:27 +01:00
if ( e . shiftKey && lastIndex !== null ) {
const [ from , to ] = [ lastIndex , i ]. sort ( function ( a , b ) { return a - b ; });
2026-08-15 12:35:44 +01:00
for ( let j = from ; j <= to ; j ++ ) checks [ j ]. checked = cb . checked ;
}
2026-08-15 16:31:27 +01:00
lastIndex = i ;
2026-08-15 12:35:44 +01:00
updateToolbar ();
});
});
selectAll . addEventListener ( 'change' , function () {
checks . 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 ;
checks . forEach ( function ( c ) { c . checked = true ; });
updateToolbar ();
}
});
function selectedUIDs () { return checks . 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' && ! ( await showConfirmation ( 'Move the selected message(s) to Trash?' ))) return ;
submitBulk ( action );
});
});
if ( moveSelect ) {
moveSelect . addEventListener ( 'change' , function () {
if ( moveSelect . value ) submitBulk ( 'move' , moveSelect . value );
});
}
})();
2026-08-15 16:31:27 +01:00
// Star toggle — click the star icon to flip \Flagged (see
// db.ToggleMessageStarred) without a full page reload. stopPropagation keeps
// this from also triggering the row's own click (which would open the reading
// pane / range-select, see the combined IIFE above).
document . querySelectorAll ( '.msg-star' ). forEach ( function ( star ) {
star . addEventListener ( 'click' , async function ( e ) {
e . stopPropagation ();
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' ;
});
});
// 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 );
})();
2026-08-14 13:04:55 +01:00
</ script >
</ body >
</ html >
{{end}}
2026-08-15 16:31:27 +01:00
{{/* 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}}