2026-08-14 13:04:55 +01:00
{{define "compose_widget"}}
< div id = "composePopup" class = "card shadow" style = "display: none; position: fixed; z-index: 1080; min-width: 320px; min-height: 200px; resize: none; overflow: hidden; background-color: #2d2d2d; border: 1px solid #404040;" >
< div id = "composePopupHandle" class = "card-header d-flex justify-content-between align-items-center py-1" style = "cursor: move; user-select: none;" >
< span class = "small" >< i class = "bi bi-pencil-square me-1" ></ i > Compose</ span >
< div >
2026-08-16 14:05:59 +01:00
< button type = "button" id = "composePopupMinimize" class = "btn btn-sm btn-outline-light border-0 py-0 px-2" title = "Minimize" >< i class = "bi bi-dash-lg" ></ i ></ button >
< button type = "button" id = "composePopupMaximize" class = "btn btn-sm btn-outline-light border-0 py-0 px-2" title = "Maximize" >< i class = "bi bi-arrows-angle-expand" ></ i ></ button >
2026-08-14 13:04:55 +01:00
< button type = "button" id = "composePopupClose" class = "btn btn-sm btn-outline-light border-0 py-0 px-2" title = "Close" >< i class = "bi bi-x-lg" ></ i ></ button >
</ div >
</ div >
< iframe id = "composePopupFrame" style = "border: 0; width: 100%; flex: 1 1 auto;" ></ iframe >
< div id = "composePopupResize" style = "position: absolute; right: 0; bottom: 0; width: 16px; height: 16px; cursor: nwse-resize;" ></ div >
</ div >
2026-08-16 14:05:59 +01:00
{{/* Shown by the X button only when a draft actually exists (DRAFT_ID_KEY set) — a
blank/untouched compose just closes immediately, nothing to ask about. Lives here
(not per-host-page) since compose_widget is the one thing common to every page
that can show this. */}}
{{/* z-index bumped above #composePopup's own 1080 (see the popup div's inline style
above) — without it, Bootstrap's default modal z-index (1055) sits BELOW the
compose popup, so this prompt renders visually underneath/behind it instead of
over it. */}}
< div class = "modal fade" id = "composeCloseDraftModal" 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-save2 me-2" ></ i > Keep this draft?</ h5 >
< button type = "button" class = "btn-close" data-bs-dismiss = "modal" aria-label = "Close" ></ button >
</ div >
< div class = "modal-body" > This message was saved to Drafts. Keep it there, or discard it?</ 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 = "composeCloseDiscard" > Discard</ button >
< button type = "button" class = "btn btn-primary" id = "composeCloseKeep" > Keep Draft</ button >
</ div >
</ div >
</ div >
</ div >
2026-08-14 13:04:55 +01:00
< script >
// A floating window over the existing /webmail/mail/compose page (loaded as-is
// in an iframe) — Outlook-style compose without rewriting compose itself as a
// modal. Position/size persist in localStorage so it reopens where it was left.
( function () {
const POS_KEY = 'webmail_compose_popup_pos' ;
2026-08-16 14:05:59 +01:00
// Survives navigating to a different folder/message/settings page within this
// SAME tab (sessionStorage, not localStorage — deliberately NOT shared with
// other tabs, so two webmail tabs open at once don't fight over which one's
// compose gets restored into the other). OPEN_STATE_KEY's mere presence means
// "a compose was open when the last page unloaded"; DRAFT_ID_KEY (written by
// the compose iframe itself — see webmail_compose.html) is which draft to
// reload it from. Cleared on an explicit X close or once a send actually
// succeeds — see closePopup and the frame 'load' listener below.
const OPEN_STATE_KEY = 'webmail_compose_open_state' ;
const DRAFT_ID_KEY = 'webmail_compose_draft_id' ;
2026-08-14 13:04:55 +01:00
const popup = document . getElementById ( 'composePopup' );
const handle = document . getElementById ( 'composePopupHandle' );
const frame = document . getElementById ( 'composePopupFrame' );
const resizeHandle = document . getElementById ( 'composePopupResize' );
const closeBtn = document . getElementById ( 'composePopupClose' );
2026-08-16 14:05:59 +01:00
const minimizeBtn = document . getElementById ( 'composePopupMinimize' );
const maximizeBtn = document . getElementById ( 'composePopupMaximize' );
// 'normal' (draggable/resizable, wherever the user last left it) | 'minimized'
// (small bar pinned bottom-right, iframe hidden but NOT unloaded — its content/
// in-progress draft survives, same DOM element, just display:none) | 'maximized'
// (fills most of the viewport). savedRect is always the 'normal' rect to
// return to, captured the moment either alternate state is entered.
let state = 'normal' ;
let savedRect = null ;
function saveOpenState () {
try { sessionStorage . setItem ( OPEN_STATE_KEY , JSON . stringify ({ minimized : state === 'minimized' , maximized : state === 'maximized' })); } catch ( e ) { /* private browsing etc. — restoring across navigation just won't work, not fatal */ }
}
2026-08-14 13:04:55 +01:00
function defaultRect () {
const w = Math . min ( 720 , window . innerWidth - 40 );
const h = Math . min ( 600 , window . innerHeight - 40 );
return { top : Math . max ( 20 , ( window . innerHeight - h ) / 2 ), left : Math . max ( 20 , ( window . innerWidth - w ) / 2 ), width : w , height : h };
}
function clampRect ( r ) {
const width = Math . max ( 320 , Math . min ( r . width , window . innerWidth - 20 ));
const height = Math . max ( 200 , Math . min ( r . height , window . innerHeight - 20 ));
const left = Math . max ( 0 , Math . min ( r . left , window . innerWidth - width ));
const top = Math . max ( 0 , Math . min ( r . top , window . innerHeight - height ));
return { top , left , width , height };
}
function loadRect () {
try {
const saved = JSON . parse ( localStorage . getItem ( POS_KEY ));
if ( saved && typeof saved . top === 'number' ) return clampRect ( saved );
} catch ( e ) { /* fall through to default */ }
return defaultRect ();
}
function saveRect ( r ) {
localStorage . setItem ( POS_KEY , JSON . stringify ( r ));
}
function applyRect ( r ) {
popup . style . top = r . top + 'px' ;
popup . style . left = r . left + 'px' ;
popup . style . width = r . width + 'px' ;
popup . style . height = r . height + 'px' ;
}
function currentRect () {
return {
top : parseFloat ( popup . style . top ) || 0 ,
left : parseFloat ( popup . style . left ) || 0 ,
width : parseFloat ( popup . style . width ) || 0 ,
height : parseFloat ( popup . style . height ) || 0 ,
};
}
2026-08-16 14:05:59 +01:00
function setFrameVisible ( visible ) {
frame . style . display = visible ? '' : 'none' ;
resizeHandle . style . display = visible ? '' : 'none' ;
}
// restorePopup() undoes either alternate state, returning to the plain
// draggable/resizable window at whatever rect it had before minimizing/
// maximizing.
function restorePopup () {
state = 'normal' ;
popup . style . right = '' ;
popup . style . bottom = '' ;
popup . style . width = '' ;
popup . style . height = '' ;
popup . style . minWidth = '320px' ;
popup . style . minHeight = '200px' ;
applyRect ( savedRect || loadRect ());
setFrameVisible ( true );
minimizeBtn . style . display = '' ;
maximizeBtn . style . display = '' ;
maximizeBtn . querySelector ( 'i' ). className = 'bi bi-arrows-angle-expand' ;
maximizeBtn . title = 'Maximize' ;
saveOpenState ();
}
// doMinimize/doMaximize are the actual button actions, factored out so
// tryRestoreOnLoad (below) can re-apply whichever state was in effect when the
// previous page unloaded, right after reopening — not just the plain/normal
// window every fresh openCompose call would otherwise give it.
function doMinimize () {
if ( state === 'normal' ) savedRect = currentRect ();
state = 'minimized' ;
popup . style . top = '' ;
popup . style . left = '' ;
popup . style . right = '16px' ;
popup . style . bottom = '16px' ;
popup . style . width = '220px' ;
popup . style . height = 'auto' ;
popup . style . minWidth = '0' ;
popup . style . minHeight = '0' ;
setFrameVisible ( false );
minimizeBtn . style . display = 'none' ;
maximizeBtn . style . display = 'none' ;
saveOpenState ();
}
function doMaximize () {
if ( state === 'normal' ) savedRect = currentRect ();
state = 'maximized' ;
popup . style . right = '' ;
popup . style . bottom = '' ;
popup . style . minWidth = '320px' ;
popup . style . minHeight = '200px' ;
applyRect ({ top : 10 , left : 10 , width : window . innerWidth - 20 , height : window . innerHeight - 20 });
setFrameVisible ( true );
minimizeBtn . style . display = '' ;
maximizeBtn . style . display = '' ;
maximizeBtn . querySelector ( 'i' ). className = 'bi bi-arrows-angle-contract' ;
maximizeBtn . title = 'Restore' ;
saveOpenState ();
}
2026-08-14 13:04:55 +01:00
window . openCompose = function ( url ) {
2026-08-16 14:05:59 +01:00
state = 'normal' ;
savedRect = null ;
popup . style . right = '' ;
popup . style . bottom = '' ;
popup . style . width = '' ;
popup . style . height = '' ;
popup . style . minWidth = '320px' ;
popup . style . minHeight = '200px' ;
2026-08-14 13:04:55 +01:00
applyRect ( loadRect ());
2026-08-16 14:05:59 +01:00
setFrameVisible ( true );
minimizeBtn . style . display = '' ;
maximizeBtn . style . display = '' ;
maximizeBtn . querySelector ( 'i' ). className = 'bi bi-arrows-angle-expand' ;
maximizeBtn . title = 'Maximize' ;
2026-08-14 13:04:55 +01:00
popup . style . display = 'flex' ;
popup . style . flexDirection = 'column' ;
2026-08-16 14:05:59 +01:00
// Whatever draft the previous document in this iframe was pointing at no
// longer applies — the compose page being loaded now (fresh, or a restore
// via ?draft=X) re-establishes this itself as soon as it runs, see
// webmail_compose.html. Clearing it here first means a stray leftover
// value can never be mistaken for this new session's draft if the tab
// closes before that re-establish happens.
try { sessionStorage . removeItem ( DRAFT_ID_KEY ); } catch ( e ) {}
2026-08-14 13:04:55 +01:00
frame . src = url ;
2026-08-16 14:05:59 +01:00
saveOpenState ();
2026-08-14 13:04:55 +01:00
};
function closePopup () {
popup . style . display = 'none' ;
frame . src = 'about:blank' ;
2026-08-16 14:05:59 +01:00
try {
sessionStorage . removeItem ( OPEN_STATE_KEY );
sessionStorage . removeItem ( DRAFT_ID_KEY );
} catch ( e ) {}
2026-08-14 13:04:55 +01:00
}
2026-08-16 14:05:59 +01:00
// Keep/Discard/Cancel prompt for the X button, resolving to which one was
// picked ('cancel' for the backdrop/Escape/its own Cancel button, matching
// showConfirmation's cancel-on-dismiss convention elsewhere in this app).
function promptKeepOrDiscardDraft () {
return new Promise ( function ( resolve ) {
const modal = document . getElementById ( 'composeCloseDraftModal' );
const keepBtn = document . getElementById ( 'composeCloseKeep' );
const discardBtn = document . getElementById ( 'composeCloseDiscard' );
let choice = 'cancel' ;
const onKeep = function () { choice = 'keep' ; bootstrap . Modal . getInstance ( modal ). hide (); };
const onDiscard = function () { choice = 'discard' ; bootstrap . Modal . getInstance ( modal ). hide (); };
const onHidden = function () { cleanup (); resolve ( choice ); };
function cleanup () {
keepBtn . removeEventListener ( 'click' , onKeep );
discardBtn . removeEventListener ( 'click' , onDiscard );
modal . removeEventListener ( 'hidden.bs.modal' , onHidden );
}
keepBtn . addEventListener ( 'click' , onKeep );
discardBtn . addEventListener ( 'click' , onDiscard );
modal . addEventListener ( 'hidden.bs.modal' , onHidden , { once : true });
new bootstrap . Modal ( modal ). show ();
});
}
async function discardDraft ( draftId ) {
const body = new URLSearchParams ();
body . set ( 'csrf_token' , window . __csrfToken || '' );
try {
await fetch ( '/webmail/mail/Drafts/' + encodeURIComponent ( draftId ) + '/delete' , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/x-www-form-urlencoded' },
body : body . toString (),
});
} catch ( e ) { /* best effort — worst case the draft just stays in Drafts */ }
}
closeBtn . addEventListener ( 'click' , async function () {
let draftId ;
try { draftId = sessionStorage . getItem ( DRAFT_ID_KEY ); } catch ( e ) { draftId = null ; }
if ( ! draftId ) { closePopup (); return ; } // nothing was ever saved — nothing to ask about
const choice = await promptKeepOrDiscardDraft ();
if ( choice === 'cancel' ) return ; // leave the compose window open, untouched
if ( choice === 'discard' ) {
// Stop the iframe's own last-resort pagehide save (see
// webmail_compose.html) from firing once closePopup tears it down
// below — it would otherwise silently recreate the very draft just
// discarded, since its draft_id no longer exists once deleted and that
// save's own fallback is to create a brand-new one rather than update
// a now-gone row.
try { if ( frame . contentWindow ) frame . contentWindow . __composeFormSubmitting = true ; } catch ( e ) {}
await discardDraft ( draftId );
}
closePopup ();
});
// Minimize: shrinks to a small bar pinned bottom-right and hides the iframe
// (display:none on the element itself, never touching frame.src) — the
// in-progress draft's DOM/JS state is fully preserved, just not rendered, so
// expanding it again picks up exactly where you left off. Clicking anywhere on
// the minimized bar's handle (not a button) restores it, like a taskbar entry
// — the bar itself only ever shows the title plus Close, matching that.
minimizeBtn . addEventListener ( 'click' , doMinimize );
handle . addEventListener ( 'click' , function ( e ) {
if ( state === 'minimized' && ! e . target . closest ( 'button' )) restorePopup ();
});
maximizeBtn . addEventListener ( 'click' , function () {
if ( state === 'maximized' ) restorePopup ();
else doMaximize ();
});
// "Forward as attachment" (webmail_folder.html/webmail_message.html/
// webmail_message_pane.html's context-menu action and toolbar button, and the
// message-drag-onto-compose drop handler inside webmail_compose.html itself)
// — items is [{uid, folder, label}, ...]. If compose is already open with a
// loaded document, appends to its existing attachment list instead of
// reopening (which used to silently overwrite whatever was already attached —
// this is what makes attaching more than one message at a time work), popping
// it back into view first if it was minimized. Otherwise opens a fresh compose
// prefilled from the first item, then adds any remaining items once that fresh
// compose has finished loading.
window . forwardAsAttachment = function ( items ) {
if ( ! items || items . length === 0 ) return ;
if ( popup . style . display !== 'none' && frame . contentWindow && typeof frame . contentWindow . addForwardAttachments === 'function' ) {
if ( state === 'minimized' ) restorePopup ();
frame . contentWindow . addForwardAttachments ( items );
return ;
}
const first = items [ 0 ];
window . openCompose ( '/webmail/mail/compose?forward_attach=' + encodeURIComponent ( first . uid ) + '&folder=' + encodeURIComponent ( first . folder ));
if ( items . length > 1 ) {
frame . addEventListener ( 'load' , function onLoad () {
frame . removeEventListener ( 'load' , onLoad );
if ( typeof frame . contentWindow . addForwardAttachments === 'function' ) {
frame . contentWindow . addForwardAttachments ( items . slice ( 1 ));
}
});
}
};
2026-08-14 13:04:55 +01:00
// The compose form navigates the iframe on success (send/save-draft both
// redirect away from /mail/compose) — treat that as "done": close the popup
// and refresh the page underneath so the new Sent/Drafts entry shows up.
frame . addEventListener ( 'load' , function () {
let path ;
try { path = frame . contentWindow . location . pathname ; } catch ( e ) { return ; }
if ( frame . src === 'about:blank' || path . indexOf ( '/webmail/mail/compose' ) !== - 1 ) return ;
2026-08-16 14:05:59 +01:00
closePopup (); // also clears OPEN_STATE_KEY/DRAFT_ID_KEY — a sent message has nothing left to restore
2026-08-14 13:04:55 +01:00
window . location . reload ();
});
2026-08-16 14:05:59 +01:00
// Reopens whatever was open when the last page in this tab unloaded (a folder
// switch, opening a message, going into Settings — anything short of the X
// button, which clears OPEN_STATE_KEY via closePopup above) so an in-progress
// draft is never silently lost just from navigating around the mail client.
// Only meaningful once the compose iframe has actually written a real
// DRAFT_ID_KEY of its own (see webmail_compose.html) — if navigation happened
// before that (compose opened and left within a couple of seconds, nothing
// worth saving yet), there's nothing to restore and this is a no-op.
( function tryRestoreOnLoad () {
let openState ;
try {
const raw = sessionStorage . getItem ( OPEN_STATE_KEY );
if ( ! raw ) return ;
openState = JSON . parse ( raw );
} catch ( e ) { return ; }
let draftId ;
try { draftId = sessionStorage . getItem ( DRAFT_ID_KEY ); } catch ( e ) { return ; }
if ( ! draftId ) { try { sessionStorage . removeItem ( OPEN_STATE_KEY ); } catch ( e ) {} return ; }
window . openCompose ( '/webmail/mail/compose?draft=' + encodeURIComponent ( draftId ) + '&folder=Drafts' );
if ( openState && openState . minimized ) doMinimize ();
else if ( openState && openState . maximized ) doMaximize ();
})();
2026-08-14 13:04:55 +01:00
let dragOffset = null ;
handle . addEventListener ( 'mousedown' , function ( e ) {
if ( e . target . closest ( 'button' )) return ;
2026-08-16 14:05:59 +01:00
if ( state !== 'normal' ) return ; // minimized bar and maximized window aren't draggable
2026-08-14 13:04:55 +01:00
const r = popup . getBoundingClientRect ();
dragOffset = { x : e . clientX - r . left , y : e . clientY - r . top };
e . preventDefault ();
});
let resizing = false ;
resizeHandle . addEventListener ( 'mousedown' , function ( e ) {
2026-08-16 14:05:59 +01:00
if ( state !== 'normal' ) return ;
2026-08-14 13:04:55 +01:00
resizing = true ;
e . preventDefault ();
});
document . addEventListener ( 'mousemove' , function ( e ) {
if ( dragOffset ) {
const r = clampRect ({ top : e . clientY - dragOffset . y , left : e . clientX - dragOffset . x , width : currentRect (). width , height : currentRect (). height });
applyRect ( r );
} else if ( resizing ) {
const r = popup . getBoundingClientRect ();
const rect = clampRect ({ top : r . top , left : r . left , width : e . clientX - r . left , height : e . clientY - r . top });
applyRect ( rect );
}
});
document . addEventListener ( 'mouseup' , function () {
if ( dragOffset || resizing ) saveRect ( currentRect ());
dragOffset = null ;
resizing = false ;
});
})();
</ script >
{{end}}
2026-08-16 14:05:59 +01:00
{{/* webmail_account_menu is the "sign out" navbar button, everywhere it appears
(every standalone webmail page — see render.go's pagesWithComposeWidget, which
this piggybacks on since it's already parsed on exactly those pages) — now a
dropdown so there's somewhere for "sign in to admin dashboard" to live alongside
it, rather than a second always-visible button competing for navbar space. */}}
{{define "webmail_account_menu"}}
< div class = "btn-group" >
< button type = "button" class = "btn btn-outline-light btn-sm dropdown-toggle" data-bs-toggle = "dropdown" aria-expanded = "false" title = "Account" >< i class = "bi bi-box-arrow-right" ></ i ></ button >
< ul class = "dropdown-menu dropdown-menu-end" >
< li >< a class = "dropdown-item" href = "/smtp-server/login" target = "_blank" >< i class = "bi bi-shield-lock me-2" ></ i > Sign in to admin dashboard</ a ></ li >
< li >< hr class = "dropdown-divider" ></ li >
< li >
< form method = "post" action = "/webmail/logout" >
< button type = "submit" class = "dropdown-item" >< i class = "bi bi-box-arrow-right me-2" ></ i > Sign out</ button >
</ form >
</ li >
</ ul >
</ div >
{{end}}