/**
 * SeekQuarry/Yioop --
 * Open Source Pure PHP Search Engine, Crawler, and Indexer
 *
 * Copyright (C) 2009 - 2026  Chris Pollett chris@pollett.org
 *
 * LICENSE:
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 *
 * END LICENSE
 *
 * @author Chris Pollett chris@pollett.org
 * @license https://www.gnu.org/licenses/ GPL3
 * @link https://www.seekquarry.com/
 * @copyright 2009 - 2026
 * @filesource
 */
// state shared by the inbox infinite-scroll handlers
var loading_mail_messages = false;
var has_more_mail_messages = true;
var last_mail_scroll_time = 0;
// min milliseconds between two infinite-scroll fetches
const MAIL_SCROLL_THROTTLE_MS = 500;
// pixels-from-bottom that triggers loading the next older batch
const MAIL_SCROLL_BOTTOM_THRESHOLD_PX = 150;
// number of message rows to request per infinite-scroll batch
const MAIL_LOAD_BATCH_SIZE = 25;
/*
 * Handles scroll events on the inbox list to implement infinite
 * scroll. When the user nears the bottom of the already-loaded rows
 * the next older batch is fetched and appended. Throttled so a fast
 * scroll does not fire a burst of overlapping requests.
 */
function handleMailListScroll()
{
    var mail_list = elt('mail-inbox-scroll');
    if (!mail_list || loading_mail_messages || !has_more_mail_messages) {
        return;
    }
    var now = Date.now();
    if (now - last_mail_scroll_time < MAIL_SCROLL_THROTTLE_MS) {
        return;
    }
    last_mail_scroll_time = now;
    var remaining = mail_list.scrollHeight - mail_list.scrollTop -
        mail_list.clientHeight;
    if (remaining < MAIL_SCROLL_BOTTOM_THRESHOLD_PX) {
        loadMoreMailMessages();
    }
}
/*
 * Fetches the next older batch of inbox rows from the server and
 * appends them to the list. The server is asked for the messages
 * just below the oldest IMAP sequence number currently shown; it
 * replies with a small JSON payload carrying a ready-to-insert HTML
 * fragment plus the new oldest sequence number and a has-more flag.
 */
function loadMoreMailMessages()
{
    if (loading_mail_messages) {
        return;
    }
    var mail_list = elt('mail-inbox-scroll');
    var tbody = elt('mail-inbox-rows');
    if (!mail_list || !tbody) {
        return;
    }
    var account_id = attr(mail_list, 'data-account-id');
    var mode = attr(mail_list, 'data-sort-mode') || 'range';
    var oldest_seq = parseInt(
        attr(mail_list, 'data-oldest-seq'));
    var sort_offset = parseInt(
        attr(mail_list, 'data-sort-offset')) || 0;
    var loaded_count = parseInt(
        attr(mail_list, 'data-loaded-count'));
    if (!account_id) {
        has_more_mail_messages = false;
        return;
    }
    if (mode === 'range' && (!oldest_seq || oldest_seq <= 1)) {
        has_more_mail_messages = false;
        return;
    }
    if (mode === 'sorted' && sort_offset < 1) {
        has_more_mail_messages = false;
        return;
    }
    loading_mail_messages = true;
    var base_url = attr(mail_list, 'data-load-url');
    base_url += '&account_id=' + encodeURIComponent(account_id);
    if (mode === 'sorted') {
        base_url += '&offset=' + sort_offset;
    } else {
        base_url += '&before_seq=' + oldest_seq;
    }
    base_url += '&limit=' + MAIL_LOAD_BATCH_SIZE;
    fetch(base_url, {
        method: 'GET',
        headers: {
            'Accept': 'application/json'
        }
    })
    .then(response => {
        const content_type = response.headers.get('content-type');
        if (!content_type ||
            !content_type.includes('application/json')) {
            throw new Error('Server returned HTML instead of JSON');
        }
        return response.json();
    })
    .then(data => {
        if (data.error) {
            loading_mail_messages = false;
            has_more_mail_messages = false;
            return;
        }
        if (data.html && data.message_count > 0) {
            tbody.insertAdjacentHTML('beforeend', data.html);
            has_more_mail_messages = data.has_more;
            mail_list.setAttribute('data-loaded-count',
                loaded_count + data.message_count);
            if (mode === 'sorted' && data.offset) {
                mail_list.setAttribute('data-sort-offset',
                    data.offset);
            } else if (data.oldest_seq) {
                mail_list.setAttribute('data-oldest-seq',
                    data.oldest_seq);
            }
        } else {
            has_more_mail_messages = false;
        }
        loading_mail_messages = false;
    })
    .catch(error => {
        loading_mail_messages = false;
        has_more_mail_messages = false;
    });
}
// smallest width, in percent of the table, a column may be dragged to
const MAIL_COL_MIN_PERCENT = 8;
/*
 * Wires up the inbox column resizers. Each resizer is a thin handle
 * inside a header cell; dragging it widens or narrows that column's
 * colgroup <col> and gives the slack to (or takes it from) the
 * column to its right, so the table width stays constant. This
 * mirrors the split-adjuster drag handler in wiki.js, adapted from
 * sibling flex panes to colgroup columns: a table cell cannot have
 * a plain element as a sibling, so the handle lives inside the
 * header cell and names its <col> through a data- attribute rather
 * than relying on previousElementSibling / nextElementSibling.
 */
function initMailColumnResizers()
{
    var table = elt('mail-inbox-table');
    if (!table) {
        return;
    }
    var direction = attr(tag('html')[0], 'dir');
    var direction_sign = (direction === 'rtl') ? -1 : 1;
    var resizers = table.getElementsByClassName('mail-col-resizer');
    for (var i = 0; i < resizers.length; i++) {
        wireMailColumnResizer(resizers[i], table, direction_sign);
    }
}
/*
 * Binds the mousedown / mousemove / mouseup cycle for a single
 * column resize handle. On mousedown the starting cursor position
 * and the two affected columns' widths are recorded; mousemove
 * shifts width between them in percent of the table, clamped so
 * neither column drops below MAIL_COL_MIN_PERCENT; mouseup detaches
 * the document-level handlers.
 *
 * The column the handle resizes is its own header cell's <col>;
 * the column that absorbs the change is the next <col> after it.
 */
function wireMailColumnResizer(handle, table, direction_sign)
{
    var col = elt(attr(handle, 'data-resize-col'));
    if (!col) {
        return;
    }
    var next_col = col.nextElementSibling;
    if (!next_col) {
        return;
    }
    var start_x = 0;
    var start_col_width = 0;
    var start_next_width = 0;
    var table_width = 1;
    var mouseMoveHandler = function (e) {
        var dx = direction_sign * (e.clientX - start_x);
        var dx_percent = (dx * 100) / table_width;
        var new_col = start_col_width + dx_percent;
        var new_next = start_next_width - dx_percent;
        /* Block the drag only when it would push a shrinking
           column below the minimum. A column already narrower
           than the minimum -- the star column once the list pane
           is wide -- can still be grown, so the handle is never
           frozen in a dead zone. */
        if ((new_col < start_col_width &&
            new_col < MAIL_COL_MIN_PERCENT) ||
            (new_next < start_next_width &&
            new_next < MAIL_COL_MIN_PERCENT)) {
            return;
        }
        col.style.width = new_col + '%';
        next_col.style.width = new_next + '%';
        document.body.style.cursor = 'col-resize';
    };
    var mouseUpHandler = function () {
        document.body.style.removeProperty('cursor');
        unlisten(document, 'mousemove', mouseMoveHandler);
        unlisten(document, 'mouseup', mouseUpHandler);
    };
    var mouseDownHandler = function (e) {
        start_x = e.clientX;
        table_width = table.getBoundingClientRect().width || 1;
        var col_cell = handle.parentNode;
        var next_cell = col_cell.nextElementSibling;
        start_col_width = (col_cell.getBoundingClientRect().width *
            100) / table_width;
        start_next_width = next_cell ?
            (next_cell.getBoundingClientRect().width * 100) /
            table_width : 0;
        listen(document, 'mousemove', mouseMoveHandler);
        listen(document, 'mouseup', mouseUpHandler);
        e.preventDefault();
    };
    listen(handle, 'mousedown', mouseDownHandler);
}
listen(document, 'DOMContentLoaded', initMailColumnResizers);
// smallest height, in pixels, the mail container may be dragged to
const MAIL_CONTAINER_MIN_HEIGHT = 200;
/*
 * Wires up the mail-container vertical resizer. The handle is a
 * full-width strip just below the container; dragging it changes
 * the container's height. The container's CSS height is the
 * starting value and is overridden inline on drag; nothing is
 * persisted, so a reload restores the stylesheet default. Mirrors
 * the column-resizer drag cycle, on the vertical axis.
 */
function initMailContainerResizer()
{
    var handle = elt('mail-container-resizer');
    if (!handle) {
        return;
    }
    var container = handle.previousElementSibling;
    if (!container) {
        return;
    }
    var start_y = 0;
    var start_height = 0;
    var mouseMoveHandler = function (e) {
        var dy = e.clientY - start_y;
        var new_height = start_height + dy;
        if (new_height < MAIL_CONTAINER_MIN_HEIGHT) {
            return;
        }
        container.style.height = new_height + 'px';
        document.body.style.cursor = 'row-resize';
    };
    var mouseUpHandler = function () {
        document.body.style.removeProperty('cursor');
        unlisten(document, 'mousemove', mouseMoveHandler);
        unlisten(document, 'mouseup', mouseUpHandler);
    };
    var mouseDownHandler = function (e) {
        start_y = e.clientY;
        start_height = container.getBoundingClientRect().height;
        listen(document, 'mousemove', mouseMoveHandler);
        listen(document, 'mouseup', mouseUpHandler);
        e.preventDefault();
    };
    listen(handle, 'mousedown', mouseDownHandler);
}
listen(document, 'DOMContentLoaded', initMailContainerResizer);
// smallest width, in pixels, the mail container may be dragged to
const MAIL_CONTAINER_MIN_WIDTH = 320;
/* gap, in pixels, kept on each side between the widened mail
   container and the viewport edge so it never slams the edge */
const MAIL_CONTAINER_VIEWPORT_INSET = 16;
/*
 * Wires up the mail-container horizontal resizer. The handle is a
 * strip pinned to the container's right edge (the container is
 * position: relative so the absolutely-positioned handle anchors
 * there); dragging it changes the container's width. Like the
 * vertical resizer the CSS width is the starting value, overridden
 * inline on drag, and nothing is persisted. The handle sits inside
 * the container, so the container is its parentNode rather than a
 * previous sibling.
 */
