Files
mailgoserver/internal/webui/templates/webmail_compose_widget.html
T

403 lines
21 KiB
HTML
Raw Normal View History

{{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>
<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>
<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';
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 */ }
}
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();
}
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';
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';
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) {}
frame.src = url;
2026-08-16 14:05:59 +01:00
saveOpenState();
};
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-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));
}
});
}
};
// 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
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();
})();
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
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;
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}}