403 lines
21 KiB
HTML
403 lines
21 KiB
HTML
{{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>
|
|
<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>
|
|
<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>
|
|
|
|
{{/* 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>
|
|
<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';
|
|
// 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';
|
|
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');
|
|
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 */ }
|
|
}
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
window.openCompose = function(url) {
|
|
state = 'normal';
|
|
savedRect = null;
|
|
popup.style.right = '';
|
|
popup.style.bottom = '';
|
|
popup.style.width = '';
|
|
popup.style.height = '';
|
|
popup.style.minWidth = '320px';
|
|
popup.style.minHeight = '200px';
|
|
applyRect(loadRect());
|
|
setFrameVisible(true);
|
|
minimizeBtn.style.display = '';
|
|
maximizeBtn.style.display = '';
|
|
maximizeBtn.querySelector('i').className = 'bi bi-arrows-angle-expand';
|
|
maximizeBtn.title = 'Maximize';
|
|
popup.style.display = 'flex';
|
|
popup.style.flexDirection = 'column';
|
|
// 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) {}
|
|
frame.src = url;
|
|
saveOpenState();
|
|
};
|
|
|
|
function closePopup() {
|
|
popup.style.display = 'none';
|
|
frame.src = 'about:blank';
|
|
try {
|
|
sessionStorage.removeItem(OPEN_STATE_KEY);
|
|
sessionStorage.removeItem(DRAFT_ID_KEY);
|
|
} catch (e) {}
|
|
}
|
|
|
|
// 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));
|
|
}
|
|
});
|
|
}
|
|
};
|
|
|
|
// 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;
|
|
closePopup(); // also clears OPEN_STATE_KEY/DRAFT_ID_KEY — a sent message has nothing left to restore
|
|
window.location.reload();
|
|
});
|
|
|
|
// 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();
|
|
})();
|
|
|
|
let dragOffset = null;
|
|
handle.addEventListener('mousedown', function(e) {
|
|
if (e.target.closest('button')) return;
|
|
if (state !== 'normal') return; // minimized bar and maximized window aren't draggable
|
|
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) {
|
|
if (state !== 'normal') return;
|
|
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}}
|
|
|
|
{{/* 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}}
|