function initMailContainerHResizer()
{
    var handle = elt('mail-container-h-resizer');
    if (!handle) {
        return;
    }
    var container = handle.parentNode;
    if (!container) {
        return;
    }
    var start_x = 0;
    var start_width = 0;
    var mouseMoveHandler = function (e) {
        var dx = e.clientX - start_x;
        var new_width = start_width + dx;
        if (new_width < MAIL_CONTAINER_MIN_WIDTH) {
            return;
        }
        /* the container may grow wider than its parent column; the
           ceiling is the viewport less a small inset on each side
           so it never slams the very edge. */
        var viewport_width = document.documentElement.clientWidth;
        var max_width = viewport_width -
            2 * MAIL_CONTAINER_VIEWPORT_INSET;
        if (new_width > max_width) {
            new_width = max_width;
        }
        /* keep the container centered in the viewport. margin:auto
           cannot do this once the container is wider than its
           parent, so the offset is computed and applied directly:
           pull the container left from its parent's content edge
           by enough that its own midpoint meets the viewport
           midpoint. */
        var parent_left =
            container.parentNode.getBoundingClientRect().left;
        var margin_left = (viewport_width - new_width) / 2 -
            parent_left;
        container.style.width = new_width + 'px';
        container.style.marginLeft = margin_left + 'px';
        document.body.style.cursor = 'col-resize';
    };
    var mouseUpHandler = function () {
        document.body.style.removeProperty('cursor');
        unlisten(document, 'mousemove', mouseMoveHandler);
        unlisten(document, 'mouseup', mouseUpHandler);
    };
    var mouseDownHandler = function (e) {
        start_x = e.clientX;
        start_width = container.getBoundingClientRect().width;
        listen(document, 'mousemove', mouseMoveHandler);
        listen(document, 'mouseup', mouseUpHandler);
        e.preventDefault();
    };
    listen(handle, 'mousedown', mouseDownHandler);
}
listen(document, 'DOMContentLoaded', initMailContainerHResizer);
/* triangle glyphs for the expanded and collapsed disclosure
   states. the values come from an inline script in MailElement
   so they can be localized; the fallbacks here cover the case
   where that script did not run. */
function mailTriangleExpanded()
{
    return window.MAIL_TRIANGLE_EXPANDED || "\u25BC";
}
function mailTriangleCollapsed()
{
    return window.MAIL_TRIANGLE_COLLAPSED || "\u25B6";
}
/*
 * Wires up the account folder-list disclosure toggles. Each
 * account name carries a .user-accounts-toggle span; clicking it
 * shows or hides that account's folder list, swaps the triangle
 * glyph, and persists the new state to the session through the
 * toggleAccount endpoint so it survives later page loads. The
 * show/hide happens immediately on the client; the request is
 * fire-and-forget since a failed persist only means the state is
 * not remembered, not that the current view is wrong.
 */
function initMailAccountToggles()
{
    var toggles =
        sel('.user-accounts-toggle');
    for (var i = 0; i < toggles.length; i++) {
        wireMailAccountToggle(toggles[i]);
    }
}
/*
 * Binds the click handler for a single account disclosure toggle.
 * The matching folder list is found by id from the toggle's
 * account id; its collapsed class and the triangle glyph are
 * flipped, and the new collapsed state is sent to the server.
 *
 * @param toggle the .user-accounts-toggle span for one account
 */
function wireMailAccountToggle(toggle)
{
    var account_id = attr(toggle, 'data-account-id');
    var folder_list = elt('user-accounts-folders-' + account_id);
    if (!folder_list) {
        return;
    }
    var clickHandler = function () {
        /* first click on a not-yet-loaded account: just fetch
           the folder list. the triangle is currently pointing
           collapsed (at the account name) and the ul is empty;
           lazyLoadMailAccountFolders flips the triangle to the
           expanded glyph on success. skip the collapse/expand
           class flipping and the persist call — there is
           nothing to collapse from. */
        if (attr(toggle, 'data-folders-loaded') !== '1') {
            lazyLoadMailAccountFolders(toggle, account_id,
                folder_list);
            return;
        }
        var now_collapsed = !folder_list.classList.contains(
            'user-accounts-folders-collapsed');
        if (now_collapsed) {
            folder_list.classList.add(
                'user-accounts-folders-collapsed');
        } else {
            folder_list.classList.remove(
                'user-accounts-folders-collapsed');
        }
        var triangle = toggle.querySelector(
            '.user-accounts-triangle');
        if (triangle) {
            triangle.textContent = now_collapsed ?
                mailTriangleCollapsed() : mailTriangleExpanded();
        }
        toggle.setAttribute('data-collapsed',
            now_collapsed ? '1' : '0');
        persistMailAccountToggle(toggle, account_id, now_collapsed);
    };
    listen(toggle, 'click', clickHandler);
}
/*
 * Sends an account's new collapsed state to the toggleAccount
 * endpoint. Fire-and-forget: the visible state has already been
 * updated, so a network failure only costs persistence.
 *
 * @param toggle the .user-accounts-toggle span for the account
 * @param account_id the id of the account whose state changed
 * @param collapsed true when the folder list is now collapsed
 */
function persistMailAccountToggle(toggle, account_id, collapsed)
{
    var toggle_url = attr(toggle, 'data-toggle-url');
    if (!toggle_url) {
        return;
    }
    toggle_url += '&account_id=' + encodeURIComponent(account_id);
    toggle_url += '&collapsed=' + (collapsed ? '1' : '0');
    fetch(toggle_url, {
        method: 'GET',
        headers: {
            'Accept': 'application/json'
        }
    })
    .catch(error => {
    });
}
/*
 * Asks the listFolders endpoint for an account's folder rows
 * and drops them into the disclosure ul. Mark the toggle as
 * loaded so a subsequent expand does not refetch. A network or
 * IMAP error leaves the ul empty; the user can click again to
 * retry.
 *
 * @param toggle the .user-accounts-toggle span for the account
 * @param account_id the id of the account to load folders for
 * @param folder_list the ul element to drop the rendered rows
 *      into
 */
function lazyLoadMailAccountFolders(toggle, account_id, folder_list)
{
    var folders_url = attr(toggle, 'data-folders-url');
    if (!folders_url) {
        return;
    }
    folders_url += '&account_id=' + encodeURIComponent(account_id);
    fetch(folders_url, {
        method: 'GET',
        headers: {
            'Accept': 'application/json'
        }
    })
    .then(response => response.json())
    .then(data => {
        if (data.status !== 'OK' || !data.html) {
            return;
        }
        folder_list.innerHTML = data.html;
        wireMailFolderTree(folder_list);
        toggle.setAttribute('data-folders-loaded', '1');
        /* the triangle was pointing at the account name while
           the ul was empty; folders are now visible underneath
           so the expanded glyph is the honest state. */
        var triangle = toggle.querySelector(
            '.user-accounts-triangle');
        if (triangle) {
            triangle.textContent = mailTriangleExpanded();
        }
    })
    .catch(error => {
    });
}
/*
 * Wires the nested-folder-tree affordances under $root:
 *   - rows that directly contain a .user-accounts-folder-children
 *     ul get the .user-accounts-folder-has-children class so the
 *     CSS reveals their disclosure arrow.
 *   - clicking the disclosure arrow toggles the parent row's
 *     .expanded class. the click does not propagate to the
 *     folder name link so navigating-by-name stays a separate
 *     gesture from expand-by-arrow.
 *
 * Idempotent: safe to call again after lazy-loaded folder html
 * is injected, or after a folder rename rewrites the tree.
 *
 * @param root DOM element under which to wire folder rows
 */
function wireMailFolderTree(root)
{
    if (!root) {return;}
    var rows = root.querySelectorAll(
        '.user-accounts-folder-row');
    for (var i = 0; i < rows.length; i++) {
        var children = rows[i].querySelector(
            ':scope > .user-accounts-folder-children');
        if (children) {
            rows[i].classList.add(
                'user-accounts-folder-has-children');
        }
    }
    listen(root, 'click', function (e) {
        if (!e.target || !e.target.classList ||
            !e.target.classList.contains(
            'user-accounts-folder-disclosure')) {
            return;
        }
        var row = e.target.closest('.user-accounts-folder-row');
        if (!row) {return;}
        e.preventDefault();
        e.stopPropagation();
        row.classList.toggle('expanded');
        persistMailFolderExpanded(row);
    });
}
/*
 * Sends a folder's new expanded/collapsed state to the
 * toggleFolderExpanded endpoint. Fire-and-forget: the visible
 * state has already been updated, so a network failure only
 * costs persistence across the next page load.
 *
 * The URL base lives on the outer .user-accounts-folders ul as
 * data-folder-expand-url; the per-row account_id and folder
 * name come from data-* on the row itself.
 *
 * @param row the .user-accounts-folder-row whose state changed
 */
function persistMailFolderExpanded(row)
{
    var list = row.closest('.user-accounts-folders');
    if (!list) {return;}
    var url = attr(list, 'data-folder-expand-url');
    if (!url) {return;}
    var account_id = row.dataset.accountId;
    var folder = row.dataset.folderName;
    var expanded = row.classList.contains('expanded');
    if (!account_id || !folder) {return;}
    url += '&account_id=' + encodeURIComponent(account_id);
    url += '&folder=' + encodeURIComponent(folder);
    url += '&expanded=' + (expanded ? '1' : '0');
    fetch(url, {
        method: 'GET',
        headers: {
            'Accept': 'application/json'
        }
    })
    .catch(error => {
    });
}
listen(document, 'DOMContentLoaded', initMailAccountToggles);
listen(document, 'DOMContentLoaded', function () {
    var lists = sel('.user-accounts-folders');
    for (var i = 0; i < lists.length; i++) {
        wireMailFolderTree(lists[i]);
    }
});
/*
 * Wires up the provider preset dropdown on the account form.
 * Choosing a provider fills the six IMAP and SMTP server fields
 * from that option's data attributes; the username, password and
 * display-name fields are never touched. The "custom" option
 * carries no data attributes, so choosing it leaves the form as
 * it is.
 */
function initMailProviderPreset()
{
    var select = elt('ma-provider');
    if (!select) {
        return;
    }
    var username_label = elt('ma-username-label');
    var preset_rows = sel('.mail-account-preset-field');
    var changeHandler = function () {
        var option = select.options[select.selectedIndex];
        var imap_host = attr(option, 'data-imap-host');
        var is_preset = !!imap_host;
        if (is_preset) {
            applyMailFieldValue('ma-host', imap_host);
            applyMailFieldValue('ma-port', attr(option,
                'data-imap-port'));
            applyMailFieldValue('ma-tls-mode', attr(option,
                'data-imap-tls'));
            applyMailFieldValue('ma-smtp-host', attr(option,
                'data-smtp-host'));
            applyMailFieldValue('ma-smtp-port', attr(option,
                'data-smtp-port'));
            applyMailFieldValue('ma-smtp-tls-mode', attr(option,
                'data-smtp-tls'));
        }
        for (var i = 0; i < preset_rows.length; i++) {
            if (is_preset) {
                preset_rows[i].classList.add('none');
            } else {
                preset_rows[i].classList.remove('none');
            }
        }
        if (username_label) {
            var login_label = attr(option, 'data-login-label');
            var username_text = attr(select,
                'data-username-text');
            var email_text = attr(select, 'data-email-text');
            if (login_label === 'email' && email_text) {
                username_label.textContent = email_text + ':';
            } else if (username_text) {
                username_label.textContent = username_text + ':';
            }
        }
    };
    listen(select, 'change', changeHandler);
}
/*
 * Sets a form field's value by element id, doing nothing when no
 * element with that id is present.
 *
 * @param field_id the id of the input or select to set
 * @param value the value to assign
 */
function applyMailFieldValue(field_id, value)
{
    var field = elt(field_id);
    if (field) {
        field.value = value;
    }
}
listen(document, 'DOMContentLoaded', initMailProviderPreset);
// milliseconds between auto-refresh ticks for the inbox list
const MAIL_AUTO_REFRESH_INTERVAL = 60000;
/* idle threshold in milliseconds: a tick that finds the user has
   been active more recently than this is skipped and tried again
   next tick */
const MAIL_AUTO_REFRESH_IDLE = 10000;
// timestamp of the last observed user activity on the inbox page
var last_mail_activity_time = Date.now();
/*
 * Wires up the inbox auto-refresh: every minute, if the user has
 * been idle for at least the idle threshold, the page navigates
 * to the refresh URL (the same URL the user-clickable refresh
 * link points at). User activity (mousemove, keydown, scroll) is
 * recorded so an active session never reloads beneath the user;
 * the timer also only runs on a page that actually shows the
 * inbox list. Folder, account and other state are preserved
 * because the refresh URL carries them.
 */
function initMailAutoRefresh()
{
    var scroll_pane = elt('mail-inbox-scroll');
    var refresh_container = elt('mail-refresh-link');
    var refresh_link = refresh_container ?
        refresh_container.querySelector('a') : null;
    if (!scroll_pane || !refresh_link) {
        return;
    }
    var noteActivity = function () {
        last_mail_activity_time = Date.now();
    };
    listen(document, 'mousemove', noteActivity);
    listen(document, 'keydown', noteActivity);
    listen(scroll_pane, 'scroll', noteActivity);
    var tickHandler = function () {
        var idle_for = Date.now() - last_mail_activity_time;
        if (idle_for < MAIL_AUTO_REFRESH_IDLE) {
            return;
        }
        window.location.href = refresh_link.href;
    };
    setInterval(tickHandler, MAIL_AUTO_REFRESH_INTERVAL);
}
listen(document, 'DOMContentLoaded', initMailAutoRefresh);
/*
 * Persists the left-column account list scroll position across
 * page renders so a user with many inboxes (MailSite, Pollett,
 * Gmail, etc.) stays scrolled to their active account after a
 * full-page redirect lands a flash message in the right pane.
 * sessionStorage is per-tab so multi-tab users do not interfere
 * with each other. Bound on DOMContentLoaded; restores any saved
 * scrollTop then listens for scroll events to save the new
 * position.
 */
function initMailSidebarScroll()
{
    var sidebar = document.querySelector('.user-accounts');
    if (!sidebar) {
        return;
    }
    var saved = sessionStorage.getItem('mail_sidebar_scroll');
    if (saved !== null) {
        sidebar.scrollTop = parseInt(saved, 10) || 0;
    }
    listen(sidebar, 'scroll', function () {
        sessionStorage.setItem('mail_sidebar_scroll',
            String(sidebar.scrollTop));
    });
}
listen(document, 'DOMContentLoaded', initMailSidebarScroll);
/* delay, in ms, after the last filter keystroke before navigating
   to the filtered URL; keeps a typist from triggering a reload
   per key. */
const MAIL_FILTER_DEBOUNCE = 300;
/*
 * Wires up the message-list filter input. Typing into the input
 * fires a debounced navigation to the same listMessages URL with
 * a filter=... parameter (or with that parameter removed when the
 * input is cleared). The clear-x icon, present only when the
 * input has a value, blanks the input and fires the navigate
 * immediately. Sort key/direction in the URL is preserved so
 * filter and sort co-exist.
 */
function initMailFilter()
{
    var input = elt('mail-filter-input');
    if (!input) {
        return;
    }
    var debounce_timer = null;
    var navigate = function () {
        var base = attr(input, 'data-filter-url');
        if (!base) {
            return;
        }
        var value = input.value.trim();
        var url = base;
        if (value !== '') {
            url += '&filter=' + encodeURIComponent(value);
        }
        window.location.href = url;
    };
    var keyHandler = function () {
        if (debounce_timer !== null) {
            clearTimeout(debounce_timer);
        }
        debounce_timer = setTimeout(navigate,
            MAIL_FILTER_DEBOUNCE);
    };
    listen(input, 'input', keyHandler);
    var clear_btn = elt('mail-filter-clear');
    if (clear_btn) {
        listen(clear_btn, 'click', function () {
            input.value = '';
            if (debounce_timer !== null) {
                clearTimeout(debounce_timer);
            }
            navigate();
        });
    }
}
listen(document, 'DOMContentLoaded', initMailFilter);
/*
 * Wires up the in-message-view affordances: the raw-toggle button
 * in the header that swaps between rendered and raw views, and the
 * "load remote images" button that reveals blocked images by
 * removing their mail-blocked-image class, so the browser loads
 * each one natively. Both are no-ops on pages that do not render a
 * message (the buttons are absent so the listeners never wire up).
 */
function initMailMessageView()
{
    var toggle = document.querySelector(
        '.mail-message-toggle-raw');
    if (toggle) {
        listen(toggle, 'click', function () {
            var rendered = document.querySelector(
                '[data-mail-view="rendered"]');
            var raw = document.querySelector(
                '[data-mail-view="raw"]');
            if (!rendered || !raw) {return;}
            var showing_raw = !raw.classList.contains('none');
            var pretty_only = sel(
                '[data-mail-pretty-only]');
            if (showing_raw) {
                raw.classList.add('none');
                rendered.classList.remove('none');
                for (var i = 0; i < pretty_only.length; i++) {
                    pretty_only[i].classList.remove('none');
                }
                toggle.setAttribute('aria-pressed', 'false');
            } else {
                rendered.classList.add('none');
                raw.classList.remove('none');
                for (var i = 0; i < pretty_only.length; i++) {
                    pretty_only[i].classList.add('none');
                }
                toggle.setAttribute('aria-pressed', 'true');
            }
        });
    }
    var dkim_badge = document.querySelector('.mail-dkim-icon');
    if (dkim_badge) {
        listen(dkim_badge, 'click', function () {
            var detail = document.querySelector(
                '.mail-dkim-detail');
            if (!detail) {return;}
            var hidden = detail.classList.contains('none');
            if (hidden) {
                detail.classList.remove('none');
                dkim_badge.setAttribute('aria-expanded', 'true');
            } else {
                detail.classList.add('none');
                dkim_badge.setAttribute('aria-expanded', 'false');
            }
        });
    }
}
listen(document, 'DOMContentLoaded', initMailMessageView);
/*
 * Reveals the remote images in a message body. Bound once on the
 * document rather than per button, so it works no matter when the
 * message view enters the page -- including a view swapped in by
 * initMailMessageNav, where wiring the button at insertion time has
 * proven unreliable. A click on a load-images button drops the
 * mail-blocked-image class from every blocked image and forces its
 * load: the loading="lazy" attribute is removed and the src is
 * re-assigned, which starts the fetch even for an image that arrived
 * through innerHTML, where a still-hidden lazy image otherwise never
 * begins loading. The banner then hides itself.
 */
function initMailLoadImages()
{
    listen(document, 'click', function (event) {
        var button = event.target.closest(
            '.mail-message-load-images-button');
        if (!button) {
            return;
        }
        var blocked = sel('img.mail-blocked-image');
        for (var i = 0; i < blocked.length; i++) {
            blocked[i].removeAttribute('loading');
            blocked[i].classList.remove('mail-blocked-image');
            blocked[i].src = blocked[i].src;
        }
        var banner = button.closest('.mail-message-load-images');
        if (banner) {
            banner.classList.add('none');
        }
    });
}
listen(document, 'DOMContentLoaded', initMailLoadImages);
/*
 * Opens a message in the reading pane without a full page
 * navigation. The inbox subject links carry the same URL an ordinary
 * navigation would use and are marked with mail-open-message; this
 * intercepts a plain left click on one, fetches that page, lifts its
 * .account-messages content into the current pane, and re-wires the
 * message-view controls. The browser keeps the scripts and styles it
 * already loaded, so opening a message no longer reloads and reparses
 * the whole shell. Modifier and middle clicks are left alone so
 * open-in-new-tab still works, and any failure falls back to a normal
 * navigation so a click is never stranded.
 */
function initMailMessageNav()
{
    listen(document, 'click', function (event) {
        if (event.button !== 0 || event.metaKey || event.ctrlKey ||
                event.shiftKey || event.altKey ||
                event.defaultPrevented) {
            return;
        }
        var link = event.target.closest('a.mail-open-message');
        if (!link) {
            return;
        }
        event.preventDefault();
        loadMailReadingPane(link.href, true);
    });
    listen(window, 'popstate', function () {
        location.reload();
    });
}
/*
 * Replaces the reading pane with a brief loading indicator so a click
 * on a message gives immediate feedback while its page is fetched.
 * Built with DOM calls rather than an HTML string so the translated
 * label is set as text and never parsed as markup.
 *
 * @param pane the .account-messages element to fill
 */
function showMailPaneLoading(pane)
{
    var wrap = document.createElement('div');
    wrap.className = 'mail-loading';
    wrap.setAttribute('role', 'status');
    var spinner = document.createElement('span');
    spinner.className = 'mail-loading-spinner';
    spinner.setAttribute('aria-hidden', 'true');
    var label = document.createElement('span');
    label.textContent = window.MAIL_LOADING || 'Loading';
    wrap.appendChild(spinner);
    wrap.appendChild(label);
    pane.innerHTML = '';
    pane.appendChild(wrap);
}
/*
 * Fetches a Mail page and swaps its .account-messages content into
 * the current one, then re-wires the message-view controls. When push
 * is true the visited URL is pushed onto history so reload and the
 * back button still show the right thing (a back navigation reloads
 * the pane the address bar then names). Any failure -- a network
 * error, or a response without the pane such as a re-login redirect
 * -- falls back to an ordinary navigation.
 *
 * @param url the Mail page URL to open in place
 * @param push whether to push url onto the history stack
 */
function loadMailReadingPane(url, push)
{
    var pane = document.querySelector('.account-messages');
    if (!pane) {
        location.href = url;
        return;
    }
    showMailPaneLoading(pane);
    fetch(url, {method: 'GET'})
    .then(function (response) {
        if (!response.ok) {
            throw new Error('bad status');
        }
        return response.text();
    })
    .then(function (text) {
        var parsed = new DOMParser().parseFromString(text,
            'text/html');
        var fresh = parsed.querySelector('.account-messages');
        if (!fresh) {
            throw new Error('no pane');
        }
        pane.innerHTML = fresh.innerHTML;
        if (push) {
            history.pushState({}, '', url);
        }
        pane.scrollTop = 0;
        initMailMessageView();
    })
    .catch(function () {
        location.href = url;
    });
}
listen(document, 'DOMContentLoaded', initMailMessageNav);
/*
 * Wires up the compose-attachment UI: the hidden file input, the
 * drag-drop zone, and the per-file removal buttons. No-op on
 * pages without a compose form. FileList is read-only, so file
 * mutation goes through a fresh DataTransfer whose items list we
 * build up and then assign back to input.files.
 */
function initMailCompose()
{
    var input = elt('mail-compose-attachments');
    var form = document.querySelector('.mail-compose-form');
    var list = elt('mail-compose-file-list');
    if (!input || !form || !list) {return;}
    /* threshold above which sizes display in MB rather than KB. */
    var SIZE_KB_THRESHOLD = 1024 * 1024;
    /* tracked blob URLs so we can revoke when the list is
       rebuilt; otherwise the browser holds the File ref forever. */
    var blob_urls = [];
    function formatSize(bytes)
    {
        if (bytes < SIZE_KB_THRESHOLD) {
            return Math.max(1, Math.round(bytes / 1024)) + ' KB';
        }
        return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
    }
    function extLabel(name)
    {
        var dot = name.lastIndexOf('.');
        if (dot < 0 || dot === name.length - 1) return 'FILE';
        return name.substring(dot + 1).toUpperCase().substring(0,
            4);
    }
    function revokeBlobUrls()
    {
        for (var i = 0; i < blob_urls.length; i++) {
            URL.revokeObjectURL(blob_urls[i]);
        }
        blob_urls = [];
    }
    function renderList()
    {
        revokeBlobUrls();
        list.innerHTML = '';
        var files = input.files;
        for (var i = 0; i < files.length; i++) {
            var card = ce('div');
            card.className = 'mail-compose-attachment-card';
            var thumb = ce('div');
            thumb.className = 'mail-compose-attachment-thumb';
            if (files[i].type.indexOf('image/') === 0) {
                var img = ce('img');
                var url = URL.createObjectURL(files[i]);
                blob_urls.push(url);
                img.src = url;
                img.alt = '';
                thumb.appendChild(img);
            } else {
                var label = ce('span');
                label.className =
                    'mail-compose-attachment-ext-label';
                label.textContent = extLabel(files[i].name);
                thumb.appendChild(label);
            }
            var info = ce('div');
            info.className = 'mail-compose-attachment-info';
            var name = ce('div');
            name.className = 'mail-compose-attachment-name';
            name.textContent = files[i].name;
            var size = ce('div');
            size.className = 'mail-compose-attachment-size';
            size.textContent = formatSize(files[i].size);
            info.appendChild(name);
            info.appendChild(size);
            var remove = ce('button');
            remove.type = 'button';
            remove.className = 'mail-compose-attachment-remove';
            remove.textContent = '\u00D7';
            remove.setAttribute('aria-label',
                'Remove ' + files[i].name);
            remove.dataset.index = i;
            listen(remove, 'click', removeOne);
            card.appendChild(thumb);
            card.appendChild(info);
            card.appendChild(remove);
            list.appendChild(card);
        }
    }
    function removeOne(e)
    {
        var idx = parseInt(e.target.dataset.index, 10);
        var dt = new DataTransfer();
        for (var i = 0; i < input.files.length; i++) {
            if (i !== idx) dt.items.add(input.files[i]);
        }
        input.files = dt.files;
        renderList();
    }
    listen(input, 'change', renderList);
    /* form-wide drop target: drag anywhere onto the compose
       form to attach (Yahoo-style). dragleave checks
       relatedTarget so the .dragover class doesn't flicker
       when the cursor crosses a child element boundary inside
       the form. */
    listen(form, 'dragover', function (e) {
        e.preventDefault();
        form.classList.add('dragover');
    });
    listen(form, 'dragleave', function (e) {
        if (!e.relatedTarget || !form.contains(e.relatedTarget)) {
            form.classList.remove('dragover');
        }
    });
    listen(form, 'drop', function (e) {
        e.preventDefault();
        form.classList.remove('dragover');
        var dt = new DataTransfer();
        for (var i = 0; i < input.files.length; i++) {
            dt.items.add(input.files[i]);
        }
        var dropped = e.dataTransfer.files;
        for (var j = 0; j < dropped.length; j++) {
            dt.items.add(dropped[j]);
        }
        input.files = dt.files;
        renderList();
    });
}
listen(document, 'DOMContentLoaded', initMailCompose);
/*
 * Wires up the Cc/Bcc reveal-toggle buttons on the compose TO
 * row AND the in-field collapse buttons on the expanded Cc/Bcc
 * rows. Reveal: clicking a toggle shows its target row, hides
 * the toggle, focuses the newly-shown input. Collapse: clicking
 * the in-field label button hides the row, restores its matching
 * toggle on the To row, and clears the input value so the form
 * doesn't carry a stray address the user can no longer see.
 * Both directions update aria-expanded for screen readers.
 * Layout is direction-agnostic via flexbox -- the buttons sit at
 * the row's inline-end in LTR and inline-start in RTL.
 */
function initMailComposeRecipients()
{
    var toggles = sel('.mail-compose-recipient-toggle');
    for (var i = 0; i < toggles.length; i++) {
        listen(toggles[i], 'click', function (e) {
            e.preventDefault();
            var target_id = attr(e.currentTarget, 'data-target');
            var row = elt(target_id);
            if (!row) {return;}
            row.classList.remove('none');
            e.currentTarget.setAttribute('aria-expanded', 'true');
            e.currentTarget.classList.add('none');
            var field_input = row.querySelector(
                '.mail-compose-input');
            if (field_input) {
                field_input.focus();
            }
        });
    }
    var collapse_buttons = sel('.mail-compose-field-collapse');
    for (var j = 0; j < collapse_buttons.length; j++) {
        listen(collapse_buttons[j], 'click', function (e) {
            e.preventDefault();
            var row_id = attr(e.currentTarget,
                'data-collapse-target');
            var toggle_id = attr(e.currentTarget,
                'data-restore-toggle');
            var row = elt(row_id);
            var toggle = elt(toggle_id);
            if (!row || !toggle) {return;}
            var field_input = row.querySelector(
                '.mail-compose-input');
            if (field_input) {
                field_input.value = '';
            }
            row.classList.add('none');
            toggle.classList.remove('none');
            toggle.setAttribute('aria-expanded', 'false');
            toggle.focus();
        });
    }
}
listen(document, 'DOMContentLoaded', initMailComposeRecipients);
/*
 * Wires up the inbox bulk-action UI plus the row-interaction
 * affordances:
 *   - per-row hidden checkbox carrying the message UID for the
 *     form submission. selected state shows via row highlight.
 *   - single-click on a row toggles its hidden checkbox.
 *   - double-click on a row opens the message.
 *   - shift-click extends selection from the last-clicked row.
 *   - Delete / Backspace keypress with rows selected triggers
 *     bulk-delete (with confirmation).
 *   - touch swipe-left reveals an inline Delete button on the
 *     row (mobile affordance).
 * Uses event delegation on the form so infinite-scroll rows
 * participate without re-binding. No-op on pages without an
 * inbox form.
 */
function initMailInboxBulk()
{
    var form = elt('mail-inbox-form');
    var bar = elt('mail-inbox-bulk-bar');
    var count_el = elt(
        'mail-inbox-bulk-count');
    var action_select = elt(
        'mail-inbox-bulk-action');
    var destination_select = elt(
        'mail-inbox-bulk-destination');
    var destination_account_select = elt(
        'mail-inbox-bulk-destination-account');
    var select_toggle = elt('mail-inbox-bulk-select-toggle');
    var mode_toggle = elt('mail-inbox-bulk-mode-toggle');
    var rows_tbody = elt('mail-inbox-rows');
    if (!form || !bar || !rows_tbody) {return;}
    /* selection-mode flag. when false (default), dbl-click on
       a row opens the message and subject-link clicks navigate
       there; the bulk-action controls only appear once a row
       is selected. when true, dbl-click and subject-link
       clicks instead toggle row selection so the user can
       multi-select on touch screens without accidentally
       opening a message. */
    var selection_mode = false;
    var move_value = bar.dataset.moveValue || '';
    /* current account id and the per-account list of selectable
       folder names, used to repopulate the destination folder
       select when the destination account changes (a cross-account
       move). Parsed once; an unparseable or absent map disables
       the account selector gracefully. */
    var current_account = bar.dataset.currentAccount || '';
    var move_folders = {};
    if (bar.dataset.moveFolders) {
        try {
            move_folders = JSON.parse(bar.dataset.moveFolders);
        } catch (parse_error) {
            move_folders = {};
        }
    }
    var instant_actions = (bar.dataset.instantActions || '').
        split(',').filter(function (s) {return s !== '';});
    /* anchor for shift-click range selection; the last row
       whose hidden checkbox state was toggled by user
       interaction -- not by infinite scroll. */
    var last_clicked_row = null;
    /* swipe-reveal commit threshold (px) and the smaller
       direction-lock threshold (px) at which we decide whether
       the gesture is horizontal or vertical. */
    const SWIPE_REVEAL_THRESHOLD = 60;
    const SWIPE_DIRECTION_LOCK_THRESHOLD = 12;
    var touch_state = null;
    function rowChecks()
    {
        return form.querySelectorAll(
            '.mail-inbox-check:not(:disabled)');
    }
    function checked()
    {
        return form.querySelectorAll(
            '.mail-inbox-check:checked:not(:disabled)');
    }
    function paintSelection()
    {
        /* sync the .mail-inbox-row-selected class on every row
           to that row's checkbox state, so the CSS rule can
           tint selected rows distinctly from unselected ones. */
        var rows = rows_tbody.children;
        for (var i = 0; i < rows.length; i++) {
            var checkbox = rows[i].querySelector(
                '.mail-inbox-check');
            if (!checkbox) {continue;}
            if (checkbox.checked) {
                rows[i].classList.add(
                    'mail-inbox-row-selected');
            } else {
                rows[i].classList.remove(
                    'mail-inbox-row-selected');
            }
        }
    }
    function refresh()
    {
        var all_count = rowChecks().length;
        var checked_count = checked().length;
        var all_selected = (all_count > 0 &&
            checked_count === all_count);
        if (checked_count === 0) {
            bar.classList.remove('has-selection');
            /* re-arm for the next selection: reset the action
               select to the placeholder and hide the destination
               picker so the user does not see a stale "Move..."
               prompt the next time they select messages. */
            if (action_select) {action_select.value = '';}
            if (destination_select) {
                destination_select.value = '';
                destination_select.classList.add('none');
            }
            if (destination_account_select) {
                destination_account_select.classList.add('none');
            }
        } else {
            bar.classList.add('has-selection');
            var template = bar.dataset.countText || '';
            count_el.textContent = template === '' ?
                String(checked_count) :
                template.replace('%s', checked_count);
        }
        if (select_toggle) {
            select_toggle.setAttribute('aria-pressed',
                all_selected ? 'true' : 'false');
            var label = all_selected ?
                select_toggle.dataset.labelNone :
                select_toggle.dataset.labelAll;
            if (label) {
                select_toggle.textContent = label;
            }
        }
        paintSelection();
    }
    function rowOf(target)
    {
        var node = target;
        while (node && node !== rows_tbody) {
            if (node.tagName === 'TR') {return node;}
            node = node.parentNode;
        }
        return null;
    }
    function checkboxIn(row)
    {
        if (!row) {return null;}
        return row.querySelector(
            '.mail-inbox-check:not(:disabled)');
    }
    function setRangeChecked(from_row, to_row, value)
    {
        if (!from_row || !to_row) {return;}
        /* normalize so iteration always goes top to bottom. */
        var rows = Array.prototype.slice.call(
            rows_tbody.children);
        var from_index = rows.indexOf(from_row);
        var to_index = rows.indexOf(to_row);
        if (from_index < 0 || to_index < 0) {return;}
        var low = Math.min(from_index, to_index);
        var high = Math.max(from_index, to_index);
        for (var i = low; i <= high; i++) {
            var checkbox = checkboxIn(rows[i]);
            if (checkbox) {checkbox.checked = value;}
        }
    }
    function clickBarDelete()
    {
        /* triggered by swipe-button-tap and keyboard-delete; set
           the action select to delete and submit. the submit
           handler is the single point where the user is asked
           to confirm, so we never get a double confirmation
           when any of these paths funnel through here. */
        if (!action_select) {return;}
        action_select.value = 'delete';
        if (typeof form.requestSubmit === 'function') {
            form.requestSubmit();
        } else {
            form.submit();
        }
    }
    /* event delegation: checkbox toggle refreshes the bar;
       action select on change either reveals destination (Move)
       or submits (Mark Read / Mark Unread / Delete); destination
       select on change submits when the chosen action is Move. */
    listen(form, 'change', function (e) {
        if (e.target && e.target.classList &&
            e.target.classList.contains('mail-inbox-check')) {
            refresh();
            return;
        }
        if (e.target === action_select) {
            var value = action_select.value;
            if (value === '') {
                if (destination_select) {
                    destination_select.classList.add('none');
                }
                if (destination_account_select) {
                    destination_account_select.classList.add('none');
                }
                return;
            }
            if (value === move_value) {
                if (destination_account_select) {
                    destination_account_select.value = current_account;
                    destination_account_select.classList.remove('none');
                }
                if (destination_select) {
                    populateDestinationFolders(current_account);
                    destination_select.classList.remove('none');
                    destination_select.focus();
                }
                return;
            }
            if (instant_actions.indexOf(value) >= 0) {
                if (destination_select) {
                    destination_select.classList.add('none');
                }
                if (destination_account_select) {
                    destination_account_select.classList.add('none');
                }
                if (typeof form.requestSubmit === 'function') {
                    form.requestSubmit();
                } else {
                    form.submit();
                }
            }
            return;
        }
        if (e.target === destination_account_select) {
            /* destination account changed: rebuild the folder
               list for that account and wait for a folder pick
               before submitting. */
            populateDestinationFolders(destination_account_select.value);
            return;
        }
        if (e.target === destination_select) {
            if (!action_select ||
                action_select.value !== move_value ||
                destination_select.value === '') {
                return;
            }
            if (typeof form.requestSubmit === 'function') {
                form.requestSubmit();
            } else {
                form.submit();
            }
        }
    });
    listen(rows_tbody, 'click', function (e) {
        /* clicks on the subject link normally navigate to open
           the message. in selection mode, prevent the
           navigation and toggle the row instead. */
        var is_link = (e.target && e.target.tagName === 'A');
        if (is_link && !selection_mode) {return;}
        if (is_link && selection_mode) {
            e.preventDefault();
        }
        var row = rowOf(e.target);
        var checkbox = checkboxIn(row);
        if (!checkbox) {return;}
        if (e.shiftKey && last_clicked_row) {
            /* shift-click range-select: set every row between
               the anchor and this row to the new state. */
            checkbox.checked = !checkbox.checked;
            setRangeChecked(last_clicked_row, row,
                checkbox.checked);
        } else {
            checkbox.checked = !checkbox.checked;
        }
        last_clicked_row = row;
        refresh();
    });
    listen(rows_tbody, 'dblclick', function (e) {
        /* in selection mode, dbl-click does not open -- the two
           underlying click events already toggled the row twice
           (net no-op). in normal mode, dbl-click opens. */
        if (selection_mode) {return;}
        if (e.target && e.target.tagName === 'A') {return;}
        var row = rowOf(e.target);
        if (!row) {return;}
        var link = row.querySelector('a[href]');
        if (link) {window.location.href = link.href;}
    });
    /* shared helpers for Cmd/Ctrl-A select-all. select toggles
       between all and none so the same gesture undoes itself. */
    function selectAll(value)
    {
        var all = rowChecks();
        for (var i = 0; i < all.length; i++) {
            all[i].checked = value;
        }
        last_clicked_row = null;
        refresh();
    }
    function toggleSelectAll()
    {
        var all_count = rowChecks().length;
        if (all_count === 0) {return;}
        selectAll(checked().length < all_count);
    }
    function isFormFocused(target)
    {
        if (!target) {return false;}
        var tag_name = target.tagName || '';
        if (tag_name === 'INPUT' || tag_name === 'TEXTAREA' ||
            tag_name === 'SELECT') {
            return true;
        }
        return !!target.isContentEditable;
    }
    /* Rebuilds the destination folder select to list the
       selectable folders of the chosen destination account. For
       the current account the active source folder is omitted (you
       cannot move within a folder to itself); for a different
       account every selectable folder is offered, since the same
       name in another account is a distinct place. Leaves a
       leading placeholder option selected so picking a folder is
       an explicit change that triggers the submit. */
    function populateDestinationFolders(account_id)
    {
        if (!destination_select) {return;}
        var names = move_folders[account_id] || [];
        var same_account = (String(account_id) ===
            String(current_account));
        var active_folder = destination_select.dataset.activeFolder || '';
        while (destination_select.options.length > 0) {
            destination_select.remove(0);
        }
        var placeholder = document.createElement('option');
        placeholder.value = '';
        placeholder.textContent =
            destination_select.dataset.placeholder || '';
        destination_select.appendChild(placeholder);
        for (var i = 0; i < names.length; i++) {
            if (same_account && names[i] === active_folder) {
                continue;
            }
            var option = document.createElement('option');
            option.value = names[i];
            option.textContent = names[i];
            destination_select.appendChild(option);
        }
        destination_select.value = '';
    }
    if (select_toggle) {
        listen(select_toggle, 'click', function (e) {
            e.preventDefault();
            toggleSelectAll();
        });
    }
    function applyModeState()
    {
        if (selection_mode) {
            bar.classList.add('selection-mode');
        } else {
            bar.classList.remove('selection-mode');
        }
        if (mode_toggle) {
            mode_toggle.setAttribute('aria-pressed',
                selection_mode ? 'true' : 'false');
            var label = selection_mode ?
                mode_toggle.dataset.labelOn :
                mode_toggle.dataset.labelOff;
            if (label) {
                mode_toggle.textContent = label;
            }
        }
    }
    if (mode_toggle) {
        listen(mode_toggle, 'click', function (e) {
            e.preventDefault();
            selection_mode = !selection_mode;
            applyModeState();
            /* leaving selection mode clears any selection so
               the user does not return to normal mode with
               stale row highlights they cannot easily manage
               from the collapsed bar. */
            if (!selection_mode && checked().length > 0) {
                selectAll(false);
            } else {
                refresh();
            }
        });
    }
    applyModeState();
    /* keyboard delete: only fires when focus is not in an
       input/textarea (so typing into Filter or Compose still
       works). Backspace also acts as delete here, matching
       the macOS convention. The submit handler does the
       confirm so this path does not double-confirm. */
    listen(document, 'keydown', function (e) {
        if (e.key === 'Delete' || e.key === 'Backspace') {
            if (isFormFocused(e.target)) {return;}
            if (checked().length === 0) {return;}
            e.preventDefault();
            clickBarDelete();
            return;
        }
        if ((e.key === 'a' || e.key === 'A') &&
            (e.metaKey || e.ctrlKey)) {
            if (isFormFocused(e.target)) {return;}
            e.preventDefault();
            toggleSelectAll();
        }
    });
    /* the one and only place that asks for confirmation
       before a bulk-delete submission. all delete trigger
       paths (action dropdown, keyboard, swipe button) end up
       here via form submit. */
    listen(form, 'submit', function (e) {
        var action = action_select ? action_select.value : '';
        if (action === 'delete') {
            var count = checked().length;
            if (count === 0) {
                e.preventDefault();
                return;
            }
            var template = (count === 1) ?
                bar.dataset.confirmOne :
                bar.dataset.confirmMany;
            if (!template) {
                /* localized strings should always be present on
                   the bulk bar's data attributes; if they're
                   missing, refuse to delete rather than fall
                   back to a hardcoded English prompt that would
                   surface to a non-English user. */
                e.preventDefault();
                return;
            }
            var message = template.replace('%s', count);
            if (!window.confirm(message)) {
                e.preventDefault();
            }
        }
    });
    /* touch swipe-left to reveal an inline delete button.
       horizontal-drag detection: track touchstart x/y; once
       the horizontal delta clears SWIPE_DIRECTION_LOCK_THRESHOLD
       and exceeds the vertical delta, treat the gesture as a
       horizontal swipe. on touchend, if the swipe was
       horizontal, toggle the row's revealed state. */
    listen(rows_tbody, 'touchstart', function (e) {
        if (e.touches.length !== 1) {return;}
        var row = rowOf(e.target);
        if (!row) {return;}
        touch_state = {
            row: row,
            x: e.touches[0].clientX,
            y: e.touches[0].clientY,
            locked: null,
        };
    });
    listen(rows_tbody, 'touchmove', function (e) {
        if (!touch_state || e.touches.length !== 1) {return;}
        var dx = e.touches[0].clientX - touch_state.x;
        var dy = e.touches[0].clientY - touch_state.y;
        if (touch_state.locked === null) {
            if (Math.abs(dx) > SWIPE_DIRECTION_LOCK_THRESHOLD &&
                Math.abs(dx) > Math.abs(dy)) {
                touch_state.locked = 'horizontal';
            } else if (Math.abs(dy) >
                SWIPE_DIRECTION_LOCK_THRESHOLD) {
                touch_state.locked = 'vertical';
            }
        }
    });
    listen(rows_tbody, 'touchend', function (e) {
        if (!touch_state) {return;}
        var state = touch_state;
        touch_state = null;
        if (state.locked !== 'horizontal') {return;}
        /* require the swipe to cross the commit threshold so a
           tiny accidental horizontal nudge does not toggle the
           reveal. changedTouches has the touch that just ended. */
        var end_x = (e.changedTouches && e.changedTouches[0]) ?
            e.changedTouches[0].clientX : state.x;
        if (Math.abs(end_x - state.x) < SWIPE_REVEAL_THRESHOLD) {
            return;
        }
        /* if any other row has the swipe-revealed class, clear
           it first so only one row at a time shows the
           affordance. */
        var prev = rows_tbody.querySelector(
            'tr.mail-inbox-row-swipe-revealed');
        if (prev && prev !== state.row) {
            prev.classList.remove(
                'mail-inbox-row-swipe-revealed');
        }
        state.row.classList.toggle(
            'mail-inbox-row-swipe-revealed');
    });
    /* clicking the inline swipe-revealed Delete cell triggers
       bulk-delete with just that row's UID, by checking only
       that row's checkbox and clicking the bar Delete button.
       confirmation comes from the form submit handler -- this
       path doesn't ask, to avoid double-confirm. */
    listen(rows_tbody, 'click', function (e) {
        if (!e.target.classList ||
            !e.target.classList.contains(
            'mail-inbox-row-swipe-delete')) {
            return;
        }
        var row = rowOf(e.target);
        if (!row) {return;}
        var checkbox = checkboxIn(row);
        if (!checkbox) {return;}
        /* deselect every other row so only this one is acted on. */
        var all = rowChecks();
        for (var i = 0; i < all.length; i++) {
            all[i].checked = (all[i] === checkbox);
        }
        refresh();
        clickBarDelete();
        row.classList.remove('mail-inbox-row-swipe-revealed');
    });
    refresh();
}
listen(document, 'DOMContentLoaded', initMailInboxBulk);
/*
 * Wires up the long-press / right-click edit-mode for the
 * side-panel account header and folder rows. Holding a touch
 * on an account name for ~500ms (or right-clicking on desktop)
 * swaps the name for an inline text input and reveals an inline
 * toolbar with the appropriate action buttons:
 *   account row: ✓ rename | ＋ new folder | ✎ edit account | ✕ delete account
 *   folder row (only when not protected): ✓ rename | ✕ delete
 * Escape or click outside the row cancels edit mode without
 * committing. Uses event delegation on document so lazy-loaded
 * folder rows work too.
 */
function initMailFolderOps()
{
    /* duration (ms) that a touch or mouse-button must be held
       before the gesture counts as a long-press and enters edit
       mode. matches the OS-level long-press timing on iOS and
       Android (~500ms). */
    const LONG_PRESS_DURATION = 500;
    /* small horizontal/vertical wiggle (px) tolerated during a
       long-press before we cancel and treat the gesture as a
       scroll or drag instead. */
    const LONG_PRESS_MOVE_TOLERANCE = 10;
    /* references to whichever row is currently in edit mode,
       and the original DOM/text we need to restore on cancel. */
    var edit_state = null;
    var press_timer = null;
    var press_start_x = 0;
    var press_start_y = 0;
    /* set true once a long-press has fired so the synthesized
       click that follows (touchend or mouseup) does not
       navigate or toggle. */
    var long_press_fired = false;
    function getCsrfToken()
    {
        var token = document.querySelector(
            'input[name="YIOOP_TOKEN"]');
        if (token && token.value) {
            return token.value;
        }
        /* fall back to scraping a token out of any link on the
           page that carries the YIOOP_TOKEN query parameter, in
           case the dispatcher rendered tokens into URLs but not
           into a form input. */
        var link = document.querySelector(
            'a[href*="YIOOP_TOKEN="]');
        if (!link) {return '';}
        var match = link.href.match(/YIOOP_TOKEN=([^&]+)/);
        return match ? decodeURIComponent(match[1]) : '';
    }
    function getMailUrl()
    {
        /* the user_mail entry-point URL is exposed implicitly via
           any account toggle's data-toggle-url, which already
           carries the framework's controller routing. strip the
           toggle-specific query and rebuild for the action. */
        var toggle = document.querySelector(
            '.user-accounts-toggle[data-toggle-url]');
        if (!toggle) {return '';}
        var url = toggle.dataset.toggleUrl;
        /* the toggle URL ends in &arg=toggleAccount&...; clip at
           the first arg= and the caller will append its own. */
        var arg_at = url.indexOf('&arg=');
        if (arg_at >= 0) {
            return url.substring(0, arg_at);
        }
        return url;
    }
    function makeButton(glyph, title, click_handler)
    {
        var button = ce('button');
        button.type = 'button';
        button.className = 'user-accounts-edit-btn';
        button.title = title;
        button.setAttribute('aria-label', title);
        button.innerHTML = glyph;
        listen(button, 'click', function (event) {
            event.stopPropagation();
            click_handler(event);
        });
        return button;
    }
    function decodeHtmlEntities(text)
    {
        var decoder = ce('textarea');
        decoder.innerHTML = text;
        return decoder.value;
    }
    function makeLinkButton(glyph, title, href, confirm_message)
    {
        var link = ce('a');
        link.className = 'user-accounts-edit-btn';
        link.title = title;
        link.setAttribute('aria-label', title);
        link.innerHTML = glyph;
        link.href = decodeHtmlEntities(href);
        if (confirm_message) {
            listen(link, 'click', function (event) {
                if (!window.confirm(confirm_message)) {
                    event.preventDefault();
                }
            });
        }
        return link;
    }
    function postForm(action_args, body_fields)
    {
        var token = getCsrfToken();
        var base_url = getMailUrl();
        if (token === '' || base_url === '') {return;}
        var form = ce('form');
        form.method = 'post';
        form.action = base_url + '&arg=' +
            encodeURIComponent(action_args.arg);
        var token_field = ce('input');
        token_field.type = 'hidden';
        token_field.name = 'YIOOP_TOKEN';
        token_field.value = token;
        form.appendChild(token_field);
        for (var arg in action_args) {
            if (arg === 'arg') {continue;}
            var arg_field = ce('input');
            arg_field.type = 'hidden';
            arg_field.name = arg;
            arg_field.value = action_args[arg];
            form.appendChild(arg_field);
        }
        for (var field in body_fields) {
            var body_field = ce('input');
            body_field.type = 'hidden';
            body_field.name = field;
            body_field.value = body_fields[field];
            form.appendChild(body_field);
        }
        document.body.appendChild(form);
        form.submit();
    }
    function cancelEditMode()
    {
        if (!edit_state) {return;}
        /* restore the original name display by replacing the
           edited-node parent's contents with the saved markup. */
        if (edit_state.host && edit_state.original_html !== null) {
            edit_state.host.innerHTML = edit_state.original_html;
        }
        if (edit_state.toolbar && edit_state.toolbar.parentNode) {
            edit_state.toolbar.parentNode.removeChild(
                edit_state.toolbar);
        }
        if (edit_state.new_folder_box &&
            edit_state.new_folder_box.parentNode) {
            edit_state.new_folder_box.parentNode.removeChild(
                edit_state.new_folder_box);
        }
        if (edit_state.editing_element) {
            edit_state.editing_element.classList.remove(
                'user-accounts-name-editing');
            edit_state.editing_element.classList.remove(
                'user-accounts-folder-row-editing');
        }
        edit_state = null;
    }
    function enterAccountEditMode(account_header)
    {
        /* While the account is being cloned, rename / new-folder
           / edit / delete are all locked: the source account's
           IMAP credentials are mid-use by MailCloneJob and the
           destination mailbox is mid-append. The h3 carries
           data-cloning="1" in that case (see MailElement). */
        if (account_header.dataset.cloning === '1') {
            return;
        }
        cancelEditMode();
        var account_id = account_header.dataset.accountId;
        var display_name =
            account_header.dataset.accountDisplayName || '';
        var edit_url = account_header.dataset.editUrl || '';
        var delete_url = account_header.dataset.deleteUrl || '';
        var delete_confirm =
            account_header.dataset.deleteConfirm || '';
        var name_span = account_header.querySelector(
            '.user-accounts-display-name');
        if (!name_span) {return;}
        var original = name_span.innerHTML;
        var no_rename = (account_header.dataset.noRename === '1');
        var name_field = ce('input');
        name_field.type = 'text';
        name_field.className = 'user-accounts-rename-input';
        name_field.value = display_name;
        name_field.setAttribute('aria-label',
            tlData('mail_element_account_rename_label'));
        listen(name_field, 'click', function (event) {
            event.stopPropagation();
        });
        listen(name_field, 'keydown', function (event) {
            if (event.key === 'Enter') {
                event.preventDefault();
                commit();
            } else if (event.key === 'Escape') {
                event.preventDefault();
                cancelEditMode();
            }
        });
        if (!no_rename) {
            name_span.innerHTML = '';
            name_span.appendChild(name_field);
        }
        function commit()
        {
            var new_value = name_field.value.trim();
            if (new_value === '' || new_value === display_name) {
                cancelEditMode();
                return;
            }
            postForm({arg: 'accountRename',
                account_id: account_id},
                {new_name: new_value});
        }
        var toolbar = ce('span');
        toolbar.className = 'user-accounts-edit-toolbar';
        listen(toolbar, 'click', function (event) {
            event.stopPropagation();
        });
        if (!no_rename) {
            toolbar.appendChild(makeButton('&#10003;',
                tlData('mail_element_save'), commit));
        }
        toolbar.appendChild(makeButton('&#43;',
            tlData('mail_element_folder_new'),
            function () { showNewFolderBox(); }));
        if (edit_url) {
            toolbar.appendChild(makeLinkButton('&#x270E;',
                tlData('mail_element_edit_link'),
                edit_url, ''));
        }
        if (delete_url) {
            toolbar.appendChild(makeLinkButton('&#x2715;',
                tlData('mail_element_delete_link'),
                delete_url, delete_confirm));
        }
        account_header.appendChild(toolbar);
        function showNewFolderBox()
        {
            if (edit_state && edit_state.new_folder_box) {
                edit_state.new_folder_box.querySelector(
                    'input[type=text]').focus();
                return;
            }
            var box = ce('div');
            box.className = 'user-accounts-new-folder-box';
            var folder_field = ce('input');
            folder_field.type = 'text';
            folder_field.className =
                'user-accounts-new-folder-input';
            folder_field.setAttribute('placeholder',
                tlData('mail_element_folder_new_placeholder'));
            folder_field.setAttribute('aria-label',
                tlData('mail_element_folder_new_placeholder'));
            listen(folder_field, 'click', function (event) {
                event.stopPropagation();
            });
            listen(folder_field, 'keydown', function (event) {
                if (event.key === 'Enter') {
                    event.preventDefault();
                    folderCommit();
                } else if (event.key === 'Escape') {
                    event.preventDefault();
                    cancelEditMode();
                }
            });
            function folderCommit()
            {
                var new_value = folder_field.value.trim();
                if (new_value === '') {
                    cancelEditMode();
                    return;
                }
                postForm({arg: 'folderAction',
                    account_id: account_id},
                    {folder_action: 'create',
                    new_name: new_value});
            }
            var ok = makeButton('&#10003;',
                tlData('mail_element_save'), folderCommit);
            box.appendChild(folder_field);
            box.appendChild(ok);
            account_header.parentNode.insertBefore(box,
                account_header.nextSibling);
            if (edit_state) {
                edit_state.new_folder_box = box;
            }
            folder_field.focus();
        }
        account_header.classList.add('user-accounts-name-editing');
        edit_state = {
            kind: 'account',
            row: account_header.parentNode,
            host: name_span,
            original_html: original,
            toolbar: toolbar,
            new_folder_box: null,
            editing_element: account_header,
        };
        name_field.focus();
        name_field.select();
    }
    function enterFolderEditMode(folder_row)
    {
        /* synthetic / Noselect parents have no underlying mailbox
           to rename, delete, or host subfolders in; the IMAP
           server will reject any CREATE/RENAME/DELETE against
           them. skip edit mode entirely. */
        if (folder_row.classList.contains(
            'user-accounts-folder-noselect')) {
            return;
        }
        /* While the folder's owning account is being cloned, all
           per-folder edits are locked too: renaming a folder
           mid-clone would invalidate the UIDVALIDITY-keyed dedup
           cursor in MAIL_CLONE_SEEN and corrupt the resume state.
           The containing .user-accounts-folders ul carries
           data-cloning="1" in that case. */
        var folder_list = folder_row.closest(
            '.user-accounts-folders');
        if (folder_list &&
            folder_list.dataset.cloning === '1') {
            return;
        }
        cancelEditMode();
        var account_id = folder_row.dataset.accountId;
        var folder_name = folder_row.dataset.folderName;
        var delimiter = folder_row.dataset.folderDelimiter || '/';
        var is_protected = folder_row.dataset.protected === '1';
        var can_create_child =
            folder_row.dataset.canCreateChild === '1';
        /* split folder_name into parent path + leaf segment so a
           nested folder edits only its leaf and reassembles in
           place. unrelated to display_name (which is already the
           leaf segment) since the rename input wants the raw
           value, not the bracket-stripped display form. */
        var last_delimiter = folder_name.lastIndexOf(delimiter);
        var parent_path = last_delimiter < 0 ? '' :
            folder_name.substring(0, last_delimiter);
        var leaf_segment = last_delimiter < 0 ? folder_name :
            folder_name.substring(last_delimiter + 1);
        var name_span = folder_row.querySelector(
            '.user-accounts-folder-name');
        var name_field = null;
        var original = null;
        if (!is_protected && name_span) {
            original = name_span.innerHTML;
            name_field = ce('input');
            name_field.type = 'text';
            name_field.className = 'user-accounts-rename-input';
            name_field.value = leaf_segment;
            name_field.setAttribute('aria-label',
                tlData('mail_element_folder_rename'));
            listen(name_field, 'click', function (event) {
                event.stopPropagation();
            });
            listen(name_field, 'keydown', function (event) {
                if (event.key === 'Enter') {
                    event.preventDefault();
                    commit();
                } else if (event.key === 'Escape') {
                    event.preventDefault();
                    cancelEditMode();
                }
            });
            name_span.innerHTML = '';
            name_span.appendChild(name_field);
        }
        function commit()
        {
            if (!name_field) {return;}
            var new_value = name_field.value.trim();
            if (new_value === '' || new_value === leaf_segment) {
                cancelEditMode();
                return;
            }
            var full_new = parent_path === '' ? new_value :
                parent_path + delimiter + new_value;
            postForm({arg: 'folderAction',
                account_id: account_id},
                {folder_action: 'rename', folder: folder_name,
                new_name: full_new});
        }
        function deleteFolder()
        {
            var message = folder_row.dataset.deleteConfirm ||
                tlData('mail_element_folder_delete_confirm');
            message = message.replace('%s', folder_name);
            if (!window.confirm(message)) {return;}
            postForm({arg: 'folderAction',
                account_id: account_id},
                {folder_action: 'delete', folder: folder_name});
        }
        function createChild()
        {
            var prompt_label = tlData(
                'mail_element_folder_new_placeholder');
            var child_name = window.prompt(prompt_label, '');
            if (child_name === null) {return;}
            child_name = child_name.trim();
            if (child_name === '') {return;}
            var full_new = folder_name + delimiter + child_name;
            postForm({arg: 'folderAction',
                account_id: account_id},
                {folder_action: 'create', new_name: full_new});
        }
        var toolbar = ce('span');
        toolbar.className = 'user-accounts-edit-toolbar';
        listen(toolbar, 'click', function (event) {
            event.stopPropagation();
        });
        if (!is_protected && name_field) {
            toolbar.appendChild(makeButton('&#10003;',
                tlData('mail_element_save'), commit));
            toolbar.appendChild(makeButton('&#x2715;',
                tlData('mail_element_folder_delete'),
                deleteFolder));
        }
        if (can_create_child) {
            toolbar.appendChild(makeButton('&#xFF0B;',
                tlData('mail_element_folder_new_child'),
                createChild));
        }
        /* nothing to do if neither rename nor create-child is
           available on this row -- bail out without entering
           edit mode rather than showing an empty toolbar. */
        if (toolbar.children.length === 0) {
            if (name_span && original !== null) {
                name_span.innerHTML = original;
            }
            return;
        }
        folder_row.appendChild(toolbar);
        folder_row.classList.add('user-accounts-folder-row-editing');
        edit_state = {
            kind: 'folder',
            row: folder_row,
            host: name_span,
            original_html: original,
            toolbar: toolbar,
            new_folder_box: null,
            editing_element: folder_row,
        };
        if (name_field) {
            name_field.focus();
            name_field.select();
        }
    }
    function tlData(key)
    {
        /* localized strings come from data-* attributes on a
           hidden element the template emits once per page; if
           the element or key is missing the value is empty (the
           dialogs / aria-labels then degrade gracefully without
           leaking English fallback text). */
        var strings = elt(
            'mail-folder-ops-strings');
        if (!strings) {return '';}
        var key_camel = camelCase(key);
        if (!strings.dataset[key_camel]) {return '';}
        return strings.dataset[key_camel];
    }
    function camelCase(source)
    {
        return source.replace(/_([a-z])/g,
            function (match, letter) {
                return letter.toUpperCase();
            });
    }
    function startPressTimer(account_header, folder_row)
    {
        long_press_fired = false;
        if (press_timer) {clearTimeout(press_timer);}
        press_timer = setTimeout(function () {
            press_timer = null;
            long_press_fired = true;
            if (account_header) {
                enterAccountEditMode(account_header);
            } else if (folder_row) {
                enterFolderEditMode(folder_row);
            }
        }, LONG_PRESS_DURATION);
    }
    function cancelPressTimer()
    {
        if (press_timer) {
            clearTimeout(press_timer);
            press_timer = null;
        }
    }
    function pressTargets(target)
    {
        if (!target || !target.closest) {return [null, null];}
        return [
            target.closest('.user-accounts-name'),
            target.closest('.user-accounts-folder-row'),
        ];
    }
    /* long-press detection on touch. */
    listen(document, 'touchstart', function (event) {
        if (event.touches.length !== 1) {return;}
        var targets = pressTargets(event.target);
        if (!targets[0] && !targets[1]) {return;}
        press_start_x = event.touches[0].clientX;
        press_start_y = event.touches[0].clientY;
        startPressTimer(targets[0], targets[1]);
    });
    listen(document, 'touchmove', function (event) {
        if (!press_timer || event.touches.length !== 1) {return;}
        var dx = Math.abs(
            event.touches[0].clientX - press_start_x);
        var dy = Math.abs(
            event.touches[0].clientY - press_start_y);
        if (dx > LONG_PRESS_MOVE_TOLERANCE ||
            dy > LONG_PRESS_MOVE_TOLERANCE) {
            cancelPressTimer();
        }
    });
    listen(document, 'touchend', cancelPressTimer);
    listen(document, 'touchcancel', cancelPressTimer);
    /* long-press detection on mouse: hold the left button down
       on a long-press zone for LONG_PRESS_DURATION to enter
       edit mode. desktop Firefox does not fire touchstart for
       mouse, so without this path the touch handler never
       starts the timer and long-press never fires on desktop. */
    listen(document, 'mousedown', function (event) {
        if (event.button !== 0) {return;}
        var targets = pressTargets(event.target);
        if (!targets[0] && !targets[1]) {return;}
        press_start_x = event.clientX;
        press_start_y = event.clientY;
        startPressTimer(targets[0], targets[1]);
    });
    listen(document, 'mousemove', function (event) {
        if (!press_timer) {return;}
        var dx = Math.abs(event.clientX - press_start_x);
        var dy = Math.abs(event.clientY - press_start_y);
        if (dx > LONG_PRESS_MOVE_TOLERANCE ||
            dy > LONG_PRESS_MOVE_TOLERANCE) {
            cancelPressTimer();
        }
    });
    listen(document, 'mouseup', cancelPressTimer);
    listen(document, 'mouseleave', cancelPressTimer);
    /* the click that the browser synthesizes after a press
       sequence which fired a long-press would otherwise navigate
       (folder anchor) or toggle (account name) -- swallow it. */
    listen(document, 'click', function (event) {
        if (long_press_fired) {
            long_press_fired = false;
            var target = event.target;
            var inside_edit = edit_state && edit_state.row &&
                edit_state.row.contains(target);
            if (inside_edit) {
                event.preventDefault();
                event.stopPropagation();
                return;
            }
        }
    }, true);
    /* right-click on desktop is the same gesture. */
    listen(document, 'contextmenu', function (event) {
        var targets = pressTargets(event.target);
        if (targets[0]) {
            event.preventDefault();
            enterAccountEditMode(targets[0]);
        } else if (targets[1]) {
            event.preventDefault();
            enterFolderEditMode(targets[1]);
        }
    });
    /* click anywhere outside the editing row cancels. */
    listen(document, 'click', function (event) {
        if (!edit_state) {return;}
        var inside_row = edit_state.row.contains(event.target);
        var inside_new_folder = edit_state.new_folder_box &&
            edit_state.new_folder_box.contains(event.target);
        if (!inside_row && !inside_new_folder) {
            cancelEditMode();
        }
    });
    /* Escape from anywhere cancels too. */
    listen(document, 'keydown', function (event) {
        if (event.key === 'Escape' && edit_state) {
            cancelEditMode();
        }
    });
}
listen(document, 'DOMContentLoaded', initMailFolderOps);
/*
 * Wires up the click-on-whitespace deselect for the side
 * column. When the user clicks in the side column on inert
 * space (not an account header, not a folder row, not the
 * social controls), navigates to the mail entry-point with
 * no arg -- which renders the empty-state "Select an Account"
 * pane in the content area. No-op when the column has no
 * data-deselect-url (e.g. mobile-with-selection where the
 * column is not rendered).
 */
function initMailColumnDeselect()
{
    var column = document.querySelector(
        '.user-accounts[data-deselect-url]');
    if (!column) {return;}
    listen(column, 'click', function (event) {
        if (!event.target || !event.target.closest) {return;}
        /* clicks on any actionable descendant should pass
           through to that element's own handler -- only clicks
           on inert whitespace deselect. */
        var interactive = event.target.closest(
            '.user-accounts-name, .user-accounts-folder-row, ' +
            '.user-accounts-folder-noselect, ' +
            '.user-accounts-edit-toolbar, ' +
            '.user-accounts-new-folder-box, ' +
            '.socialcontrols-wrap, a, button, input, ' +
            'select, textarea, label');
        if (interactive) {return;}
        window.location.href = column.dataset.deselectUrl;
    });
}
listen(document, 'DOMContentLoaded', initMailColumnDeselect);
/*
 * Wires up the compose schedule-send split-button. The visible Send
 * button submits the form unchanged when no time has been picked;
 * the caret button opens a popover with quick presets (tomorrow
 * morning / afternoon / pick custom) and a datetime-local input
 * for ad-hoc times. When a time is picked, the hidden
 * #mail-compose-scheduled-at field is set to a unix timestamp, the
 * visible Send button label changes to "Schedule" + a status row
 * appears showing the formatted target time. The caret-only path
 * means the form posts the same way whether scheduled or not; the
 * server-side handler branches on scheduled_at > 0.
 *
 * Time formatting uses the browser's Intl.DateTimeFormat with the
 * user's locale so the displayed time matches what they would see
 * in their OS clock. The data-pattern attribute on the status
 * element carries the localized "Scheduled for %s" string so RTL
 * and non-English locales can position the time correctly.
 */
function initMailComposeSchedule()
{
    var split = elt('mail-compose-send-split');
    var caret = elt('mail-compose-schedule-toggle');
    var popover = elt('mail-compose-schedule-popover');
    var send_button = elt('mail-compose-send-button');
    var hidden = elt('mail-compose-scheduled-at');
    var status = elt('mail-compose-schedule-status');
    var clear = elt('mail-compose-schedule-clear');
    var choose = elt('mail-compose-schedule-choose');
    var custom = elt('mail-compose-schedule-custom');
    var custom_input = elt('mail-compose-schedule-input');
    if (!split || !caret || !popover || !send_button || !hidden) {
        return;
    }
    var schedule_label = attr(split, 'data-schedule-button-label');
    var send_label = attr(split, 'data-send-button-label');
    var pattern = status ? attr(status, 'data-pattern') : '%s';
    function openPopover()
    {
        popover.classList.remove('none');
        caret.setAttribute('aria-expanded', 'true');
    }
    function closePopover()
    {
        popover.classList.add('none');
        caret.setAttribute('aria-expanded', 'false');
        custom.classList.add('none');
    }
    function setScheduled(timestamp)
    {
        if (timestamp <= 0) {
            clearScheduled();
            return;
        }
        hidden.value = String(timestamp);
        send_button.textContent = schedule_label;
        if (status) {
            var when = new Date(timestamp * 1000);
            var formatted = when.toLocaleString();
            status.textContent = pattern.replace('%s', formatted);
            status.classList.remove('none');
        }
        if (clear) {clear.classList.remove('none');}
        closePopover();
    }
    function clearScheduled()
    {
        hidden.value = '0';
        send_button.textContent = send_label;
        if (status) {
            status.textContent = '';
            status.classList.add('none');
        }
        if (clear) {clear.classList.add('none');}
    }
    function presetTimestamp(preset)
    {
        var d = new Date();
        d.setDate(d.getDate() + 1);
        if (preset === 'tomorrow_morning') {
            d.setHours(9, 0, 0, 0);
        } else if (preset === 'tomorrow_afternoon') {
            d.setHours(17, 0, 0, 0);
        }
        return Math.floor(d.getTime() / 1000);
    }
    listen(caret, 'click', function (e) {
        e.preventDefault();
        if (popover.classList.contains('none')) {
            openPopover();
        } else {
            closePopover();
        }
    });
    var presets = popover.querySelectorAll(
        '.mail-compose-schedule-preset[data-preset]');
    for (var i = 0; i < presets.length; i++) {
        listen(presets[i], 'click', function (e) {
            e.preventDefault();
            var preset = attr(e.currentTarget, 'data-preset');
            setScheduled(presetTimestamp(preset));
        });
    }
    if (choose && custom && custom_input) {
        listen(choose, 'click', function (e) {
            e.preventDefault();
            custom.classList.remove('none');
            custom_input.focus();
        });
        listen(custom_input, 'change', function () {
            var v = custom_input.value;
            if (!v) {return;}
            var d = new Date(v);
            if (isNaN(d.getTime())) {return;}
            setScheduled(Math.floor(d.getTime() / 1000));
        });
    }
    if (clear) {
        listen(clear, 'click', function (e) {
            e.preventDefault();
            clearScheduled();
        });
    }
    listen(document, 'click', function (e) {
        if (popover.classList.contains('none')) {return;}
        if (split.contains(e.target)) {return;}
        closePopover();
    });
    listen(document, 'keydown', function (e) {
        if (e.key === 'Escape' &&
                !popover.classList.contains('none')) {
            closePopover();
            caret.focus();
        }
    });
}
listen(document, 'DOMContentLoaded', initMailComposeSchedule);
/*
 * Enables drag-to-reorder of the mail accounts in the side column.
 * Only rows marked .user-accounts-reorderable participate (the
 * pinned local MailSite account and any account with an active
 * clone are excluded), and only the grip handle starts a drag so
 * the account-name click-to-expand still works. Supports mouse
 * drag and keyboard (Space/Enter to grab, ArrowUp/ArrowDown to
 * move, Space/Enter to drop, Escape to cancel). On drop the new
 * order of account ids is sent to the reorderAccounts endpoint so
 * it persists across page loads.
 */
function initMailAccountReorder()
{
    var list = document.querySelector('.user-accounts-list');
    if (!list) {
        return;
    }
    var reorder_url = attr(list, 'data-reorder-url');
    if (!reorder_url) {
        return;
    }
    var dragged = null;
    var keyboard_grabbed = null;
    function reorderableRows()
    {
        return Array.prototype.slice.call(
            list.querySelectorAll('.user-accounts-reorderable'));
    }
    /* GET the current DOM order of account ids to the endpoint. */
    function persistOrder()
    {
        var ids = reorderableRows().map(function (row) {
            return attr(row, 'data-account-id');
        }).filter(function (value) {
            return value !== null && value !== '';
        });
        if (ids.length === 0) {
            return;
        }
        var url = reorder_url + '&order=' +
            encodeURIComponent(ids.join(','));
        fetch(url, {
            method: 'GET',
            headers: {
                'Accept': 'application/json'
            }
        })
        .catch(function (error) {
        });
    }
    function clearOverMarks()
    {
        reorderableRows().forEach(function (row) {
            row.classList.remove('user-accounts-drag-over');
        });
    }
    /* Move grabbed row up (-1) or down (1) among reorderable
       siblings, for keyboard reordering. */
    function keyboardMove(row, direction)
    {
        var rows = reorderableRows();
        var index = rows.indexOf(row);
        var target = index + direction;
        if (target < 0 || target >= rows.length) {
            return;
        }
        if (direction < 0) {
            list.insertBefore(row, rows[target]);
        } else {
            list.insertBefore(row, rows[target].nextSibling);
        }
        row.focus();
    }
    reorderableRows().forEach(function (row) {
        var handle = row.querySelector(
            '.user-accounts-drag-handle');
        if (handle) {
            handle.setAttribute('tabindex', '0');
            handle.setAttribute('role', 'button');
        }
        listen(row, 'dragstart', function (e) {
            dragged = row;
            if (e.dataTransfer) {
                e.dataTransfer.effectAllowed = 'move';
            }
            setTimeout(function () {
                row.classList.add('user-accounts-dragging');
            }, 0);
        });
        listen(row, 'dragend', function () {
            row.classList.remove('user-accounts-dragging');
            clearOverMarks();
            dragged = null;
            persistOrder();
        });
        listen(row, 'dragover', function (e) {
            e.preventDefault();
            if (!dragged || row === dragged) {
                return;
            }
            var box = row.getBoundingClientRect();
            var mid = box.top + box.height / 2;
            clearOverMarks();
            row.classList.add('user-accounts-drag-over');
            if (e.clientY < mid) {
                list.insertBefore(dragged, row);
            } else {
                list.insertBefore(dragged, row.nextSibling);
            }
        });
        listen(row, 'drop', function (e) {
            e.preventDefault();
        });
        if (handle) {
            listen(handle, 'keydown', function (e) {
                if (e.key === ' ' || e.key === 'Enter') {
                    e.preventDefault();
                    if (keyboard_grabbed === row) {
                        row.classList.remove(
                            'user-accounts-grabbed');
                        keyboard_grabbed = null;
                        persistOrder();
                    } else {
                        if (keyboard_grabbed) {
                            keyboard_grabbed.classList.remove(
                                'user-accounts-grabbed');
                        }
                        keyboard_grabbed = row;
                        row.classList.add(
                            'user-accounts-grabbed');
                    }
                } else if (e.key === 'ArrowUp') {
                    if (keyboard_grabbed === row) {
                        e.preventDefault();
                        keyboardMove(row, -1);
                    }
                } else if (e.key === 'ArrowDown') {
                    if (keyboard_grabbed === row) {
                        e.preventDefault();
                        keyboardMove(row, 1);
                    }
                } else if (e.key === 'Escape') {
                    if (keyboard_grabbed === row) {
                        e.preventDefault();
                        row.classList.remove(
                            'user-accounts-grabbed');
                        keyboard_grabbed = null;
                    }
                }
            });
        }
    });
}
listen(document, 'DOMContentLoaded', initMailAccountReorder);

/**
 * checkComposeBeforeSend looks over the compose form for the faults it
 * can see from the browser, and stops the send where it finds one. A
 * form that is sent and comes back with an error loses the chosen file,
 * because a browser does not let a page fill a file chooser back in, so
 * a fault caught here saves the person attaching the file again. What
 * it cannot see, such as whether an address exists, is left to the
 * send, which checks everything either way.
 */
function checkComposeBeforeSend()
{
    let form = elt('mail-compose-form');
    if (!form) {
        return;
    }
    let say = function (message)
    {
        let box = elt('mail-compose-client-error');
        if (!box) {
            box = document.createElement('p');
            box.id = 'mail-compose-client-error';
            box.className = 'red';
            box.setAttribute('role', 'alert');
            form.insertBefore(box, form.firstChild);
        }
        box.textContent = message;
        box.classList.remove('none');
    };
    let clearSaid = function ()
    {
        let box = elt('mail-compose-client-error');
        if (box) {
            box.classList.add('none');
        }
    };
    /* An address written as a bare name belongs to somebody on this
       site, so the site's own mail domain is put on it here as well as
       at the send, and what the person sees matches what is sent. */
    let completeAddresses = function (written, domain)
    {
        if (!domain) {
            return written;
        }
        return written.split(',').map(function (one)
        {
            let spec = one.trim();
            if (spec === '' || spec.indexOf('@') >= 0 ||
                spec.indexOf('<') >= 0 || /\s/.test(spec)) {
                return spec;
            }
            return spec + '@' + domain;
        }).filter(function (one)
        {
            return one !== '';
        }).join(', ');
    };
    let looksLikeAddress = function (spec)
    {
        let bare = spec;
        let open = spec.lastIndexOf('<');
        let close = spec.lastIndexOf('>');
        if (open >= 0 && close > open) {
            bare = spec.substring(open + 1, close);
        }
        bare = bare.trim();
        /* A domain with no dot in it, such as the name a site is reached
           by on its own machine, is a real place to deliver, so the
           check asks only for a name, an at sign, and a domain. */
        return /^[^\s@]+@[^\s@]+$/.test(bare);
    };
    listen(form, 'submit', function (event)
    {
        clearSaid();
        let domain = attr(form, 'data-mail-domain');
        let to_field = elt('mail-compose-to');
        if (to_field) {
            to_field.value = completeAddresses(to_field.value, domain);
        }
        ['mail-compose-cc', 'mail-compose-bcc'].forEach(function (id)
        {
            let field = elt(id);
            if (field) {
                field.value = completeAddresses(field.value, domain);
            }
        });
        let written = to_field ? to_field.value.trim() : '';
        if (written === '') {
            event.preventDefault();
            say(attr(form, 'data-error-to-required'));
            if (to_field) {
                to_field.focus();
            }
            return;
        }
        let specs = written.split(',');
        for (let i = 0; i < specs.length; i++) {
            let spec = specs[i].trim();
            if (spec !== '' && !looksLikeAddress(spec)) {
                event.preventDefault();
                say(attr(form, 'data-error-address-invalid'
                    ).replace('%s', spec));
                if (to_field) {
                    to_field.focus();
                }
                return;
            }
        }
        let body = elt('mail-compose-body');
        let body_max = parseInt(attr(form, 'data-body-max-len'), 10);
        if (body && body_max > 0 && body.value.length > body_max) {
            event.preventDefault();
            say(attr(form, 'data-error-body-long'));
            body.focus();
            return;
        }
        let chooser = elt('mail-compose-attachments');
        if (!chooser || !chooser.files) {
            return;
        }
        let max_count = parseInt(attr(form, 'data-attach-max-count'), 10);
        if (max_count > 0 && chooser.files.length > max_count) {
            event.preventDefault();
            say(attr(form, 'data-error-too-many'));
            return;
        }
        let max_bytes = parseInt(attr(form, 'data-attach-max-bytes'), 10);
        for (let i = 0; i < chooser.files.length; i++) {
            if (max_bytes > 0 && chooser.files[i].size > max_bytes) {
                event.preventDefault();
                say(attr(form, 'data-error-too-large'
                    ).replace('%s', chooser.files[i].name));
                return;
            }
        }
    });
}
listen(document, 'DOMContentLoaded', checkComposeBeforeSend);
